diff --git a/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts index a5fd5229..a41c9a3d 100644 --- a/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts +++ b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts @@ -138,9 +138,12 @@ describe("primeAndHoldSseStream (#1999)", () => { s.finishWrite(); s.abort(); + // Hono guards re-entry itself, but the helper must not depend on that — + // so drive the listener a second time directly. + s.abort(); await withinTick(held); - // Hono guards re-entry itself, but the helper must not depend on that. expect(cleanups).toBe(1); + expect(s.closes).toBe(1); }); it("does not reject when cleanup's own close loses the race", async () => { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index b837eac1..5bc58f5e 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -147,8 +147,15 @@ export async function primeAndHoldSseStream( stream: PrimableSseStream, cleanup: () => void, ): Promise { + let done = false; const aborted = new Promise((resolve) => { stream.onAbort(() => { + // Hono's `abort()` guards its own re-entry, but the guard belongs here + // too: this helper's contract is exactly-once teardown, and running a + // caller's cleanup twice would double-delete a subscriber that has + // since been re-added by a reconnect. + if (done) return; + done = true; cleanup(); closeAbortedStream(stream); resolve();