Files
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

171 lines
5.3 KiB
TypeScript

import { existsSync, statSync } from "fs";
import { dirname, isAbsolute, relative } from "path";
import {
createExactFileWatchListener,
createFileBrowserWatchRegistry,
type FileBrowserChangeEvent,
type FileBrowserWatchRegistry,
type FileBrowserWatchTarget,
type WatchEntryHandle,
} from "@plannotator/shared/file-browser-watch-core";
import { isFileBrowserExcludedPath } from "@plannotator/shared/reference-common";
import { resolveUserPath } from "@plannotator/shared/resolve-file";
import { getGitMetadataWatchPaths } from "@plannotator/shared/workspace-status";
// The watcher engine (deferred warmup, reconnect grace, native recursive
// backend) lives in @plannotator/shared/file-browser-watch-core (#1313).
// This module keeps only the Bun transport: request parsing, the SSE
// ReadableStream, and heartbeats.
const HEARTBEAT_MS = 30_000;
const encoder = new TextEncoder();
function serialize(event: FileBrowserChangeEvent): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
}
const registry: FileBrowserWatchRegistry<ReadableStreamDefaultController> = createFileBrowserWatchRegistry<ReadableStreamDefaultController>({
send: (subscriber, event) => {
try {
subscriber.enqueue(serialize(event));
return true;
} catch {
return false;
}
},
getGitMetadataWatchPaths,
});
export { createExactFileWatchListener };
/** Immediate teardown of every live watcher. Server stop and tests. */
export function closeAllFileBrowserWatchers(): void {
registry.closeAll();
}
/** Tests only. See FileBrowserWatchRegistry.diagnostics/configureForTests. */
export const __fileBrowserWatchTestHooks = {
diagnostics: () => registry.diagnostics(),
configure: (overrides: Parameters<FileBrowserWatchRegistry<ReadableStreamDefaultController>["configureForTests"]>[0]) =>
registry.configureForTests(overrides),
};
export function isFileBrowserWatchIgnoredPath(path: string, root: string): boolean {
const rel = relative(root, path).replace(/\\/g, "/");
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
return isFileBrowserExcludedPath(rel);
}
function isValidDirectory(dirPath: string): boolean {
try {
return existsSync(dirPath) && statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function isValidFileTarget(filePath: string): boolean {
if (!filePath) return false;
try {
if (existsSync(filePath)) return !statSync(filePath).isDirectory();
return isValidDirectory(dirname(filePath));
} catch {
return false;
}
}
export function handleFileBrowserFilesStream(
req: Request,
options?: { disableIdleTimeout?: () => void },
): Response {
const url = new URL(req.url);
const rawDirPaths = url.searchParams.getAll("dirPath");
const rawFilePaths = url.searchParams.getAll("filePath");
if ((rawDirPaths.length > 0) === (rawFilePaths.length > 0)) {
return Response.json({ error: "Provide exactly one of dirPath or filePath" }, { status: 400 });
}
const targets = new Map<string, FileBrowserWatchTarget & { clientDirPath: string }>();
if (rawDirPaths.length > 0) {
for (const rawDirPath of rawDirPaths) {
const dirPath = resolveUserPath(rawDirPath);
if (!isValidDirectory(dirPath)) {
return Response.json({ error: "Invalid directory path" }, { status: 400 });
}
const key = `dir:${dirPath}`;
if (!targets.has(key)) {
targets.set(key, {
key,
watchPath: dirPath,
clientDirPath: rawDirPath,
watchGit: true,
ignored: (path) => isFileBrowserWatchIgnoredPath(path, dirPath),
});
}
}
} else {
for (const rawFilePath of rawFilePaths) {
const filePath = resolveUserPath(rawFilePath);
if (!isValidFileTarget(filePath)) {
return Response.json({ error: "Invalid file path" }, { status: 400 });
}
const key = `file:${filePath}`;
if (!targets.has(key)) {
const parentPath = dirname(filePath);
targets.set(key, {
key,
watchPath: parentPath,
clientDirPath: dirname(rawFilePath),
watchGit: false,
exactFilePath: filePath,
});
}
}
}
options?.disableIdleTimeout?.();
const subscriptions: Array<{ handle: WatchEntryHandle; clientDirPath: string }> = [...targets.values()].map((target) => ({
handle: registry.ensure(target),
clientDirPath: target.clientDirPath,
}));
let controllerRef: ReadableStreamDefaultController | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
controllerRef = controller;
for (const { handle, clientDirPath } of subscriptions) {
registry.attach(handle, controller, clientDirPath);
controller.enqueue(serialize({
type: "ready",
dirPath: clientDirPath,
reason: "initial",
timestamp: Date.now(),
}));
}
heartbeatTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
} catch {
for (const { handle } of subscriptions) registry.release(handle, controller);
if (heartbeatTimer) clearInterval(heartbeatTimer);
}
}, HEARTBEAT_MS);
},
cancel() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
if (controllerRef) {
for (const { handle } of subscriptions) registry.release(handle, controllerRef);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}