Files
backnotprop__plannotator/packages/shared/file-browser-watch-core.test.ts
Michael Ramos 192b026073 fix(annotate): stop the folder watcher freezing the server (#1314)
* fix(annotate): stop the folder watcher freezing the server (#1313)

The file-browser content watcher built a chokidar scan over the whole
workspace synchronously on the request path. Under Bun that scan
monopolizes the event loop (a 780-directory nested tree measured 79
seconds), and because teardown was immediate on the last unsubscribe,
every EventSource reconnect paid the scan again: the reconnect the
freeze itself provoked made the hang self-sustaining.

The watcher engine now lives once in
packages/shared/file-browser-watch-core and both runtimes keep only
their transport:

- construction is deferred off the request path, so the SSE ready event
  and concurrent API requests are served before any scan starts
- teardown gets a 30s reconnect grace; a reload reuses the warm watcher
- on macOS and Windows the content watcher is the platform's native
  recursive fs.watch (measured ~0ms for the same tree); chokidar stays
  the Linux backend and the runtime fallback, with a forced catch-up
  refresh on the swap so no events are lost
- server stop tears every watcher down immediately in both runtimes

The responsiveness regression test reproduces the reported freeze on
the pre-fix implementation (79s, fails) and passes in under a second on
the fix.

* docs: folder annotate sessions do write per-file version history

The PLANNOTATOR_ANNOTATE_HISTORY row claimed URL, folder, and
annotate-last sessions never write to the data dir. The folder /api/doc
path deliberately runs the per-file version-history pipeline (lazily,
memoized per resolved path, gated on the same flag) to power the
per-file version diff, and has since it shipped. The code is the
intended behavior; the sentence was stale. URL and annotate-last
sessions remain fully stateless, and submit records remain single-file
only.

* fix(annotate): review follow-ups for the watcher engine

Applied from the independent review of #1314:

- contentWatchBackend gains a forced 'native' mode and the fallback
  tests use it, so the native-to-chokidar paths (creation failure and
  runtime error) genuinely execute on Linux CI; the runtime-error test
  is no longer macOS-only
- a platform-agnostic responsiveness test pins that SSE ready is served
  before the scan starts on the chokidar backend, via the runtime test
  hooks; the tight full-scan bound stays macOS-only
- watcher construction failures and the native-to-chokidar swap now log
  one console.error each instead of stranding subscribers silently;
  the swap also increments the diagnostics start counter honestly
- closeEntry guards both watcher close() calls; the Bun annotate stop
  chain got the same try/finally shape as the plan server; all four
  stop chains close watchers ahead of throwable disposals so a failing
  dispose cannot strand a watcher keeping embedded hosts alive
- a broadcast that empties the subscriber map by deleting dead
  subscribers now schedules the teardown grace instead of leaving the
  entry live until closeAll
- bun.lock drift reverted: only the chokidar edge and the workspace
  version corrections remain (27 unrelated esbuild resolution entries
  dropped; frozen-lockfile install verified)
- stale never-write comments in both annotate servers corrected to
  match the folder per-file history reality documented in AGENTS.md;
  the engine header now states plainly that chokidar is a correctness
  fallback, not a performance one
2026-08-13 16:02:41 -07:00

182 lines
6.5 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync, type FSWatcher } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createFileBrowserWatchRegistry,
type FileBrowserChangeEvent,
type FileBrowserWatchTarget,
} from "./file-browser-watch-core";
// Timing note: watcher construction is deferred one macrotask and broadcasts
// are debounced, so every expectation polls with a deadline instead of
// asserting instantly.
interface Recorded {
events: FileBrowserChangeEvent[];
}
function makeRegistry(options?: {
teardownGraceMs?: number;
contentWatchBackend?: "auto" | "chokidar" | "native";
nativeWatch?: unknown;
}) {
const recorded = new Map<string, Recorded>();
const registry = createFileBrowserWatchRegistry<string>({
send: (subscriber, event) => {
const box = recorded.get(subscriber);
if (!box) return false;
box.events.push(event);
return true;
},
getGitMetadataWatchPaths: () => [],
debounceMs: 20,
...(options?.teardownGraceMs !== undefined && { teardownGraceMs: options.teardownGraceMs }),
...(options?.contentWatchBackend && { contentWatchBackend: options.contentWatchBackend }),
...(options?.nativeWatch !== undefined && { nativeWatch: options.nativeWatch as never }),
});
const subscribe = (name: string, target: FileBrowserWatchTarget) => {
recorded.set(name, { events: [] });
const handle = registry.ensure(target);
registry.attach(handle, name, target.watchPath);
return handle;
};
return { registry, recorded, subscribe };
}
function dirTarget(watchPath: string): FileBrowserWatchTarget {
return { key: `dir:${watchPath}`, watchPath, watchGit: false };
}
async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await Bun.sleep(25);
}
throw new Error("Timed out waiting for condition");
}
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "plannotator-watch-core-"));
}
describe("file-browser-watch-core", () => {
test("a native-watch creation failure falls back to chokidar without losing events", async () => {
const root = makeTempDir();
const throwingNativeWatch = () => {
throw new Error("recursive watch unavailable");
};
const { registry, recorded, subscribe } = makeRegistry({
contentWatchBackend: "native",
nativeWatch: throwingNativeWatch,
});
try {
subscribe("client", dirTarget(root));
// Let the deferred warmup run and chokidar's scan settle.
await Bun.sleep(250);
writeFileSync(join(root, "plan.md"), "created");
await waitFor(() => (recorded.get("client")?.events.length ?? 0) > 0);
expect(recorded.get("client")?.events[0]).toMatchObject({ type: "changed", reason: "files" });
} finally {
registry.closeAll();
rmSync(root, { recursive: true, force: true });
}
});
test(
"a native-watch runtime error swaps to chokidar and forces a catch-up refresh",
async () => {
const root = makeTempDir();
// A fake native watcher: created successfully, then fails at runtime.
let created: (EventEmitter & { close: () => void }) | null = null;
const fakeNativeWatch = (() => {
const emitter = new EventEmitter() as EventEmitter & { close: () => void };
emitter.close = () => {};
created = emitter;
return emitter;
}) as unknown as typeof import("node:fs").watch;
const { registry, recorded, subscribe } = makeRegistry({
contentWatchBackend: "native",
nativeWatch: fakeNativeWatch,
});
try {
subscribe("client", dirTarget(root));
await waitFor(() => created !== null);
(created as unknown as EventEmitter).emit("error", new Error("EMFILE"));
// The swap itself forces one refresh so changes made during the
// gap are not lost.
await waitFor(() => (recorded.get("client")?.events.length ?? 0) > 0);
// And the chokidar replacement keeps delivering real events, with the
// swap counted as a second construction in the diagnostics.
expect(registry.diagnostics().contentWatcherStarts).toBe(2);
await Bun.sleep(250);
const before = recorded.get("client")?.events.length ?? 0;
writeFileSync(join(root, "after-swap.md"), "x");
await waitFor(() => (recorded.get("client")?.events.length ?? 0) > before);
} finally {
registry.closeAll();
rmSync(root, { recursive: true, force: true });
}
},
);
test("a resubscription inside the teardown grace reuses the warm watcher", async () => {
const root = makeTempDir();
const { registry, recorded, subscribe } = makeRegistry({ teardownGraceMs: 60_000 });
try {
const handle = subscribe("first", dirTarget(root));
await Bun.sleep(100);
expect(registry.diagnostics()).toMatchObject({ entries: 1, contentWatcherStarts: 1 });
// Last subscriber leaves: the entry must survive the grace window.
registry.release(handle, "first");
expect(registry.diagnostics().entries).toBe(1);
// Reconnect: same entry, no second construction. This is the #1313
// freeze loop regression guard: a rebuild here re-pays the full scan.
subscribe("second", dirTarget(root));
await Bun.sleep(100);
expect(registry.diagnostics()).toMatchObject({ entries: 1, contentWatcherStarts: 1 });
// The reused watcher still delivers to the new subscriber.
writeFileSync(join(root, "reconnect.md"), "x");
await waitFor(() => (recorded.get("second")?.events.length ?? 0) > 0);
} finally {
registry.closeAll();
rmSync(root, { recursive: true, force: true });
}
});
test("an expired teardown grace closes the entry", async () => {
const root = makeTempDir();
const { registry, subscribe } = makeRegistry({ teardownGraceMs: 120 });
try {
const handle = subscribe("only", dirTarget(root));
await Bun.sleep(50);
registry.release(handle, "only");
expect(registry.diagnostics().entries).toBe(1);
await waitFor(() => registry.diagnostics().entries === 0, 2_000);
} finally {
registry.closeAll();
rmSync(root, { recursive: true, force: true });
}
});
test("closeAll tears down immediately, including entries inside their grace window", async () => {
const root = makeTempDir();
const { registry, subscribe } = makeRegistry({ teardownGraceMs: 60_000 });
try {
const handle = subscribe("only", dirTarget(root));
registry.release(handle, "only");
expect(registry.diagnostics().entries).toBe(1);
registry.closeAll();
expect(registry.diagnostics().entries).toBe(0);
} finally {
registry.closeAll();
rmSync(root, { recursive: true, force: true });
}
});
});