From 61396ea156bb60a14b95c8d0e92aa81ad25db9c3 Mon Sep 17 00:00:00 2001 From: Jerry Qian <36783706+JQ100@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:11:54 -0700 Subject: [PATCH 1/2] test(e2e): wait for the call to reach the server in protocol:timeout:max-total The test switched to fake timers and advanced the clock immediately after starting tools/call. Over SSE the request is a real HTTP POST that fake timers do not wait for, so when it was slow to land the 500ms per-request timeout fired before any progress was sent, and the test failed without ever exercising maxTotalTimeout. Resolve a promise when the tool handler starts and await it before advancing. Awaiting a promise does not move the fake clock, unlike vi.waitFor, which advances fake timers while it polls. On macOS, -t "timeout:basic|timeout:max-total" failed 5 of 20 SSE variants before this change and 0 of 40 after. Delaying only the tools/call POST by 50ms of real time fails both SSE variants on every run without the change and passes all 12 variants with it. Fixes #3045 --- test/e2e/scenarios/protocol.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 4abcde078d..66f3f5f2e7 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -662,12 +662,15 @@ verifies('protocol:timeout:basic', async ({ transport }: TestArgs) => { verifies('protocol:timeout:max-total', async ({ transport }: TestArgs) => { vi.useFakeTimers(); try { + let markCallReceived!: () => void; + const callReceived = new Promise(resolve => (markCallReceived = resolve)); const makeServer = () => { const s = new McpServer({ name: 's', version: '0' }); s.registerTool( 'slow-progress', { inputSchema: z.object({ delayMs: z.number(), steps: z.number() }) }, async ({ delayMs, steps }, ctx) => { + markCallReceived(); const token = ctx.mcpReq._meta?.progressToken; if (token !== undefined) { for (let i = 1; i <= steps; i++) { @@ -703,6 +706,12 @@ verifies('protocol:timeout:max-total', async ({ transport }: TestArgs) => { ); call.catch(() => {}); + // Over SSE the tools/call POST is real I/O that fake timers do not wait for. + // Advancing before it lands lets the per-request timeout fire before any + // progress is sent, so maxTotalTimeout is never exercised (#3045). Awaiting a + // promise, unlike vi.waitFor, does not move the fake clock. + await callReceived; + for (let elapsed = 0; elapsed < maxTotal + perChunk; elapsed += delayMs) { await vi.advanceTimersByTimeAsync(delayMs); } From 18119535688930339825164108786f224aaf9ef9 Mon Sep 17 00:00:00 2001 From: Jerry Qian <36783706+JQ100@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:24:00 -0800 Subject: [PATCH 2/2] test(e2e): fail fast if the max-total call settles before reaching the handler Awaiting callReceived has no reject path: if tools/call settles without reaching the tool handler (e.g. its arguments fail validation), the test waits until vitest's 15s test timeout and reports only "Test timed out". Race callReceived against the call settling so the test fails at once with a named error. The guard's promise gets its own no-op catch, because once callReceived wins, the call later rejects with the expected timeout and that rejection would otherwise be unhandled. --- test/e2e/scenarios/protocol.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 66f3f5f2e7..8e0013b3c6 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -709,8 +709,14 @@ verifies('protocol:timeout:max-total', async ({ transport }: TestArgs) => { // Over SSE the tools/call POST is real I/O that fake timers do not wait for. // Advancing before it lands lets the per-request timeout fire before any // progress is sent, so maxTotalTimeout is never exercised (#3045). Awaiting a - // promise, unlike vi.waitFor, does not move the fake clock. - await callReceived; + // promise, unlike vi.waitFor, does not move the fake clock. If the call settles + // without reaching the handler (e.g. its arguments are rejected), fail with + // that reason instead of waiting on callReceived until the test times out. + const settledFirst = call.then(() => { + throw new Error('tools/call settled before the tool handler ran'); + }); + settledFirst.catch(() => {}); + await Promise.race([callReceived, settledFirst]); for (let elapsed = 0; elapsed < maxTotal + perChunk; elapsed += delayMs) { await vi.advanceTimersByTimeAsync(delayMs);