Skip to content

Commit d4da0e5

Browse files
committed
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.
1 parent fff7ed6 commit d4da0e5

1 file changed

Lines changed: 46 additions & 15 deletions

File tree

e2e/cloud/mcp-session-cap-eviction.test.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,25 +73,48 @@ const openSession = async (
7373
label: string,
7474
recordSession: (sessionId: string) => void,
7575
): Promise<string> => {
76-
const initialized = await postJson(mcpUrl, bearer, {
77-
jsonrpc: "2.0" as const,
78-
id: "initialize",
79-
method: "initialize",
80-
params: {
81-
protocolVersion: PROTOCOL_VERSION,
82-
capabilities: {},
83-
clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
84-
},
85-
});
86-
const sessionId = initialized.headers.get("mcp-session-id");
87-
if (!sessionId) {
76+
// The platform can reset a session Durable Object while its initialize is
77+
// in flight (a burst of cold starts makes the agents SDK's
78+
// blockConcurrencyWhile start-up block outlive the runtime's budget). The
79+
// server answers that with the restart envelope — 503, JSON-RPC -32001,
80+
// `MCP session is restarting, please retry` — which is exactly the
81+
// contract a real streamable-http client honors: same request, after the
82+
// advertised delay. Treat it as transient here too instead of failing the
83+
// scenario on a retryable platform blip.
84+
const RESTART_ATTEMPTS = 8;
85+
const RESTART_DELAY_MS = 250;
86+
let minted: { readonly response: Response; readonly sessionId: string } | undefined;
87+
for (let attempt = 0; attempt < RESTART_ATTEMPTS; attempt += 1) {
88+
const response = await postJson(mcpUrl, bearer, {
89+
jsonrpc: "2.0" as const,
90+
id: "initialize",
91+
method: "initialize",
92+
params: {
93+
protocolVersion: PROTOCOL_VERSION,
94+
capabilities: {},
95+
clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
96+
},
97+
});
98+
const candidate = response.headers.get("mcp-session-id");
99+
if (candidate !== null && candidate.length > 0) {
100+
minted = { response, sessionId: candidate };
101+
break;
102+
}
103+
const body = await response.text().catch(() => "");
104+
const isRestart = response.status === 503 && body.includes("MCP session is restarting");
105+
if (!isRestart) break;
106+
if (attempt === RESTART_ATTEMPTS - 1) break;
107+
await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS));
108+
}
109+
if (!minted) {
88110
// oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition.
89111
throw new Error(`openSession (${label}): no mcp-session-id header`);
90112
}
91-
// Recorded the moment the id exists — BEFORE the body read and status
113+
const { response: initialized, sessionId } = minted;
114+
// Recorded the moment the id exists - BEFORE the body read and status
92115
// assertion below, either of which can throw with the session already live
93116
// on the server. The cleanup finalizer needs the id on every one of those
94-
// paths, not just a fully successful return.
117+
// paths, not just on a fully successful return.
95118
recordSession(sessionId);
96119
await initialized.text();
97120
expect(initialized.status, `initialize (${label}) opens a session`).toBe(200);
@@ -159,7 +182,15 @@ scenario(
159182
openedSessionIds.push(sessionId);
160183
}),
161184
),
162-
{ concurrency: 8 },
185+
// 2, not 8: each open is a cold Durable Object start (sqlite open plus
186+
// runtime construction inside the agents SDK's blockConcurrencyWhile).
187+
// A wide burst makes those blocks outlive the runtime's wall-clock
188+
// budget and the platform resets the object mid-initialize — the 503
189+
// restart envelope the retry inside openSession then fights, loading
190+
// the server with replays while the counter climbs. A narrow opening
191+
// order keeps cold starts from overlapping into reset territory; the
192+
// retry stays as a backstop for the occasional blip.
193+
{ concurrency: 2 },
163194
);
164195

165196
expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);

0 commit comments

Comments
 (0)