From 64487db71a743bc76868b0404c80d637bd9edc6a Mon Sep 17 00:00:00 2001 From: "Hyeonjae.Jeong" Date: Thu, 12 Nov 2026 13:47:10 +0900 Subject: [PATCH] fix(workspace-fs): wait for released watchers during shutdown --- .../src/watch-cancellation.test.ts | 43 +++++++++++++++++++ packages/workspace-fs/src/watch.ts | 19 +++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/workspace-fs/src/watch-cancellation.test.ts b/packages/workspace-fs/src/watch-cancellation.test.ts index d63ba1ca84b..4c7918a8038 100644 --- a/packages/workspace-fs/src/watch-cancellation.test.ts +++ b/packages/workspace-fs/src/watch-cancellation.test.ts @@ -148,3 +148,46 @@ test("watcher readiness refreshes edits made during the background scan", async expect(entries.map((entry) => entry.name)).toContain("during-scan.txt"); await stream.return?.(); }); + +for (const phase of ["attaching", "attached"] as const) { + test(`close waits for the last aborted listener's ${phase} native watch`, async () => { + const rootPath = await root(); + const controller = new AbortController(); + const entered = deferred(); + const gate = deferred(); + let detached = false; + const manager = new FsWatcherManager({ + backend: { + name: "test", + async subscribe() { + entered.resolve(); + if (phase === "attaching") await gate.promise; + return { + async unsubscribe() { + await gate.promise; + detached = true; + }, + }; + }, + }, + }); + managers.push(manager); + const subscription = manager + .subscribe( + { absolutePath: rootPath, signal: controller.signal }, + () => {}, + ) + .catch((error) => { + if (!(error instanceof Error && error.name === "AbortError")) + throw error; + }); + await entered.promise; + if (phase === "attached") await subscription; + controller.abort(); + const closedAfterDetach = manager.close().then(() => detached); + await new Promise((resolve) => setImmediate(resolve)); + gate.resolve(); + expect(await closedAfterDetach).toBe(true); + await subscription; + }); +} diff --git a/packages/workspace-fs/src/watch.ts b/packages/workspace-fs/src/watch.ts index bb52ec4b2b5..830fd556803 100644 --- a/packages/workspace-fs/src/watch.ts +++ b/packages/workspace-fs/src/watch.ts @@ -277,6 +277,8 @@ export class FsWatcherManager { private readonly ignore: string[]; private readonly listGitIgnoredDirs?: FsWatcherManagerOptions["listGitIgnoredDirs"]; private readonly findNestedRoots: typeof findNestedRepoRoots; + // Last-listener cleanup leaves the path maps immediately so it can be rewatched. + private readonly releasing = new Set>(); private readonly pending = new Map< string, { @@ -381,11 +383,12 @@ export class FsWatcherManager { if (pending && this.pending.get(absolutePath) === pending) { this.pending.delete(absolutePath); pending.controller.abort(); + await this.trackRelease(Promise.allSettled([pending.promise])); } const current = this.watchers.get(absolutePath); if (current?.listeners === listeners) { this.watchers.delete(absolutePath); - await this.disposeWatcherState(current); + await this.trackRelease(this.disposeWatcherState(current)); } }; const onAbort = () => { @@ -402,6 +405,15 @@ export class FsWatcherManager { } } + private async trackRelease(work: Promise): Promise { + this.releasing.add(work); + try { + await work; + } finally { + this.releasing.delete(work); + } + } + async close(): Promise { const pending = [...this.pending.values()]; this.pending.clear(); @@ -409,7 +421,10 @@ export class FsWatcherManager { await Promise.allSettled(pending.map((entry) => entry.promise)); const states = Array.from(this.watchers.values()); this.watchers.clear(); - await Promise.all(states.map((state) => this.disposeWatcherState(state))); + await Promise.all([ + ...this.releasing, + ...states.map((state) => this.disposeWatcherState(state)), + ]); } private async disposeWatcherState(state: WatcherState): Promise {