fix(watcher): coalesce edge cases, drain on unregister, expose ready()

Addresses review feedback on 7787626:

1. Deleted->Created at the same path is a file replacement, not a no-op.
   Previous coalescing dropped both Created->Deleted and Deleted->Created;
   the latter left the server with no signal to re-read replaced content.
   Now: Deleted->Created collapses to Changed, Created->Changed keeps
   Created (server didn't know the file at all). Extracted coalesce() so
   the matrix is reviewable in one place.

2. setPatterns([]) (server unregistered all watchers) stopped chokidar
   but left pending events + timers intact, so a queued batch could
   still fire after the server stopped caring. Now drains via
   cancelPending() before stopping chokidar.

3. Added ready() returning a promise resolved by chokidar's initial-scan
   'ready' event. Production daemon doesn't need to await it (LSP
   handshake gives chokidar ample wall-time), but tests now use it
   instead of fixed 200ms sleeps - deflakes the suite on slower
   filesystems and addresses the (narrow) startup race where a file
   created during chokidar's initial crawl could be missed.

4. Unit tests replace 11 hardcoded sleeps with watcher.ready(), and add
   coverage for the two coalesce fixes plus the unregister-drains case.
This commit is contained in:
2026-05-19 23:51:32 -04:00
parent 77876264ee
commit 0aa44bedc4
2 changed files with 149 additions and 29 deletions

View File

@@ -144,6 +144,11 @@ export class WorkspaceWatcher {
private quietTimer: NodeJS.Timeout | null = null;
private maxWaitTimer: NodeJS.Timeout | null = null;
private disposed = false;
// readyPromise: Resolves on chokidar's initial-scan `ready` event. Tests
// await this to deflake; the daemon doesn't bother (the LSP handshake
// gives chokidar ample wall-time to finish its initial scan).
private readyPromise: Promise<void> = Promise.resolve();
private resolveReady: (() => void) | null = null;
constructor(
private readonly rootDir: string,
@@ -160,15 +165,30 @@ export class WorkspaceWatcher {
if (this.disposed) return;
this.matchKind = compileWatchers(watchers, this.rootDir);
if (watchers.length === 0) {
// Server unregistered everything - stop watching but keep instance
// alive so re-registration works without recreating chokidar state.
// Server unregistered everything - drop pending events and timers
// so a queued batch can't fire after the server stopped caring,
// then stop chokidar but keep this instance alive for possible
// re-registration.
this.cancelPending();
void this.stopChokidar();
return;
}
if (!this.chokidar) this.startChokidar();
}
// Ready - Resolves once chokidar has finished its initial scan and is
// emitting events. Useful for deflaking tests; production callers can
// skip it.
ready(): Promise<void> {
return this.readyPromise;
}
private startChokidar(): void {
this.readyPromise = new Promise<void>((resolve) => {
// Resolved by the `ready` handler below. We capture the resolver
// here so stopChokidar() can swap promises without leaking.
this.resolveReady = resolve;
});
this.chokidar = chokidar.watch(this.rootDir, {
ignoreInitial: true,
followSymlinks: false,
@@ -188,6 +208,7 @@ export class WorkspaceWatcher {
this.chokidar.on("add", (p) => this.queue(p, FILE_CHANGE_CREATED));
this.chokidar.on("change", (p) => this.queue(p, FILE_CHANGE_CHANGED));
this.chokidar.on("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
this.chokidar.on("ready", () => this.resolveReady?.());
// ENOSPC and friends - log but don't crash the entry.
this.chokidar.on("error", (err) => {
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
@@ -198,9 +219,22 @@ export class WorkspaceWatcher {
if (!this.chokidar) return;
const w = this.chokidar;
this.chokidar = null;
// Reset Ready - A future startChokidar() will install a fresh promise.
// Resolve the old one so any awaiter doesn't hang.
this.resolveReady?.();
this.resolveReady = null;
this.readyPromise = Promise.resolve();
await w.close();
}
private cancelPending(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.quietTimer = null;
this.maxWaitTimer = null;
this.pending.clear();
}
private queue(absPath: string, type: 1 | 2 | 3): void {
const rel = path.relative(this.rootDir, absPath);
const kind = this.matchKind(rel, absPath);
@@ -210,19 +244,10 @@ export class WorkspaceWatcher {
if (type === FILE_CHANGE_CHANGED && !(kind & WATCH_KIND_CHANGE)) return;
if (type === FILE_CHANGE_DELETED && !(kind & WATCH_KIND_DELETE)) return;
const uri = pathToUri(absPath);
// Coalesce per URI - Last event wins. The exception is Created followed
// by Deleted (and vice versa), which we collapse to "nothing happened"
// by deleting the entry; the server doesn't need to learn about a file
// that briefly existed.
const prev = this.pending.get(uri);
if (
(prev === FILE_CHANGE_CREATED && type === FILE_CHANGE_DELETED) ||
(prev === FILE_CHANGE_DELETED && type === FILE_CHANGE_CREATED)
) {
this.pending.delete(uri);
} else {
this.pending.set(uri, type);
}
const next = coalesce(prev, type);
if (next === null) this.pending.delete(uri);
else this.pending.set(uri, next);
this.scheduleFlush();
}
@@ -257,9 +282,33 @@ export class WorkspaceWatcher {
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.pending.clear();
this.cancelPending();
await this.stopChokidar();
}
}
// Coalesce - Combines a pending event with a newly arrived one for the
// same URI. Returns the resulting type, or null to drop the entry entirely.
//
// The interesting cases:
// Created -> Deleted : drop (transient file the server never knew about)
// Deleted -> Created : Changed (file replaced; server already thinks it
// exists - or didn't, but Changed is the safe call
// and prompts a re-read)
// Created -> Changed : keep Created (server didn't know the file at all)
// Changed -> Deleted : Deleted (latter overrides)
// * -> * : latter overrides (default)
function coalesce(
prev: 1 | 2 | 3 | undefined,
next: 1 | 2 | 3,
): 1 | 2 | 3 | null {
if (prev === undefined) return next;
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_DELETED) return null;
if (prev === FILE_CHANGE_DELETED && next === FILE_CHANGE_CREATED) {
return FILE_CHANGE_CHANGED;
}
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_CHANGED) {
return FILE_CHANGE_CREATED;
}
return next;
}