Files
backnotprop__plannotator/packages/shared/file-browser-watch-core.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

385 lines
13 KiB
TypeScript

/**
* Shared engine for the file-browser SSE watchers (Bun and Pi runtimes).
*
* #1313: the historical per-runtime implementations built a chokidar watcher
* over the whole workspace synchronously on the request path. chokidar's
* directory scan monopolizes the event loop for roughly 40ms per directory
* under Bun, so a 228-directory repository froze the entire server for about
* nine seconds, and because teardown was immediate on the last unsubscribe,
* every EventSource reconnect paid the scan again. This module fixes the
* class, once, for both runtimes:
*
* 1. Watcher construction is deferred off the request path, so the SSE ready
* event and concurrent API requests are served before any scan starts.
* 2. Teardown gets a reconnect grace window. A reload or transient disconnect
* reuses the warm watcher instead of rebuilding it from scratch.
* 3. On macOS and Windows the content watcher is the platform's native
* recursive fs.watch (measured at ~0ms for the same tree). chokidar
* remains the Linux backend (recursive fs.watch is unreliable there) and
* the runtime fallback whenever native watching fails. That fallback is a
* correctness fallback, not a performance one: the deferred warmup moves
* the scan off the first request, but a chokidar scan of a large tree
* still saturates the event loop while it runs. The reconnect grace keeps
* that a once-per-session cost instead of a per-reconnect one.
*
* The registry is transport-generic: the Bun runtime subscribes
* ReadableStream controllers, the Pi runtime subscribes node:http responses.
* Only the `send` callback differs.
*/
import chokidar, { type FSWatcher as ChokidarWatcher } from "chokidar";
import { watch as nodeFsWatch, statSync, type FSWatcher as NodeWatcher } from "node:fs";
import { resolve } from "node:path";
export interface FileBrowserChangeEvent {
type: "ready" | "changed";
dirPath: string;
reason: "files" | "git" | "initial";
timestamp: number;
}
export interface FileBrowserWatchTarget {
key: string;
watchPath: string;
watchGit: boolean;
exactFilePath?: string;
ignored?: (path: string) => boolean;
}
export interface FileBrowserWatchRegistryOptions<S> {
/** Deliver one serialized event to one subscriber; false drops the subscriber. */
send: (subscriber: S, event: FileBrowserChangeEvent) => boolean;
getGitMetadataWatchPaths: (watchPath: string) => string[];
/** Reconnect grace before an unsubscribed watcher is torn down. */
teardownGraceMs?: number;
debounceMs?: number;
/**
* Tests force "chokidar" to exercise the fallback backend everywhere, or
* "native" to exercise the native branch and its fallback paths on
* platforms where auto would pick chokidar (Linux CI).
*/
contentWatchBackend?: "auto" | "chokidar" | "native";
/** Test seam for native-watch failure modes. Defaults to fs.watch. */
nativeWatch?: typeof nodeFsWatch;
}
export interface WatchEntryHandle {
readonly key: string;
}
interface WatchEntry<S> extends WatchEntryHandle {
key: string;
subscribers: Map<S, string>;
contentWatcher: ChokidarWatcher | NodeWatcher | null;
gitWatcher: ChokidarWatcher | null;
debounceTimer: ReturnType<typeof setTimeout> | null;
/** Deferred off-request construction (#1313). */
warmupTimer: ReturnType<typeof setTimeout> | null;
/** Pending reconnect-grace teardown (#1313). */
teardownTimer: ReturnType<typeof setTimeout> | null;
closed: boolean;
}
export interface FileBrowserWatchRegistry<S> {
ensure(target: FileBrowserWatchTarget): WatchEntryHandle;
attach(handle: WatchEntryHandle, subscriber: S, clientDirPath: string): void;
release(handle: WatchEntryHandle, subscriber: S): void;
/** Immediate teardown of every entry. Server stop and tests. */
closeAll(): void;
/**
* Tests only: entry count and how many content watchers were ever
* constructed. The grace-period test pins that a reconnect does not
* rebuild (starts stays flat), which is exactly the #1313 regression.
*/
diagnostics(): { entries: number; contentWatcherStarts: number };
/** Tests only: override timing/backend without module-scope mutation. */
configureForTests(overrides: Partial<Pick<FileBrowserWatchRegistryOptions<S>, "teardownGraceMs" | "debounceMs" | "contentWatchBackend" | "nativeWatch">>): void;
}
const DEFAULT_TEARDOWN_GRACE_MS = 30_000;
const DEFAULT_DEBOUNCE_MS = 180;
function getFileSignature(filePath: string): string {
try {
const stats = statSync(filePath, { bigint: true });
return stats.isDirectory()
? "directory"
: `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
} catch {
return "missing";
}
}
export function createExactFileWatchListener(
watchPath: string,
exactFilePath: string,
onChange: () => void,
): (event: unknown, filename: string | Buffer | null | undefined) => void {
let signature = getFileSignature(exactFilePath);
return (_event, filename) => {
try {
const nextSignature = getFileSignature(exactFilePath);
// Events on the watched directory itself arrive without a filename, as
// null on some platforms and undefined on others (Bun on Linux).
const eventMatches = filename == null
|| resolve(watchPath, filename.toString()) === exactFilePath;
if (eventMatches || nextSignature !== signature) {
signature = nextSignature;
onChange();
}
} catch {
// A watcher event must never take down the server.
}
};
}
function nativeRecursiveSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}
export function createFileBrowserWatchRegistry<S>(
initialOptions: FileBrowserWatchRegistryOptions<S>,
): FileBrowserWatchRegistry<S> {
const options = { ...initialOptions };
const watchers = new Map<string, WatchEntry<S>>();
let contentWatcherStarts = 0;
const teardownGraceMs = () => options.teardownGraceMs ?? DEFAULT_TEARDOWN_GRACE_MS;
const debounceMs = () => options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
function broadcast(entry: WatchEntry<S>, reason: "files" | "git"): void {
const hadSubscribers = entry.subscribers.size > 0;
for (const [subscriber, clientDirPath] of entry.subscribers) {
const event: FileBrowserChangeEvent = {
type: "changed",
dirPath: clientDirPath,
reason,
timestamp: Date.now(),
};
let delivered = false;
try {
delivered = options.send(subscriber, event);
} catch {
delivered = false;
}
if (!delivered) entry.subscribers.delete(subscriber);
}
// Dead subscribers discovered here never call release(), so an entry
// emptied by delivery failures must still enter the teardown grace or
// it lives until closeAll.
if (hadSubscribers && entry.subscribers.size === 0) scheduleTeardown(entry);
}
function scheduleBroadcast(entry: WatchEntry<S>, reason: "files" | "git"): void {
if (entry.closed) return;
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
entry.debounceTimer = setTimeout(() => {
entry.debounceTimer = null;
broadcast(entry, reason);
}, debounceMs());
}
function isCurrent(entry: WatchEntry<S>): boolean {
return !entry.closed && watchers.get(entry.key) === entry;
}
function startChokidarContentWatcher(entry: WatchEntry<S>, target: FileBrowserWatchTarget): ChokidarWatcher {
const watcher = chokidar.watch(target.watchPath, {
ignoreInitial: true,
persistent: true,
ignored: target.ignored,
awaitWriteFinish: {
stabilityThreshold: 120,
pollInterval: 30,
},
});
watcher.on("all", () => scheduleBroadcast(entry, "files"));
watcher.on("error", () => scheduleBroadcast(entry, "files"));
return watcher;
}
function startContentWatcher(entry: WatchEntry<S>, target: FileBrowserWatchTarget): void {
contentWatcherStarts += 1;
if (target.exactFilePath) {
const exactFilePath = target.exactFilePath;
const watcher = (options.nativeWatch ?? nodeFsWatch)(
target.watchPath,
{ persistent: true },
createExactFileWatchListener(target.watchPath, exactFilePath, () => scheduleBroadcast(entry, "files")),
);
watcher.on("error", () => scheduleBroadcast(entry, "files"));
entry.contentWatcher = watcher;
return;
}
const backend = options.contentWatchBackend ?? "auto";
if (backend === "native" || (backend === "auto" && nativeRecursiveSupported())) {
try {
const nativeWatch = options.nativeWatch ?? nodeFsWatch;
const watcher = nativeWatch(target.watchPath, { recursive: true, persistent: true }, (_event, filename) => {
try {
if (filename != null) {
const abs = resolve(target.watchPath, filename.toString());
if (target.ignored?.(abs)) return;
}
scheduleBroadcast(entry, "files");
} catch {
// A watcher event must never take down the server.
}
});
watcher.on("error", (error) => {
// Native watching failed at runtime. Swap to the chokidar backend
// and force one refresh so anything that changed during the swap
// window is not lost.
try {
watcher.close();
} catch {
// Already closed.
}
if (isCurrent(entry) && entry.contentWatcher === watcher) {
console.error(
`[plannotator] Native file watching failed for ${target.watchPath}; switching to the fallback watcher:`,
error,
);
contentWatcherStarts += 1;
entry.contentWatcher = startChokidarContentWatcher(entry, target);
scheduleBroadcast(entry, "files");
}
});
entry.contentWatcher = watcher;
return;
} catch (error) {
// Native creation failed. Fall through to chokidar.
console.error(
`[plannotator] Native file watching unavailable for ${target.watchPath}; using the fallback watcher:`,
error,
);
}
}
entry.contentWatcher = startChokidarContentWatcher(entry, target);
}
function buildWatchers(entry: WatchEntry<S>, target: FileBrowserWatchTarget): void {
if (!isCurrent(entry)) return;
try {
startContentWatcher(entry, target);
} catch (error) {
// A watcher that cannot start must not take down the stream, but the
// subscriber is now living without live refreshes; say so.
console.error(`[plannotator] File watcher failed to start for ${target.watchPath}:`, error);
}
try {
const gitWatchPaths = target.watchGit
? options.getGitMetadataWatchPaths(target.watchPath)
: [];
if (gitWatchPaths.length > 0) {
entry.gitWatcher = chokidar.watch(gitWatchPaths, {
ignoreInitial: true,
persistent: true,
// These are exact metadata files. Keep this non-recursive so a future
// target cannot make startup walk the repository's entire refs tree.
depth: 0,
awaitWriteFinish: {
stabilityThreshold: 80,
pollInterval: 30,
},
});
entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git"));
entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git"));
}
} catch (error) {
// Same containment for the git metadata watcher.
console.error(`[plannotator] Git metadata watcher failed to start for ${target.watchPath}:`, error);
}
}
function closeEntry(entry: WatchEntry<S>): void {
entry.closed = true;
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
if (entry.warmupTimer) clearTimeout(entry.warmupTimer);
if (entry.teardownTimer) clearTimeout(entry.teardownTimer);
entry.debounceTimer = null;
entry.warmupTimer = null;
entry.teardownTimer = null;
try {
void entry.contentWatcher?.close();
} catch {
// A throwing close must not block the rest of the teardown.
}
try {
void entry.gitWatcher?.close();
} catch {
// Same.
}
if (watchers.get(entry.key) === entry) {
watchers.delete(entry.key);
}
}
function scheduleTeardown(entry: WatchEntry<S>): void {
if (entry.closed) return;
if (entry.teardownTimer) clearTimeout(entry.teardownTimer);
const timer = setTimeout(() => {
entry.teardownTimer = null;
if (entry.subscribers.size === 0) closeEntry(entry);
}, teardownGraceMs());
// The grace window must never hold the process open after the server
// is otherwise done.
(timer as { unref?: () => void }).unref?.();
entry.teardownTimer = timer;
}
return {
ensure(target) {
const existing = watchers.get(target.key);
if (existing) {
// A resubscription inside the grace window reuses the warm entry.
if (existing.teardownTimer) {
clearTimeout(existing.teardownTimer);
existing.teardownTimer = null;
}
return existing;
}
const entry: WatchEntry<S> = {
key: target.key,
subscribers: new Map(),
contentWatcher: null,
gitWatcher: null,
debounceTimer: null,
warmupTimer: null,
teardownTimer: null,
closed: false,
};
// Construction is deferred off the request path: the caller's SSE
// ready event and concurrent API requests are served before any
// directory scan begins (#1313).
entry.warmupTimer = setTimeout(() => {
entry.warmupTimer = null;
buildWatchers(entry, target);
}, 0);
watchers.set(target.key, entry);
return entry;
},
attach(handle, subscriber, clientDirPath) {
const entry = watchers.get(handle.key);
if (!entry || entry.closed) return;
entry.subscribers.set(subscriber, clientDirPath);
},
release(handle, subscriber) {
const entry = watchers.get(handle.key);
if (!entry) return;
entry.subscribers.delete(subscriber);
if (entry.subscribers.size === 0) scheduleTeardown(entry);
},
closeAll() {
for (const entry of [...watchers.values()]) closeEntry(entry);
},
diagnostics() {
return { entries: watchers.size, contentWatcherStarts };
},
configureForTests(overrides) {
Object.assign(options, overrides);
},
};
}