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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user