History

Keep the standalone MCP SSE listener open after undelivered replay (#1629)

Rhys Sullivan committed 2 months ago

Commit 75a3c451 parent 003fb072 files · +52 −18Browse files

Unified · Split

2 files
  1. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +13 −1
  2. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +39 −17
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+13−1
@@ -418,9 +418,21 @@ describe("McpAgentSessionDOBase session serving", () => { · 417 unmodified lines
418418 }),
419419 ),
420420 );
421− const standaloneReplayBody = await standaloneReplay.text();
421+ // The standalone listener replays undelivered responses as its opening
422+ // frames and then STAYS OPEN (a close-after-replay here put every active
423+ // client into a permanent reconnect loop), so read incrementally instead
424+ // of draining to EOF.
425+ const standaloneReader = standaloneReplay.body?.getReader();
426+ const decoder = new TextDecoder();
427+ let standaloneReplayBody = "";
428+ while (!standaloneReplayBody.includes("slow result")) {
429+ const next = await standaloneReader?.read();
430+ if (!next || next.done) break;
431+ standaloneReplayBody += decoder.decode(next.value, { stream: true });
432+ }
422433 expect(standaloneReplayBody).toContain("slow result");
423434 expect(standaloneReplayBody).toContain("event: message");
435+ await standaloneReader?.cancel("test complete");
424436 await state.flushWaitUntil();
425437 });
426438
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+39−17
@@ -1148,14 +1148,36 @@ export abstract class McpAgentSessionDOBase< · 1147 unmodified lines
11481148 },
11491149 onClose: (reason) => {
11501150 this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1);
1151− if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) {
1151+ // "complete": the source body drained to its natural end. "rotate":
1152+ // the client stayed attached for the whole max-age window, so the
1153+ // initial replay frames were drained long before. Both count as
1154+ // delivered. "cancel"/"error" leave the streams replayable for the
1155+ // client's reconnect GET.
1156+ if (
1157+ (reason === "complete" || reason === "rotate") &&
1158+ options.acknowledge &&
1159+ options.acknowledge.length > 0
1160+ ) {
11521161 this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge));
11531162 }
11541163 },
11551164 });
11561165
1157− private async replayUndeliveredOnStandaloneGet(request: Request): Promise<Response | null> {
1158− if (request.method !== "GET" || request.headers.has("last-event-id")) return null;
1166+ /**
1167+ * Collect every undelivered stream's events as one SSE frame block, for
1168+ * prepending onto the standalone GET stream. Returning a FINITE response
1169+ * here instead (the pre-2026-08-17 behavior) was catastrophic: the client's
1170+ * standalone listener closed the moment the replay flushed, and because
1171+ * every POST marks its stream undelivered until acknowledged, an active
1172+ * session ALWAYS had something to replay — so every listener GET became a
1173+ * ~3s reconnect short-poll. Fleet-wide, that reconnect storm multiplied
1174+ * /mcp volume ~15x, drove the worker into memory-limit kills, and (by
1175+ * recycling isolates) pushed homepage TTFB from ~15ms to ~3s.
1176+ */
1177+ private async collectUndeliveredReplay(): Promise<{
1178+ readonly frame: Uint8Array;
1179+ readonly streamIds: readonly string[];
1180+ } | null> {
11591181 const frames: Uint8Array[] = [];
11601182 const streamIds = await this.eventStore.replayUndeliveredStreams({
11611183 send: (eventId, message) => {
@@ -1164,18 +1186,7 @@ export abstract class McpAgentSessionDOBase< · 2 unmodified lines
11641186 },
11651187 });
11661188 if (frames.length === 0) return null;
1167− return this.trackedLegacyResponse(
1168− new Response(combineFrames(frames), {
1169− headers: {
1170− "content-type": "text/event-stream",
1171− "cache-control": "no-cache, no-transform",
1172− connection: "keep-alive",
1173− "x-accel-buffering": "no",
1174− "mcp-session-id": this.sessionId,
1175− },
1176− }),
1177− { acknowledge: streamIds },
1178− );
1189+ return { frame: new Uint8Array(combineFrames(frames)), streamIds };
11791190 }
11801191
11811192 private async serializedTransportRequest<A>(run: () => Promise<A>): Promise<A> {
@@ -1211,8 +1222,19 @@ export abstract class McpAgentSessionDOBase< · 29 unmodified lines
12111222 // through every workerd/Vite streaming hop, so explicitly retire a
12121223 // stale standalone mapping before opening its replacement.
12131224 transport.closeStandaloneSSEStream();
1214− const replay = await this.replayUndeliveredOnStandaloneGet(request);
1215− if (replay) return replay;
1225+ const replay = await this.collectUndeliveredReplay();
1226+ if (replay) {
1227+ // Prepend the replay onto the transport's own long-lived
1228+ // standalone stream — the stream MUST stay open afterwards (see
1229+ // collectUndeliveredReplay). Acknowledgement moves to stream end:
1230+ // "complete"/"rotate" imply the client stayed attached long
1231+ // enough to have drained the prepended frames.
1232+ const response = await transport.handleRequest(request);
1233+ return this.trackedLegacyResponse(response, {
1234+ initialFrame: replay.frame,
1235+ acknowledge: replay.streamIds,
1236+ });
1237+ }
12161238 }
12171239 }
12181240 const toolCallIds = legacyToolCallRequestIds(parsedBody);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t