Repository navigation
Conversation
…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 modelcontextprotocol#3045
|
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
Read 61396ea (not run). The reasoning holds: the client-side timers are armed while the clock is frozen, so awaiting One failure mode: Question: |
…e 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.
|
Thanks for the careful read, and sorry for the slow reply. Guard: added in 1811953, along the lines you suggested: const settledFirst = call.then(() => {
throw new Error('tools/call settled before the tool handler ran');
});
settledFirst.catch(() => {});
await Promise.race([callReceived, settledFirst]);The extra To check that it fires, I temporarily sent One limit worth stating: this only catches a call that settles. If the request is lost and nothing ever comes back, the per-request timeout can't fire either, since the fake clock is frozen at that point. That case still ends on the test timeout. I think that's acceptable, but happy to add a real-time bound if you'd rather. Unconditional vs. SSE-only: agreed, keeping it unconditional. On the other transports the wait is harmless: it resolves as soon as the call arrives and never moves the clock. One code path is also easier to read than a transport check. |
Fixes #3045.
Before:
protocol:timeout:max-totalswitched to fake timers, startedtools/call, and advanced the clock straight away. Over SSE the request is a real HTTP POST that fake timers don't wait for, so when it landed late the 500ms per-request timeout fired before any progress was sent. The SSE variants then failed intermittently withexpected 0 to be greater than or equal to 3, and when they did, the test never exercisedmaxTotalTimeout.After: the tool handler resolves a promise as soon as it starts, and the test awaits it before advancing the clock. Awaiting a promise doesn't move the fake clock, unlike
vi.waitFor, which advances fake timers while it polls. Test-only change, 9 lines, no changeset.Motivation and Context
On a clean clone on macOS,
pnpm test:allintermittently fails on this test, while CI onmainis green. Details and the original investigation are in #3045.One correction to that issue: it says the failure is followed by an unhandled
SdkError: Request timed out.max-totalattachescall.catch(() => {}), so that rejection isn't from this test. It comes fromprotocol:timeout:reset-on-progress(data: { timeout: 200 }), which leaves 1–2 unhandled rejections on every run on macOS, with or without this change. That's a separate problem and I've left it out of this PR; happy to open an issue for it.How Has This Been Tested?
On macOS (Darwin 27), Node 24.16.0, from
main@39ae8e61:pnpm vitest run scenarios/protocol.test.ts -t "timeout:basic|timeout:max-total"intest/e2e, repeated. Before: 5 of 20 SSE variants failed (10 runs). After: 0 of 40 (20 runs).tools/callPOST by 50ms of real time (via asetTimeoutcaptured beforevi.useFakeTimers()). Without this change both SSE variants failed on 3 of 3 runs; with it all 12 variants passed on 3 of 3 runs. The patch isn't part of this PR.pnpm lint:allandpnpm --filter @modelcontextprotocol/test-e2e typecheckpass. Inpnpm test:allevery test passes; vitest still exits 1 because of thereset-on-progressunhandled rejections described above.