mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
192b026073
* 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
182 lines
6.5 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|