Compare commits

...
3 changed files with 70 additions and 19 deletions

No files matched your search

+28 -16
View File
@@ -246,22 +246,34 @@ function subscribeDirectory(
Effect.as(undefined),
)
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) publish(update)
}
// Copy `ignore`: it aliases the RcMap key, whose structural hash is cached,
// so the array handed to native code must never be the mutable original.
const pending = native.subscribe(directory, callback, { ignore: [...ignore], backend })
return Effect.promise(() => pending).pipe(
Effect.map((subscription) => ({ unsubscribe: () => subscription.unsubscribe(), backend })),
// Interruption (including the timeout below) abandons the pending native
// subscription, so close it once it eventually resolves.
Effect.onInterrupt(() =>
Effect.sync(() => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
}),
),
return Effect.suspend(() => {
// Parcel's FSEvents backend emits canonical paths and resolves ignorePaths
// before realpathing the watched root; resolve upfront and rebase onto `directory`.
const resolved = FSUtil.resolve(directory)
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) {
publish(
resolved !== directory && FSUtil.contains(resolved, update.path)
? { ...update, path: path.join(directory, path.relative(resolved, update.path)) }
: update,
)
}
}
// Copy `ignore`: it aliases the RcMap key, whose structural hash is cached,
// so the array handed to native code must never be the mutable original.
const pending = native.subscribe(resolved, callback, { ignore: [...ignore], backend })
return Effect.promise(() => pending).pipe(
Effect.map((subscription) => ({ unsubscribe: () => subscription.unsubscribe(), backend })),
// Interruption (including the timeout below) abandons the pending native
// subscription, so close it once it eventually resolves.
Effect.onInterrupt(() =>
Effect.sync(() => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
}),
),
)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) =>
Effect.logError("failed to subscribe", {
+4 -3
View File
@@ -403,10 +403,11 @@ describeNative("ConfigCommandPlugin native watcher", () => {
it.live("reloads commands from real file edits", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
// Watcher events report real paths, so resolve the tempdir symlink up front.
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-core-test-" }).pipe(Effect.flatMap(fs.realPath))
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-core-test-" })
const real = path.join(tmp, "real-global")
const global = path.join(tmp, "global")
yield* fs.makeDirectory(path.join(global, "commands"), { recursive: true })
yield* fs.makeDirectory(path.join(real, "commands"), { recursive: true })
yield* fs.symlink(real, global)
yield* fs.makeDirectory(path.join(tmp, "project"))
yield* Effect.gen(function* () {
const command = yield* Command.Service
@@ -149,6 +149,44 @@ describe("Watcher lifecycle", () => {
),
)
it.live("rebases directory watch events and honors ignores across symlinked roots", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const real = path.join(tmp.path, "real")
const link = path.join(tmp.path, "link")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(real, "ignored"), { recursive: true })
await fs.mkdir(path.join(real, "plugins"), { recursive: true })
await fs.symlink(real, link, process.platform === "win32" ? "junction" : undefined)
})
const native = yield* Watcher.Native
const events: Watcher.Update[] = []
yield* Effect.acquireRelease(
native.subscribe({
type: "directory",
target: link,
ignore: ["ignored"],
publish: (update) => events.push(update),
}),
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
)
yield* Effect.promise(async () => {
await fs.writeFile(path.join(link, "ignored", "skip.ts"), "ignored")
await fs.writeFile(path.join(link, "plugins", "beta.ts"), "export default {}")
})
yield* Effect.sync(() => events.some((update) => update.path === path.join(link, "plugins", "beta.ts"))).pipe(
Effect.filterOrFail(Boolean),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("5 seconds"),
)
expect(events.some((update) => update.path.includes("skip.ts"))).toBe(false)
expect(events.every((update) => FSUtil.contains(link, update.path))).toBe(true)
}).pipe(Effect.provide(Watcher.nativeLayer)),
),
),
)
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()