From d4da0e599f426f05c16ab8fa173023c975813b32 Mon Sep 17 00:00:00 2001 From: pt-act Date: Sun, 30 Aug 2026 21:35:59 +0100 Subject: [PATCH] fix(e2e): stop the cap-eviction scenario from stampeding cold DO starts The scenario opened cap+10 sessions at concurrency 8, and every open is a cold Durable Object start (sqlite open plus runtime construction inside the agents SDK blockConcurrencyWhile). The burst regularly made those blocks outlive the runtime wall-clock budget, so workerd reset the object mid-initialize and the client received the 503 restart envelope instead of an mcp-session-id header - the scenario then failed on the very first reset. Fails on main today. Two changes, root cause first: - Open at concurrency 2. Cold starts no longer overlap into reset territory; the scenario passes in ~5s locally, 4/4 consecutive runs. - openSession now honors the restart envelope it can receive: on the documented 503 "MCP session is restarting, please retry" response it retries the same initialize after a short delay (bounded, 8 attempts) instead of treating a retryable platform blip as a setup failure - the same contract a real streamable-http client follows. --- e2e/cloud/mcp-session-cap-eviction.test.ts | 61 ++++++++++++++++------ 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/e2e/cloud/mcp-session-cap-eviction.test.ts b/e2e/cloud/mcp-session-cap-eviction.test.ts index 6cbddde6a..fd1726cd6 100644 --- a/e2e/cloud/mcp-session-cap-eviction.test.ts +++ b/e2e/cloud/mcp-session-cap-eviction.test.ts @@ -73,25 +73,48 @@ const openSession = async ( label: string, recordSession: (sessionId: string) => void, ): Promise => { - const initialized = await postJson(mcpUrl, bearer, { - jsonrpc: "2.0" as const, - id: "initialize", - method: "initialize", - params: { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" }, - }, - }); - const sessionId = initialized.headers.get("mcp-session-id"); - if (!sessionId) { + // The platform can reset a session Durable Object while its initialize is + // in flight (a burst of cold starts makes the agents SDK's + // blockConcurrencyWhile start-up block outlive the runtime's budget). The + // server answers that with the restart envelope — 503, JSON-RPC -32001, + // `MCP session is restarting, please retry` — which is exactly the + // contract a real streamable-http client honors: same request, after the + // advertised delay. Treat it as transient here too instead of failing the + // scenario on a retryable platform blip. + const RESTART_ATTEMPTS = 8; + const RESTART_DELAY_MS = 250; + let minted: { readonly response: Response; readonly sessionId: string } | undefined; + for (let attempt = 0; attempt < RESTART_ATTEMPTS; attempt += 1) { + const response = await postJson(mcpUrl, bearer, { + jsonrpc: "2.0" as const, + id: "initialize", + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" }, + }, + }); + const candidate = response.headers.get("mcp-session-id"); + if (candidate !== null && candidate.length > 0) { + minted = { response, sessionId: candidate }; + break; + } + const body = await response.text().catch(() => ""); + const isRestart = response.status === 503 && body.includes("MCP session is restarting"); + if (!isRestart) break; + if (attempt === RESTART_ATTEMPTS - 1) break; + await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS)); + } + if (!minted) { // oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition. throw new Error(`openSession (${label}): no mcp-session-id header`); } - // Recorded the moment the id exists — BEFORE the body read and status + const { response: initialized, sessionId } = minted; + // Recorded the moment the id exists - BEFORE the body read and status // assertion below, either of which can throw with the session already live // on the server. The cleanup finalizer needs the id on every one of those - // paths, not just a fully successful return. + // paths, not just on a fully successful return. recordSession(sessionId); await initialized.text(); expect(initialized.status, `initialize (${label}) opens a session`).toBe(200); @@ -159,7 +182,15 @@ scenario( openedSessionIds.push(sessionId); }), ), - { concurrency: 8 }, + // 2, not 8: each open is a cold Durable Object start (sqlite open plus + // runtime construction inside the agents SDK's blockConcurrencyWhile). + // A wide burst makes those blocks outlive the runtime's wall-clock + // budget and the platform resets the object mid-initialize — the 503 + // restart envelope the retry inside openSession then fights, loading + // the server with replays while the counter climbs. A narrow opening + // order keeps cold starts from overlapping into reset territory; the + // retry stays as a backstop for the occasional blip. + { concurrency: 2 }, ); expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);