Skip to content

Commit b0ac2bc

Browse files
committed
fix(pdf-server): fail fast on never-polled viewer + surface batch errors
interact's get_screenshot/get_text waited the full 45s for a viewer that never mounted (host went idle before iframe reached startPolling), then timed out with a generic message. Now tracks viewsPolled and fails in ~8s with a recovery hint. Batch errors were also ordered success-first — hosts that flatten isError to content[0] showed "Queued: Filled N fields" and dropped the actual failure entirely. Viewer side: poll even when PDF load throws, and catch connect() rejection instead of silently blanking.
1 parent 14969c5 commit b0ac2bc

3 files changed

Lines changed: 232 additions & 14 deletions

File tree

examples/pdf-server/server.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,4 +1054,142 @@ describe("interact tool", () => {
10541054
await server.close();
10551055
});
10561056
});
1057+
1058+
describe("viewer liveness", () => {
1059+
// get_screenshot/get_text fail fast when the iframe never polled, instead
1060+
// of waiting 45s for a viewer that isn't there. Reproduces the case where
1061+
// the host goes idle before the iframe reaches startPolling().
1062+
1063+
it("get_screenshot fails fast when viewer never polled", async () => {
1064+
const { server, client } = await connect();
1065+
const uuid = "never-polled-screenshot";
1066+
1067+
const started = Date.now();
1068+
const r = await client.callTool({
1069+
name: "interact",
1070+
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
1071+
});
1072+
const elapsed = Date.now() - started;
1073+
1074+
expect(r.isError).toBe(true);
1075+
expect(firstText(r)).toContain("never connected");
1076+
expect(firstText(r)).toContain(uuid);
1077+
expect(firstText(r)).toContain("display_pdf again"); // recovery hint
1078+
// Fast-fail bound (~8s grace), well under the 45s page-data timeout.
1079+
// 15s upper bound leaves slack for CI scheduling.
1080+
expect(elapsed).toBeLessThan(15_000);
1081+
1082+
await client.close();
1083+
await server.close();
1084+
}, 20_000);
1085+
1086+
it("get_screenshot waits full timeout when viewer polled then went silent", async () => {
1087+
// Viewer polled once (proving it mounted) then hung on a heavy render.
1088+
// The grace check passes, so we fall through to the 45s page-data wait —
1089+
// verified here by racing against a 12s deadline that should NOT win.
1090+
const { server, client } = await connect();
1091+
const uuid = "polled-then-silent";
1092+
1093+
// Viewer's first poll: drain whatever's there so it returns fast.
1094+
// Enqueue a trivial command first so poll returns via the batch-wait
1095+
// path (~200ms) instead of blocking on the 30s long-poll.
1096+
await client.callTool({
1097+
name: "interact",
1098+
arguments: { viewUUID: uuid, action: "navigate", page: 1 },
1099+
});
1100+
await poll(client, uuid);
1101+
1102+
// Now get_screenshot — viewer has polled, so no fast-fail. But viewer
1103+
// never calls submit_page_data → should wait beyond the grace period.
1104+
const outcome = await Promise.race([
1105+
client
1106+
.callTool({
1107+
name: "interact",
1108+
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
1109+
})
1110+
.then(() => "completed" as const),
1111+
new Promise<"still-waiting">((r) =>
1112+
setTimeout(() => r("still-waiting"), 12_000),
1113+
),
1114+
]);
1115+
1116+
expect(outcome).toBe("still-waiting");
1117+
1118+
await client.close();
1119+
await server.close();
1120+
}, 20_000);
1121+
1122+
it("get_screenshot succeeds when viewer polls during grace window", async () => {
1123+
// Model calls interact before the viewer has polled — but the viewer
1124+
// shows up within the grace period and completes the roundtrip.
1125+
const { server, client } = await connect();
1126+
const uuid = "late-arriving-viewer";
1127+
1128+
const interactPromise = client.callTool({
1129+
name: "interact",
1130+
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
1131+
});
1132+
1133+
// Viewer connects 500ms late — well inside the grace window.
1134+
await new Promise((r) => setTimeout(r, 500));
1135+
const cmds = await poll(client, uuid);
1136+
const getPages = cmds.find((c) => c.type === "get_pages");
1137+
expect(getPages).toBeDefined();
1138+
1139+
// Viewer responds with the page data.
1140+
await client.callTool({
1141+
name: "submit_page_data",
1142+
arguments: {
1143+
requestId: getPages!.requestId as string,
1144+
pages: [
1145+
{ page: 1, image: Buffer.from("fake-jpeg").toString("base64") },
1146+
],
1147+
},
1148+
});
1149+
1150+
const r = await interactPromise;
1151+
expect(r.isError).toBeFalsy();
1152+
expect((r.content as Array<{ type: string }>)[0].type).toBe("image");
1153+
1154+
await client.close();
1155+
await server.close();
1156+
}, 15_000);
1157+
1158+
it("batch failure puts error message first", async () => {
1159+
// When [fill_form, get_screenshot] runs and get_screenshot times out,
1160+
// content[0] must describe the failure — not the earlier success. Some
1161+
// hosts flatten isError results to content[0].text only, which previously
1162+
// showed "Queued: Filled N fields" with isError:true and dropped the
1163+
// actual timeout entirely.
1164+
const { server, client } = await connect();
1165+
const uuid = "batch-error-ordering";
1166+
1167+
const r = await client.callTool({
1168+
name: "interact",
1169+
arguments: {
1170+
viewUUID: uuid,
1171+
commands: [
1172+
{ action: "navigate", page: 3 }, // succeeds → "Queued: ..."
1173+
{ action: "get_screenshot", page: 1 }, // never-polled → fast-fail
1174+
],
1175+
},
1176+
});
1177+
1178+
expect(r.isError).toBe(true);
1179+
const texts = (r.content as Array<{ type: string; text: string }>).map(
1180+
(c) => c.text,
1181+
);
1182+
// content[0]: batch-failed summary naming the culprit
1183+
expect(texts[0]).toContain("failed");
1184+
expect(texts[0]).toContain("2/2");
1185+
expect(texts[0]).toContain("get_screenshot");
1186+
// content[1]: the actual error
1187+
expect(texts[1]).toContain("never connected");
1188+
// content[2]: the earlier success, pushed to the back
1189+
expect(texts[2]).toContain("Queued");
1190+
1191+
await client.close();
1192+
await server.close();
1193+
}, 15_000);
1194+
});
10571195
});

examples/pdf-server/server.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ export type { PdfCommand };
196196
// reject first and return a real error instead of the client cancelling us.
197197
const GET_PAGES_TIMEOUT_MS = 45_000;
198198

199+
/**
200+
* Grace period for the viewer's first poll. If interact() arrives before the
201+
* iframe has ever polled, we wait this long for it to show up (iframe mount +
202+
* PDF load + startPolling). If no poll comes, the viewer almost certainly
203+
* never rendered — failing fast beats a silent 45s hang.
204+
*/
205+
const VIEWER_FIRST_POLL_GRACE_MS = 8_000;
206+
199207
interface PageDataEntry {
200208
page: number;
201209
text?: string;
@@ -232,6 +240,33 @@ function waitForPageData(
232240
});
233241
}
234242

243+
/**
244+
* Wait for the viewer's first poll_pdf_commands call.
245+
*
246+
* Called before waitForPageData() so a viewer that never mounted fails in ~8s
247+
* with a specific message instead of a generic 45s "Timeout waiting for page
248+
* data" that gives no hint why.
249+
*
250+
* Intentionally does NOT touch pollWaiters: piggybacking on that single-slot
251+
* Map races with poll_pdf_commands' batch-wait branch (which never cancels the
252+
* prior waiter) and with concurrent interact calls (which would overwrite each
253+
* other). A plain check loop on viewsPolled is stateless — multiple callers
254+
* can wait independently and all observe the same add() when it happens.
255+
*/
256+
async function ensureViewerIsPolling(uuid: string): Promise<void> {
257+
const deadline = Date.now() + VIEWER_FIRST_POLL_GRACE_MS;
258+
while (!viewsPolled.has(uuid)) {
259+
if (Date.now() >= deadline) {
260+
throw new Error(
261+
`Viewer never connected for viewUUID ${uuid} (no poll within ${VIEWER_FIRST_POLL_GRACE_MS / 1000}s). ` +
262+
`The iframe likely failed to mount — this happens when the conversation ` +
263+
`goes idle before the viewer finishes loading. Call display_pdf again to get a fresh viewUUID.`,
264+
);
265+
}
266+
await new Promise((r) => setTimeout(r, 100));
267+
}
268+
}
269+
235270
interface QueueEntry {
236271
commands: PdfCommand[];
237272
/** Timestamp of the most recent enqueue or dequeue */
@@ -243,6 +278,15 @@ const commandQueues = new Map<string, QueueEntry>();
243278
/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */
244279
const pollWaiters = new Map<string, () => void>();
245280

281+
/**
282+
* viewUUIDs that have been polled at least once. A view missing from this set
283+
* means the iframe never reached startPolling() — usually because it wasn't
284+
* mounted yet, or ontoolresult threw before the poll loop started. Used to
285+
* fail fast in get_screenshot/get_text instead of waiting the full 45s for
286+
* a viewer that was never there.
287+
*/
288+
const viewsPolled = new Set<string>();
289+
246290
/** Valid form field names per viewer UUID (populated during display_pdf) */
247291
const viewFieldNames = new Map<string, Set<string>>();
248292

@@ -288,6 +332,7 @@ function pruneStaleQueues(): void {
288332
commandQueues.delete(uuid);
289333
viewFieldNames.delete(uuid);
290334
viewFieldInfo.delete(uuid);
335+
viewsPolled.delete(uuid);
291336
stopFileWatch(uuid);
292337
}
293338
}
@@ -1925,6 +1970,7 @@ URL: ${normalized}`,
19251970

19261971
let pageData: PageDataEntry[];
19271972
try {
1973+
await ensureViewerIsPolling(uuid);
19281974
pageData = await waitForPageData(requestId, signal);
19291975
} catch (err) {
19301976
return {
@@ -1973,6 +2019,7 @@ URL: ${normalized}`,
19732019

19742020
let pageData: PageDataEntry[];
19752021
try {
2022+
await ensureViewerIsPolling(uuid);
19762023
pageData = await waitForPageData(requestId, signal);
19772024
} catch (err) {
19782025
return {
@@ -2202,7 +2249,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
22022249

22032250
// Process commands sequentially, collecting all content parts
22042251
const allContent: ContentPart[] = [];
2205-
let hasError = false;
2252+
let failedAt = -1;
22062253

22072254
for (let i = 0; i < commandList.length; i++) {
22082255
const result = await processInteractCommand(
@@ -2211,15 +2258,27 @@ Example — add a signature image and a stamp, then screenshot to verify:
22112258
extra.signal,
22122259
);
22132260
if (result.isError) {
2214-
hasError = true;
2261+
// Error content first. Some hosts flatten isError results to
2262+
// content[0].text — if we push the error after prior successes,
2263+
// the user sees "Queued: Filled 7 fields" with isError:true and
2264+
// the actual failure is silently dropped.
2265+
allContent.unshift(...result.content);
2266+
failedAt = i;
2267+
break;
22152268
}
22162269
allContent.push(...result.content);
2217-
if (hasError) break; // Stop on first error
2270+
}
2271+
2272+
if (failedAt >= 0 && commandList.length > 1) {
2273+
allContent.unshift({
2274+
type: "text",
2275+
text: `Batch failed at step ${failedAt + 1}/${commandList.length} (${commandList[failedAt].action}):`,
2276+
});
22182277
}
22192278

22202279
return {
22212280
content: allContent,
2222-
...(hasError ? { isError: true } : {}),
2281+
...(failedAt >= 0 ? { isError: true } : {}),
22232282
};
22242283
},
22252284
);
@@ -2280,6 +2339,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
22802339
_meta: { ui: { visibility: ["app"] } },
22812340
},
22822341
async ({ viewUUID: uuid }): Promise<CallToolResult> => {
2342+
viewsPolled.add(uuid);
22832343
// If commands are already queued, wait briefly to let more accumulate
22842344
if (commandQueues.has(uuid)) {
22852345
await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));

examples/pdf-server/src/mcp-app.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4093,6 +4093,15 @@ app.ontoolresult = async (result: CallToolResult) => {
40934093
} catch (err) {
40944094
log.error("Error loading PDF:", err);
40954095
showError(err instanceof Error ? err.message : String(err));
4096+
// Poll anyway. The server's interact tool has no way to know we choked —
4097+
// without a poll it waits 45s on every get_screenshot against this
4098+
// viewUUID. handleGetPages already null-guards pdfDocument, so a failed
4099+
// load just means empty page data → server returns "No screenshot
4100+
// returned" (fast, actionable) instead of "Timeout waiting for page data
4101+
// from viewer" (slow, opaque).
4102+
if (viewUUID && interactEnabled) {
4103+
startPolling();
4104+
}
40964105
}
40974106
};
40984107

@@ -4440,16 +4449,27 @@ app.onteardown = async () => {
44404449
app.onhostcontextchanged = handleHostContextChanged;
44414450

44424451
// Connect to host
4443-
app.connect().then(() => {
4444-
log.info("Connected to host");
4445-
const ctx = app.getHostContext();
4446-
if (ctx) {
4447-
handleHostContextChanged(ctx);
4448-
}
4449-
// Restore annotations early using toolInfo.id (available before tool result)
4450-
restoreAnnotations();
4451-
updateAnnotationsBadge();
4452-
});
4452+
app
4453+
.connect()
4454+
.then(() => {
4455+
log.info("Connected to host");
4456+
const ctx = app.getHostContext();
4457+
if (ctx) {
4458+
handleHostContextChanged(ctx);
4459+
}
4460+
// Restore annotations early using toolInfo.id (available before tool result)
4461+
restoreAnnotations();
4462+
updateAnnotationsBadge();
4463+
})
4464+
.catch((err: unknown) => {
4465+
// ui/initialize failed or transport rejected. Without a catch this is an
4466+
// unhandled rejection — iframe shows blank, server times out on every
4467+
// interact call with no clue why.
4468+
log.error("Failed to connect to host:", err);
4469+
showError(
4470+
`Failed to connect to host: ${err instanceof Error ? err.message : String(err)}`,
4471+
);
4472+
});
44534473

44544474
// Debug helper: dump all annotation state. Run in DevTools console as
44554475
// `__pdfDebug()` to diagnose ghost annotations (visible on canvas but not

0 commit comments

Comments
 (0)