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

@@ -56,8 +56,7 @@ describe("WorkspaceWatcher", () => {
it("emits Created event for new file matching pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
// Settle - chokidar's `ready` is implicit; give it a moment.
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x");
@@ -74,7 +73,7 @@ describe("WorkspaceWatcher", () => {
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.unlinkSync(file);
@@ -89,7 +88,7 @@ describe("WorkspaceWatcher", () => {
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(file, "y");
@@ -102,7 +101,7 @@ describe("WorkspaceWatcher", () => {
it("skips files not matching the pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
@@ -120,7 +119,7 @@ describe("WorkspaceWatcher", () => {
fs.mkdirSync(path.join(tmpDir, "ignored-dir"));
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x");
fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x");
@@ -143,7 +142,7 @@ describe("WorkspaceWatcher", () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
// Kind 1 = Create only - delete events should be suppressed.
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
const file = path.join(tmpDir, "only-create.ts");
fs.writeFileSync(file, "x");
@@ -165,7 +164,7 @@ describe("WorkspaceWatcher", () => {
it("batches multiple rapid events into one onEvents call", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
// Write Several Files Quickly - within the 50ms debounce window they
// should all land in one batch, capped at 500ms max wait.
@@ -188,7 +187,7 @@ describe("WorkspaceWatcher", () => {
it("ignores .git/ even when not in gitignore", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.mkdirSync(path.join(tmpDir, ".git"));
fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo");
@@ -205,7 +204,7 @@ describe("WorkspaceWatcher", () => {
it("stops emitting after dispose", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
await watcher.dispose();
watcher = null;
@@ -224,7 +223,7 @@ describe("WorkspaceWatcher", () => {
watcher.setPatterns([
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n");
@@ -236,10 +235,82 @@ describe("WorkspaceWatcher", () => {
assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`);
});
it("coalesces Created+Deleted to no-op", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
// Create Then Delete - both within the debounce quiet window.
const file = path.join(tmpDir, "transient.ts");
fs.writeFileSync(file, "x");
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri === pathToUri(file)),
`Transient file should not surface, got ${JSON.stringify(all)}`,
);
});
it("coalesces Deleted+Created (replacement) to Changed", async () => {
// Setup - file exists before the watcher starts, so chokidar's initial
// scan registers it (with ignoreInitial: true, no event fires).
const file = path.join(tmpDir, "replace.ts");
fs.writeFileSync(file, "v1");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
// Delete Then Recreate - within one debounce window.
fs.unlinkSync(file);
fs.writeFileSync(file, "v2");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const events = all.filter((e) => e.uri === pathToUri(file));
assert.ok(events.length > 0, `Expected an event for replaced file`);
// Expected: the coalesced single event is Changed (type=2). Some
// platforms may split the delete+create across debounce windows, in
// which case the server still sees correct state. Accept either:
// - one Changed event (coalesced), or
// - Deleted then Created in separate batches (not coalesced, fine)
const types = events.map((e) => e.type).sort();
const acceptable =
JSON.stringify(types) === JSON.stringify([2]) ||
JSON.stringify(types) === JSON.stringify([1, 3]);
assert.ok(
acceptable,
`Expected [2] or [1,3], got ${JSON.stringify(types)}`,
);
});
it("drops pending events when patterns are cleared", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
// Don't wait for the debounce - clear patterns immediately so the
// pending batch should be dropped, not flushed.
await new Promise((r) => setTimeout(r, 10));
watcher.setPatterns([]);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(
received.length,
0,
`Pending batch should be dropped, got ${JSON.stringify(received)}`,
);
});
it("empty pattern set means no watching", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([]);
await new Promise((r) => setTimeout(r, 200));
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));