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
171 lines
5.3 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|