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
136 lines
5.6 KiB
TypeScript
136 lines
5.6 KiB
TypeScript
import { afterAll, describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
// Namespace import on purpose: this file must also run against the pre-#1313
|
|
// module (which lacks the teardown export) to demonstrate the regression, so
|
|
// cleanup is feature-detected instead of imported by name.
|
|
import * as referenceWatch from "./reference-watch";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterAll(() => {
|
|
(referenceWatch as { closeAllFileBrowserWatchers?: () => void }).closeAllFileBrowserWatchers?.();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function buildLargeTree(root: string): void {
|
|
// Depth and breadth are both amplifiers and the blowup is nonlinear: this
|
|
// exact 780-directory shape blocked the pre-#1313 implementation for 77
|
|
// seconds in measurement, while a flat tree of similar size and a
|
|
// 340-directory nested variant scanned in under a second.
|
|
const build = (base: string, depth: number): void => {
|
|
for (let i = 0; i < 5; i++) {
|
|
const dir = join(base, `d${i}`);
|
|
mkdirSync(dir);
|
|
for (let f = 0; f < 8; f++) {
|
|
writeFileSync(join(dir, `f${f}.md`), "content");
|
|
}
|
|
if (depth > 1) build(dir, depth - 1);
|
|
}
|
|
};
|
|
build(root, 4);
|
|
}
|
|
|
|
async function readFirstEvent(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
timeoutMs: number,
|
|
): Promise<void> {
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error("Timed out waiting for SSE ready")), timeoutMs);
|
|
});
|
|
await Promise.race([reader.read(), timeout]);
|
|
}
|
|
|
|
describe("file browser watcher responsiveness (#1313)", () => {
|
|
// chokidar's directory scan under Bun costs ~40ms per directory and, before
|
|
// #1313, ran synchronously with request handling: on a repository the size
|
|
// of this synthetic tree the whole server stopped answering for seconds
|
|
// (the reported symptom was "document API requests time out" on folder
|
|
// switches). The native recursive backend that fixes this only exists on
|
|
// macOS and Windows; Linux keeps the chokidar backend and relies on the
|
|
// deferred warmup plus reconnect grace, so the tight bound is not asserted
|
|
// there.
|
|
test.skipIf(process.platform === "linux")(
|
|
"the event loop stays responsive while a large tree's watcher warms",
|
|
async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "plannotator-watch-large-"));
|
|
tempDirs.push(root);
|
|
buildLargeTree(root);
|
|
|
|
const url = new URL("http://localhost/api/reference/files/stream");
|
|
url.searchParams.append("dirPath", root);
|
|
|
|
const started = performance.now();
|
|
const response = referenceWatch.handleFileBrowserFilesStream(new Request(url.toString()));
|
|
expect(response.status).toBe(200);
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error("Missing response body");
|
|
try {
|
|
// The stream must STAY OPEN through the measurement: cancelling the
|
|
// reader releases the subscription and aborts the warmup, which is
|
|
// exactly how an earlier version of this test failed to reproduce
|
|
// the bug.
|
|
await readFirstEvent(reader, 2_000);
|
|
|
|
// Stand-ins for the /api/doc requests a user's file click issues
|
|
// while the watcher warms: each hop must schedule promptly instead
|
|
// of queueing behind a directory scan.
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
const elapsed = performance.now() - started;
|
|
expect(elapsed).toBeLessThan(1_500);
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
},
|
|
);
|
|
|
|
// The platform-agnostic half of the same property: the SSE ready event is
|
|
// not gated on the watcher scan, on ANY backend. The chokidar backend is
|
|
// forced through the test hooks so this exercises the Linux code path on
|
|
// every platform; the scan still saturates the loop once it starts (the
|
|
// backend is a correctness fallback, not a performance one), so the stream
|
|
// is torn down immediately after the assertion to abort the scan.
|
|
test("SSE ready is served before the watcher scan starts on the fallback backend", async () => {
|
|
const hooks = (referenceWatch as {
|
|
__fileBrowserWatchTestHooks?: {
|
|
diagnostics: () => { entries: number; contentWatcherStarts: number };
|
|
configure: (overrides: { contentWatchBackend?: "auto" | "chokidar" | "native" }) => void;
|
|
};
|
|
}).__fileBrowserWatchTestHooks;
|
|
if (!hooks) throw new Error("test hooks missing");
|
|
const root = mkdtempSync(join(tmpdir(), "plannotator-watch-fallback-"));
|
|
tempDirs.push(root);
|
|
buildLargeTree(root);
|
|
|
|
// The previous test's entry is still inside its reconnect grace; clear
|
|
// it so the entry count below is this test's own.
|
|
(referenceWatch as { closeAllFileBrowserWatchers?: () => void }).closeAllFileBrowserWatchers?.();
|
|
hooks.configure({ contentWatchBackend: "chokidar" });
|
|
try {
|
|
const url = new URL("http://localhost/api/reference/files/stream");
|
|
url.searchParams.append("dirPath", root);
|
|
const started = performance.now();
|
|
const response = referenceWatch.handleFileBrowserFilesStream(new Request(url.toString()));
|
|
expect(response.status).toBe(200);
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error("Missing response body");
|
|
try {
|
|
await readFirstEvent(reader, 2_000);
|
|
expect(performance.now() - started).toBeLessThan(1_000);
|
|
expect(hooks.diagnostics().entries).toBe(1);
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
} finally {
|
|
// Abort the deferred chokidar scan before it can slow later tests.
|
|
(referenceWatch as { closeAllFileBrowserWatchers?: () => void }).closeAllFileBrowserWatchers?.();
|
|
hooks.configure({ contentWatchBackend: undefined });
|
|
}
|
|
});
|
|
});
|