Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/core/src/filesystem/watcher-inotify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export * as WatcherInotify from "./watcher-inotify"

import { dlopen, FFIType, read } from "bun:ffi"
import os from "os"
import { lazy } from "../util/lazy"

declare const OPENCODE_LIBC: string | undefined

// The flags @parcel/watcher's InotifyBackend passes to inotify_init1. Probing
// with any other flags would not prove that the real call can succeed.
const IN_NONBLOCK = 0o4000
const IN_CLOEXEC = 0o2000000

export interface ProbeFailure {
readonly errno: number
readonly code: string
}

const errnoCodes = Object.entries(os.constants.errno)

const libc = lazy(() => {
const musl = `libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const glibc = "libc.so.6"
const preferred = (typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC) === "musl" ? musl : glibc
for (const candidate of [preferred, preferred === musl ? glibc : musl, "libc.so"]) {
try {
return dlopen(candidate, {
inotify_init1: { args: [FFIType.i32], returns: FFIType.i32 },
close: { args: [FFIType.i32], returns: FFIType.i32 },
__errno_location: { args: [], returns: FFIType.ptr },
}).symbols
} catch {
continue
}
}
return
})

/**
* Ask the kernel for one inotify instance and immediately give it back.
*
* `@parcel/watcher` builds its shared inotify backend inside the synchronous
* constructor of the N-API `SubscribeRunner`, on whichever thread called
* `subscribe()`. When `inotify_init1` fails there, `InotifyBackend::start()`
* throws before `notifyStarted()`, and `Backend::handleError()` only notifies
* already-registered watchers - so `Backend::run()` keeps waiting on
* `mStartedSignal` and the calling thread never returns. For opencode that
* thread is the JS main thread, which parks the event loop permanently: no
* timer, no fiber and no interrupt can run afterwards, which is why the failure
* produced neither a log line nor an abortable turn.
*
* Returning the errno instead of a boolean keeps the caller honest: EMFILE can
* mean either the per-process descriptor limit or the per-uid
* `fs.inotify.max_user_instances` ceiling, and this cannot tell them apart.
*
* Returns `undefined` when an instance is obtainable, or when the probe itself
* cannot run (no `bun:ffi`, no libc, missing symbols). An unprobeable runtime
* keeps the pre-existing behaviour rather than silently losing file watching.
*/
export function probe(): ProbeFailure | undefined {
const symbols = libc()
if (!symbols) return

const fd = symbols.inotify_init1(IN_NONBLOCK | IN_CLOEXEC)
if (fd !== -1) {
symbols.close(fd)
return
}

// errno is thread-local and is clobbered by the next failing libc call, so it
// has to be read before anything else happens on this thread.
const location = symbols.__errno_location()
const errno = location ? read.i32(location) : 0
const code = errnoCodes.find(([, value]) => value === errno)
return { errno, code: code ? code[0] : "UNKNOWN" }
}
208 changes: 130 additions & 78 deletions packages/core/src/filesystem/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Location } from "../location"
import { lazy } from "../util/lazy"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
import type { ProbeFailure } from "./watcher-inotify"

declare const OPENCODE_LIBC: string | undefined

Expand Down Expand Up @@ -54,87 +55,138 @@ export interface Interface {}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}

const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})

const backend = getBackend()
const location = yield* Location.Service
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
}

const w = watcher()
if (!w) return Service.of({})

yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
export interface LayerOptions {
readonly backend?: ReturnType<typeof getBackend>
readonly watcher?: () => Pick<typeof import("@parcel/watcher"), "subscribe"> | undefined
readonly probe?: () => ProbeFailure | undefined
}

const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
// Loaded only on the inotify branch: the helper depends on bun:ffi, so a
// runtime without it keeps the pre-existing behaviour rather than losing file
// watching altogether.
const loadProbe = Effect.promise(() => import("./watcher-inotify")).pipe(
Effect.map((module) => module.probe),
Effect.catchCause(() => Effect.succeed(undefined)),
)

export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})

const backend = options?.backend ?? getBackend()
const location = yield* Location.Service
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
}
}

const subscribe = (directory: string, ignore: string[]) => {
const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
}),
)
}

const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER)) {
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),

const w = (options?.watcher ?? watcher)()
if (!w) return Service.of({})

const probe = options?.probe ?? (backend === "inotify" ? yield* loadProbe : undefined)

yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
}

if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))

const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
}
}

return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
}),
),
)
const subscribe = (directory: string, ignore: string[]) =>
Effect.gen(function* () {
const started = yield* Effect.sync(() => {
const failure = probe?.()
if (failure) return { type: "unavailable" as const, failure }
// Nothing may run between the probe and this call. subscribe()
// reaches the native backend synchronously on the calling thread,
// and a kernel that refuses an inotify instance there parks that
// thread forever instead of failing, so the window in which another
// process can take the last instance stays one expression wide.
return { type: "started" as const, pending: w.subscribe(directory, callback, { ignore, backend }) }
})

if (started.type === "unavailable") {
yield* Effect.logWarning("watcher unavailable, continuing without it", {
directory,
backend,
errno: started.failure.errno,
code: started.failure.code,
})
return false
}

yield* Effect.forkScoped(
Effect.promise(() => started.pending).pipe(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
started.pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
}),
),
)
return true
})

const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER)) {
const subscribed = yield* subscribe(location.directory, [
...Ignore.PATTERNS,
...config,
...protecteds(location.directory),
])
// The .git watcher below would ask the same exhausted kernel for the
// same resource, so there is nothing left to try.
if (!subscribed) return Service.of({})
}

export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* subscribe(vcs, ignore)
}
}

return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
}),
),
)

export const nodeWith = (options?: LayerOptions) =>
makeLocationNode({
service: Service,
layer: layerWith(options),
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})

export const node = nodeWith()
Loading
Loading