Skip to content

Commit 391bd5b

Browse files
committed
fix(pdf-server): sweep aux maps independently of commandQueues; line annotations sort
Two lower-severity findings from review: Leak (server.ts:265): pruneStaleQueues iterated commandQueues to clean up viewFieldNames/viewFieldInfo/viewFileWatches, but display_pdf populates those maps without creating a commandQueues entry (only enqueueCommand does), and dequeueCommands deletes the entry on every poll. Net: the sweep found nothing; aux maps leaked every display_pdf. viewFileWatches entries hold an fs.StatWatcher -> slow FD exhaustion on HTTP --enable-interact. Fix: new viewLastActivity heartbeat map, touched at display_pdf/enqueue/dequeue, swept on TTL. Dead second loop (entry.commands.length===0 was unreachable) removed. Line-annotation sort (annotation-panel.ts:400): getAnnotationY checked 'y' in def and 'rects' in def, but LineAnnotation has only x1/y1/x2/y2. Fell through to return 0 -> all line annotations sorted to panel bottom. Added y1 branch returning max(y1, y2).
1 parent 01580bf commit 391bd5b

2 files changed

Lines changed: 32 additions & 9 deletions

File tree

examples/pdf-server/server.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -262,25 +262,38 @@ interface ViewFileWatch {
262262
}
263263
const viewFileWatches = new Map<string, ViewFileWatch>();
264264

265+
/**
266+
* Per-view heartbeat. THIS is what the sweep iterates — not commandQueues.
267+
*
268+
* Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/
269+
* viewFileWatches but never touches commandQueues (only enqueueCommand does,
270+
* and it's triply gated). And dequeueCommands deletes the entry on every poll,
271+
* so even when it exists the sweep's TTL window is ~200ms wide. Net effect:
272+
* the sweep found nothing and the aux maps leaked every display_pdf call.
273+
* viewFileWatches entries hold an fs.StatWatcher (FD + timer) — slow FD
274+
* exhaustion on HTTP --enable-interact.
275+
*/
276+
const viewLastActivity = new Map<string, number>();
277+
278+
/** Register or refresh the heartbeat for a view. */
279+
function touchView(uuid: string): void {
280+
viewLastActivity.set(uuid, Date.now());
281+
}
282+
265283
function pruneStaleQueues(): void {
266284
const now = Date.now();
267-
for (const [uuid, entry] of commandQueues) {
268-
if (now - entry.lastActivity > COMMAND_TTL_MS) {
285+
for (const [uuid, lastActivity] of viewLastActivity) {
286+
if (now - lastActivity > COMMAND_TTL_MS) {
287+
viewLastActivity.delete(uuid);
269288
commandQueues.delete(uuid);
270289
viewFieldNames.delete(uuid);
271290
viewFieldInfo.delete(uuid);
272291
stopFileWatch(uuid);
273292
}
274293
}
275-
// Clean up empty queues with no active pollers
276-
for (const [uuid, entry] of commandQueues) {
277-
if (entry.commands.length === 0 && !pollWaiters.has(uuid)) {
278-
commandQueues.delete(uuid);
279-
}
280-
}
281294
}
282295

283-
// Periodic sweep so abandoned queues don't leak
296+
// Periodic sweep so abandoned views don't leak
284297
setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
285298

286299
function enqueueCommand(viewUUID: string, command: PdfCommand): void {
@@ -291,6 +304,7 @@ function enqueueCommand(viewUUID: string, command: PdfCommand): void {
291304
}
292305
entry.commands.push(command);
293306
entry.lastActivity = Date.now();
307+
touchView(viewUUID);
294308

295309
// Wake up any long-polling request waiting for this viewUUID
296310
const waiter = pollWaiters.get(viewUUID);
@@ -301,6 +315,9 @@ function enqueueCommand(viewUUID: string, command: PdfCommand): void {
301315
}
302316

303317
function dequeueCommands(viewUUID: string): PdfCommand[] {
318+
// Poll is activity — keep the view alive even when the queue is empty
319+
// (the common case: viewer polls every ~30s with nothing to receive).
320+
touchView(viewUUID);
304321
const entry = commandQueues.get(viewUUID);
305322
if (!entry) return [];
306323
const commands = entry.commands;
@@ -1290,6 +1307,9 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
12901307
// Probe file size so the client can set up range transport without an extra fetch
12911308
const { totalBytes } = await readPdfRange(normalized, 0, 1);
12921309
const uuid = randomUUID();
1310+
// Start the heartbeat now so the sweep can clean up viewFieldNames/
1311+
// viewFieldInfo/viewFileWatches even if no interact calls ever happen.
1312+
if (!disableInteract) touchView(uuid);
12931313

12941314
// Check writability (governs save button; see isWritablePath doc).
12951315
// Also requires OS-level W_OK so we don't lie on read-only mounts.

examples/pdf-server/src/annotation-panel.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,9 @@ export function getFormFieldLabel(name: string): string {
400400
function getAnnotationY(def: PdfAnnotationDef): number {
401401
if ("y" in def && typeof def.y === "number") return def.y;
402402
if ("rects" in def && def.rects.length > 0) return def.rects[0].y;
403+
// LineAnnotation has only x1/y1/x2/y2 — sort by the higher endpoint
404+
// (higher internal-y = closer to page top).
405+
if ("y1" in def) return Math.max(def.y1, def.y2);
403406
return 0;
404407
}
405408

0 commit comments

Comments
 (0)