Skip to content

Commit fb808c8

Browse files
authored
Serialize MCP session DO startup (#1313)
* Add e2e coverage for concurrent SSE GET + POST reconnect after idle dispose * Serialize MCP session startup * Drop repro notes doc * Neutral wording for the session startup regression test
1 parent f275d5f commit fb808c8

3 files changed

Lines changed: 377 additions & 46 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Cloud: regression test for a session-DO startup race.
2+
//
3+
// 1. Open an MCP session (real SDK client init, capture mcp-session-id),
4+
// make a call, close the client.
5+
// 2. Idle past the session timeout so the DO's alarm runs disposeIdleRuntime
6+
// (runtime torn down, `initialized = false`, transport cleared).
7+
// 3. Fire an SSE GET (listen stream) and a POST (tools/list) concurrently on
8+
// the same session id.
9+
// 4. The session must survive: the concurrent pair and follow-up calls
10+
// should succeed.
11+
//
12+
// Mechanism guarded against: a request carrying a session id runs
13+
// validateMcpSessionOwner, which restores via onStart when the runtime is
14+
// disposed. The SDK's serve() streaming handler separately wakes the DO
15+
// through agent.fetch, and the Agent base also drives onStart. Without
16+
// serialization across both entry paths, two onStart calls interleave and the
17+
// second server.connect throws "Already connected to a transport", after
18+
// which every request on the session fails the same way. The concurrent pair
19+
// itself can answer 200/200 (one restore wins the race), so the assertions
20+
// cover the follow-up calls, which is where the failure persists.
21+
//
22+
import { writeFileSync } from "node:fs";
23+
import { join } from "node:path";
24+
25+
import { expect } from "@effect/vitest";
26+
import { Effect } from "effect";
27+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
28+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
29+
30+
import { scenario } from "../src/scenario";
31+
import { Mcp, RunDir, Target } from "../src/services";
32+
import type { Identity } from "../src/target";
33+
import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts";
34+
35+
const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
36+
37+
// Give the DO's idle alarm room to fire disposeIdleRuntime after the timeout.
38+
const IDLE_DISPOSE_BUFFER_MS = 2_000;
39+
const IDLE_DISPOSE_GAP_MS = configuredMcpSessionTimeoutMs() + IDLE_DISPOSE_BUFFER_MS;
40+
const SCENARIO_TIMEOUT_MS = IDLE_DISPOSE_GAP_MS + 120_000;
41+
42+
interface Connected {
43+
readonly client: Client;
44+
readonly transport: StreamableHTTPClientTransport;
45+
}
46+
47+
const connectClient = async (mcpUrl: string, bearer: string): Promise<Connected> => {
48+
const client = new Client({ name: "executor-e2e-brick", version: "0.0.1" }, { capabilities: {} });
49+
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
50+
requestInit: { headers: { authorization: `Bearer ${bearer}` } },
51+
});
52+
await client.connect(transport);
53+
return { client, transport };
54+
};
55+
56+
interface RawResult {
57+
readonly status: number;
58+
readonly body: string;
59+
}
60+
61+
/** A raw POST of a JSON-RPC request onto an existing session id. */
62+
const rawPost = async (
63+
mcpUrl: string,
64+
bearer: string,
65+
sessionId: string,
66+
message: unknown,
67+
): Promise<RawResult> => {
68+
const res = await fetch(mcpUrl, {
69+
method: "POST",
70+
headers: {
71+
authorization: `Bearer ${bearer}`,
72+
"content-type": "application/json",
73+
// The streamable-http transport requires the client accept both.
74+
accept: "application/json, text/event-stream",
75+
"mcp-session-id": sessionId,
76+
"mcp-protocol-version": "2025-06-18",
77+
},
78+
body: JSON.stringify(message),
79+
});
80+
const body = await res.text().catch(() => "");
81+
return { status: res.status, body };
82+
};
83+
84+
/** A raw SSE GET (the "listen" stream) on an existing session id. We only need
85+
* the response head — status + start of body — to see whether it 500s. */
86+
const rawSseGet = async (mcpUrl: string, bearer: string, sessionId: string): Promise<RawResult> => {
87+
const res = await fetch(mcpUrl, {
88+
method: "GET",
89+
headers: {
90+
authorization: `Bearer ${bearer}`,
91+
accept: "text/event-stream",
92+
"mcp-session-id": sessionId,
93+
"mcp-protocol-version": "2025-06-18",
94+
},
95+
});
96+
// A 200 opens a long-lived stream; don't hang reading it. A 500 has a short
97+
// (usually empty) body we can read fully.
98+
let body = "";
99+
if (res.status !== 200) {
100+
body = await res.text().catch(() => "");
101+
} else {
102+
// Cancel the stream immediately; we only wanted the status.
103+
await res.body?.cancel().catch(() => undefined);
104+
}
105+
return { status: res.status, body };
106+
};
107+
108+
const toolsListMessage = { jsonrpc: "2.0" as const, id: "brick-tools-list", method: "tools/list" };
109+
110+
const isBrick = (r: RawResult): boolean =>
111+
r.status >= 500 || /"code"\s*:\s*-32603/.test(r.body) || /Already connected/i.test(r.body);
112+
113+
scenario(
114+
"REGRESSION · concurrent SSE GET + POST after idle-dispose keeps the session alive",
115+
{ timeout: SCENARIO_TIMEOUT_MS },
116+
Effect.gen(function* () {
117+
const target = yield* Target;
118+
const mcp = yield* Mcp;
119+
const runDir = yield* RunDir;
120+
const identity = yield* target.newIdentity();
121+
const bearer = yield* mcp.mintBearer(emailOf(identity));
122+
const diagnostics: unknown[] = [];
123+
const record = (entry: Record<string, unknown>): void => {
124+
diagnostics.push(entry);
125+
console.log(JSON.stringify(entry));
126+
// Vitest swallows worker stdout on passing runs; the artifact dir is the
127+
// durable copy of what each attempt observed.
128+
writeFileSync(join(runDir, "repro-diagnostics.json"), JSON.stringify(diagnostics, null, 2));
129+
};
130+
131+
// 1. Open a real session (SDK init handshake persists the session in the
132+
// DO), do a call, close the client.
133+
const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer));
134+
const sessionId = first.transport.sessionId;
135+
expect(sessionId, "the client got a session id").toEqual(expect.any(String));
136+
if (sessionId === undefined) return yield* Effect.die("missing session id");
137+
138+
yield* Effect.promise(() =>
139+
first.client.callTool({ name: "execute", arguments: { code: "return 1;" } }),
140+
);
141+
yield* Effect.promise(() => first.client.close().catch(() => undefined));
142+
143+
// 2. Idle past the timeout so the DO's alarm runs disposeIdleRuntime.
144+
yield* Effect.sleep(IDLE_DISPOSE_GAP_MS);
145+
146+
// 3. Fire the SSE GET and the POST CONCURRENTLY on the same session id.
147+
// Raw fetch gives precise concurrency control (both start before either
148+
// resolves) that the SDK client's request queue would serialize away.
149+
const [getResult, postResult] = yield* Effect.promise(() =>
150+
Promise.all([
151+
rawSseGet(target.mcpUrl, bearer, sessionId),
152+
rawPost(target.mcpUrl, bearer, sessionId, toolsListMessage),
153+
]),
154+
);
155+
156+
record({
157+
event: "repro_concurrent_pair",
158+
sessionId,
159+
get: getResult,
160+
post: postResult,
161+
});
162+
163+
const concurrentBricked = isBrick(getResult) || isBrick(postResult);
164+
165+
// 4. Prove the session survives: subsequent SEQUENTIAL POSTs also succeed.
166+
// This is the same control path as the idle-restore scenario from #1302.
167+
//
168+
// Observed locally: the concurrent pair frequently returns 200/200 (one
169+
// of the two restores wins the race and answers), but the race leaves
170+
// the DO's McpServer in the "Already connected to a transport" state, so
171+
// the very NEXT request 500s — and every request after that. The
172+
// regression gate checks the same two follow-up calls now stay healthy.
173+
const followUp = yield* Effect.promise(() =>
174+
rawPost(target.mcpUrl, bearer, sessionId, {
175+
...toolsListMessage,
176+
id: "brick-follow-up",
177+
}),
178+
);
179+
const followUp2 = yield* Effect.promise(() =>
180+
rawPost(target.mcpUrl, bearer, sessionId, {
181+
...toolsListMessage,
182+
id: "brick-follow-up-2",
183+
}),
184+
);
185+
record({ event: "repro_follow_up", sessionId, followUp, followUp2 });
186+
187+
const permanentlyBricked = isBrick(followUp) && isBrick(followUp2);
188+
189+
expect(getResult.status, "the concurrent SSE GET opens successfully").toBe(200);
190+
expect(postResult.status, "the concurrent POST answers successfully").toBe(200);
191+
expect(isBrick(getResult), "the concurrent SSE GET does not expose the transport brick").toBe(
192+
false,
193+
);
194+
expect(isBrick(postResult), "the concurrent POST does not expose the transport brick").toBe(
195+
false,
196+
);
197+
expect(followUp.status, "the first follow-up POST answers successfully").toBe(200);
198+
expect(followUp2.status, "the second follow-up POST answers successfully").toBe(200);
199+
expect(isBrick(followUp), "the first follow-up POST does not expose the transport brick").toBe(
200+
false,
201+
);
202+
expect(
203+
isBrick(followUp2),
204+
"the second follow-up POST does not expose the transport brick",
205+
).toBe(false);
206+
expect(
207+
permanentlyBricked,
208+
"after the concurrent SSE-GET + POST post-idle, the session remains usable",
209+
).toBe(false);
210+
// Diagnostic (not a gate): whether the collision surfaced on the concurrent
211+
// pair itself vs. only on the follow-up depends on which restore won the
212+
// race. Record it so the artifact shows the timing that occurred this run.
213+
record({ event: "repro_variant", concurrentBricked, permanentlyBricked });
214+
}),
215+
);

0 commit comments

Comments
 (0)