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
43 changes: 43 additions & 0 deletions packages/workspace-fs/src/watch-cancellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const gate = deferred<void>();
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<void>((resolve) => setImmediate(resolve));
gate.resolve();
expect(await closedAfterDetach).toBe(true);
await subscription;
});
}
19 changes: 17 additions & 2 deletions packages/workspace-fs/src/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<unknown>>();
private readonly pending = new Map<
string,
{
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -402,14 +405,26 @@ export class FsWatcherManager {
}
}

private async trackRelease(work: Promise<unknown>): Promise<void> {
this.releasing.add(work);
try {
await work;
} finally {
this.releasing.delete(work);
}
}

async close(): Promise<void> {
const pending = [...this.pending.values()];
this.pending.clear();
for (const entry of pending) entry.controller.abort();
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<void> {
Expand Down
Loading