Skip to content

fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport#2541

Open
mattzcarey wants to merge 7 commits into
mainfrom
fix/v2-sse-keepalive
Open

fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport#2541
mattzcarey wants to merge 7 commits into
mainfrom
fix/v2-sse-keepalive

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

v2 port of #2538 (v1.x). Refs #1211.

Problem

Long-idle SSE responses can go too long without delivering body bytes. A client body-idle timeout (for example undici's bodyTimeout) or an intermediary such as a reverse proxy or cloud load balancer may then terminate the stream. Clients observe SSE stream disconnected: TypeError: terminated and reconnect in a loop.

v2 already keep-alived subscriptions/listen streams via listenRouter, but the transport's own SSE streams had no equivalent. Downstream users (for example Cloudflare's agents SDK v2 integration) currently rewrap transport response bodies just to inject keep-alive frames.

Fix

  • Add keepAliveMs to WebStandardStreamableHTTPServerTransport and PerRequestHTTPServerTransport, defaulting to 15,000 ms (the WHATWG SSE guidance recommends a comment line roughly every 15 seconds). Set 0 to disable. Invalid timer delays (sub-millisecond, non-finite, or above JavaScript's maximum timer delay) safely disable keep-alive rather than being clamped to a ~1 ms flood.
  • Cover every HTTP serving leg: standalone GET, POST response, and replay streams; modern per-request SSE exchanges; subscriptions/listen; and createMcpHandler's legacy fallback. In modern auto mode the timer starts after SSE upgrade; use responseMode: 'sse' when a silent long-running handler needs heartbeat bytes. NodeStreamableHTTPServerTransport inherits the option through its aliased options type.
  • Write : keepalive SSE comment frames. Compliant SSE parsers discard comments before event dispatch, so they never surface as MCP messages.
  • Prevent intermediary buffering with Cache-Control: no-cache, no-transform and X-Accel-Buffering: no on every HTTP SSE producer.
  • Harden lifecycle cleanup: timers are unref'd where supported, stopped on cancel/finalization/close, and superseded when a resumed stream reuses an id. Deferred replay/POST work re-checks closed state before registration; failed POST cleanup removes only request mappings it still owns.
  • Centralize the default, timer validation, and unref policy in one internal helper shared by the session, per-request, and listen implementations.
  • Add troubleshooting guidance for client body-idle timeouts and intermediary buffering.

Verification

  • @modelcontextprotocol/server: 474 tests passed
  • server typecheck, lint, and dual ESM/CJS build passed
  • workspace pre-push build, typecheck, lint, and snippet synchronization passed
  • fake-timer regressions cover custom/disabled/invalid intervals, every createMcpHandler forwarding leg, replay/POST close races, ownership-safe POST error cleanup, and timer teardown
  • minor changeset included (new public options/default wire behavior)

Operational note

Keep-alive is default-on to fix existing deployments without requiring a configuration change. Runtimes that intentionally depend on an idle stream for hibernation can set keepAliveMs: 0.

…PServerTransport

Idle SSE streams (the standalone GET stream in particular, but also POST
response streams during long-running tool calls) are killed by
intermediaries and server idle timeouts, which clients observe as
"SSE stream disconnected: TypeError: terminated" followed by a
reconnect loop.

The transport now writes an SSE comment frame (`: keepalive`) to every
open SSE stream every keepAliveMs milliseconds (default 15000, per the
WHATWG SSE spec recommendation; set 0 to disable). Comment frames are
dropped by SSE parsers and never surface as protocol messages. The timer
is unref'd so it never holds the process open, and is cleared on stream
cleanup/cancel and transport close. Naming matches the existing
keepAliveMs on createMcpHandler's subscriptions/listen streams.

v2 port of #2538 (v1.x); refs #1211
@mattzcarey
mattzcarey requested a review from a team as a code owner July 23, 2026 15:59
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a6a55a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2541

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2541

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2541

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2541

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2541

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2541

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2541

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2541

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2541

commit: 8a6a55a

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked the candidate concern that the keepAliveMs: 0 disable test is vacuous (Promise.race against an already-resolved promise) — it is not: race subscribes to reader.read() first, so when a frame is buffered its already-resolved promise's reaction is queued ahead of Promise.resolve('pending') and the test would correctly fail if frames were written.

Extended reasoning...

Bugs were found and posted as inline comments (keep-alive timer armed before the fallible writePrimingEvent await, and the close-during-replayEventsAfter race), so human review is already signalled. This note only records the one additional candidate examined and ruled out this run — the supposed vacuity of the keepAliveMs: 0 test's Promise.race — which was verified to be a sound test via microtask reaction ordering. Nothing here should be read as a guarantee of correctness of the rest of the diff.

Comment thread packages/server/src/server/streamableHttp.ts
Comment thread packages/server/src/server/streamableHttp.ts Outdated
Comment thread .changeset/streamable-http-sse-keepalive.md Outdated
Comment thread packages/server/src/server/streamableHttp.ts Outdated
- startKeepAlive is a no-op after transport close: a deferred arm (e.g.
  resuming after an event-store replay await that straddled close())
  would otherwise create a timer that close()'s sweep can never clear.
- The POST SSE path arms keep-alive after the fallible awaits (priming
  event write, message dispatch) instead of before them, so an error
  path that discards the Response cannot leak a permanently-firing
  timer against a stream nothing can cancel.
- createMcpHandler's keepAliveMs now also reaches the legacy stateless
  fallback's per-request transport, instead of governing only
  subscriptions/listen streams while the legacy leg silently used the
  transport default.
- Documents the keep-alive behavior and the 'SSE stream disconnected:
  TypeError: terminated' symptom in docs/troubleshooting.md.
Comment thread docs/troubleshooting.md Outdated
Comment thread packages/server/src/server/streamableHttp.ts Outdated
Comment thread packages/server/test/server/streamableHttp.test.ts
Comment thread packages/server/src/server/createMcpHandler.ts
…iew round 2

- PerRequestHTTPServerTransport gains keepAliveMs (default 15000, 0
  disables): while an exchange's SSE stream is open, an interval drives
  writeCommentFrame so a long-running handler with no mid-call output
  doesn't idle past intermediary/server timeouts — the same failure the
  session transport fix targets, previously unaddressed on the modern
  serving path. Threaded from createMcpHandler through invoke(), so the
  handler's keepAliveMs now uniformly covers listen streams, modern
  per-request exchanges, and the legacy stateless fallback.
- Keep-alive guards use the > 0 polarity (matching listenRouter) so a
  non-finite keepAliveMs disables keep-alive instead of arming a Node-
  clamped ~1ms interval.
- The close-during-replay regression test resumes the standalone GET
  stream so the continuation genuinely reaches the keep-alive arm
  (mutation-verified: removing the _closed guard now fails the test).
- Reworded the two stale legacy-fallback doc blocks that still claimed
  the transport is constructed 'with only sessionIdGenerator: undefined',
  documented legacyStatelessFallback's transportOptions parameter, and
  scoped the troubleshooting entry to match actual coverage.
Comment thread packages/server/src/server/streamableHttp.ts
…sport too

The round-2 polarity fix was reverted from WebStandardStreamableHTTP-
ServerTransport by an overzealous checkout during mutation testing and
shipped only in PerRequestHTTPServerTransport. The guard now uses
!(keepAliveMs > 0) at both sites, so a non-finite value disables
keep-alive on every leg instead of arming a Node-clamped ~1ms interval
on the session transport while the sibling guards disable. Mirrors the
non-finite regression test into the streamableHttp suite.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I verified at head c9b0215 that the round-4 fixes genuinely landed this time (given the earlier accidental revert): the NaN-safe !(keepAliveMs > 0) guard is present in both streamableHttp.ts:306 and perRequestTransport.ts:427 (matching listenRouter.ts:221), and the replay-await regression test's mock now returns _GET_stream, so it genuinely reaches the keep-alive arm and exercises the closed-transport guard. I also checked the timer lifecycle for leak paths beyond the inline findings: stopKeepAlive is wired into every stream cleanup/cancel path (standalone GET, replay, POST), and close() sweeps the timer map after stream cleanup — no unswept path found for finite in-domain intervals.

Extended reasoning...

This run's five findings are all nit-severity (test-coverage gaps, an Infinity edge on an operator-config input, a pre-existing error-code mapping a new test cements, a missing X-Accel-Buffering header on pre-existing response sites, and a docs misattribution) and are already posted inline. This note records the concrete items examined and ruled out on top of them, chiefly re-verifying that the previously-claimed-then-reverted guard fix is actually present at the current head — informational only, not a correctness guarantee. The PR touches transport lifecycle code across four review rounds, so it stays with the inline findings and human judgment rather than an approval.

Comment thread packages/server/src/server/createMcpHandler.ts
Comment thread packages/server/test/server/streamableHttp.test.ts
Comment thread packages/server/src/server/streamableHttp.ts
Comment thread docs/troubleshooting.md
Comment thread packages/server/src/server/streamableHttp.ts Outdated
@mattzcarey
mattzcarey marked this pull request as draft July 24, 2026 10:56
- reclaim stream bookkeeping and end replay streams after close races
- reject invalid timer delays across all handler serving legs
- disable proxy buffering and correct timeout troubleshooting guidance
- pin createMcpHandler option forwarding and lifecycle behavior with tests
@mattzcarey
mattzcarey marked this pull request as ready for review July 24, 2026 11:58

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, this run also examined whether the round-4 troubleshooting correction (client body-idle timeout, not server.requestTimeout) needed to propagate into the keepAliveMs JSDoc and the changeset, which still say "intermediaries and server idle timeouts" — ruled out: that prose names the client-observed symptom (TypeError: terminated) and the intermediary/watchdog class generically, without repeating the specific incorrect server.requestTimeout attribution the docs fix removed.

Extended reasoning...

This run's findings (the pre-existing handlePostRequest close-race gap and the two nits) are already posted inline, so no summary of them belongs here. The one candidate examined and refuted this run was a claimed inconsistency between the corrected troubleshooting entry and the keepAliveMs JSDoc/changeset wording; verifiers concluded the surviving prose is symptom-level and accurate (it does not re-assert the refuted server.requestTimeout mechanism), so it is not a defect. Recording it so a later pass does not re-litigate the wording after the round-4 doc correction.

Comment thread packages/server/src/server/streamableHttp.ts
Comment thread .changeset/streamable-http-sse-keepalive.md Outdated
Comment thread packages/server/src/server/streamableHttp.ts
Share the default, safe timer range, and Node unref behavior across session, per-request, and listen streams. Reject sub-millisecond delays that JavaScript timers clamp to 1ms, and clarify when auto-mode exchanges can emit heartbeats.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

Quality review follow-up in 49f886cd:

  • centralized the shared default, valid timer range, and Node unref behavior across session, per-request, and listen streams
  • rejected sub-millisecond values that JavaScript timers clamp to ~1 ms
  • clarified that modern auto exchanges heartbeat only after SSE upgrade (responseMode: sse protects silent long-running handlers)
  • kept the helper internal; no additional package API surface

The refactor removes duplicated policy while preserving each transport's distinct teardown ownership. Local server suite is 472/472 and full workspace pre-push build/typecheck/lint passed.

Prevent post-await work from registering streams after close, preserve successor request mappings during failed priming cleanup, align anti-buffering headers, and classify the new public API as a minor change.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, this run also examined a close() racing the writePrimingEvent await on the POST SSE leg and ruled it out: the stream mapping is registered before that await, so close()'s sweep runs its cleanup (controller closed, mapping deleted, timer stopped); the continuation's keep-alive arm is skipped by the registration identity check at streamableHttp.ts:1016, and the client observes stream end rather than a hang.

Extended reasoning...

Bugs were found this round (two nits, one pre-existing), so the inline comments carry the substantive feedback and no approval/deferral body is warranted. This note records one concrete close-race candidate that was examined and refuted — the writePrimingEvent-await window in handlePostRequest — since prior rounds repeatedly surfaced close-race variants in this function and this record should keep a later pass from re-exploring it. Verified against the current code: registration precedes the await, close()'s sweep closes the controller and deletes the mapping, and the post-await keep-alive arm identity-checks the mapping (startKeepAlive is additionally _closed-guarded). This is informational only, not a correctness guarantee, and is distinct from the filed nit about the earlier req.json() window (session mint + onsessioninitialized firing before the 404).

Comment on lines 870 to +877
}
}

// Request parsing and session initialization may await user/runtime
// work. Do not register or dispatch after close() has swept state.
if (this._closed) {
return this.createJsonErrorResponse(404, -32_001, 'Session not found');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The round-5 _closed re-check at streamableHttp.ts:873-877 runs after the initialization side effects, so a transport.close() that completes while await req.json() (line ~806) is parked still lets the continuation mint this.sessionId, set _initialized = true, and invoke the user's onsessioninitialized callback on a dead transport before returning 404 — leaving a permanent phantom entry in the callback's documented transports[sessionId] = transport registry pattern (onclose already fired while sessionId was still undefined, and DELETE is unreachable post-close). Fix: hoist a _closed re-check before the initialization branch (before line ~849), keeping the existing check for the onsessioninitialized-await window.

Extended reasoning...

What the bug is. The round-5 fix (8a6a55a) added a closed-transport re-check to handlePostRequest with the comment "Request parsing and session initialization may await user/runtime work. Do not register or dispatch after close() has swept state", and the PR thread states the function now "re-checks _closed after request/session-initialization awaits and before any stream/request registration or dispatch". But the check sits at streamableHttp.ts:870-877 — after the initialization branch. The suspension points and side effects run in this order: await req.json() (:806) → re-init guard (:841) → this.sessionId = this.sessionIdGenerator?.() (:849) → this._initialized = true (:850) → await Promise.resolve(this._onsessioninitialized(this.sessionId)) (:854-855) → the new _closed check (:875). So the check covers only the second await window (close landing while onsessioninitialized itself is parked — the scenario the new regression test pins). The first window is still open: a close completing while the body read is parked lets the continuation mutate the closed transport and invoke the user's callback for a session that can never serve, and only then return 404.\n\nStep-by-step proof.\n1. A client POSTs initialize on a fresh sessionful transport with onsessioninitialized configured; handlePostRequest parks on await req.json() — a real network body read.\n2. transport.close() completes concurrently (programmatic shutdown, handler teardown). Its once-only sweep runs and fires onclose. Crucially, close() (:1105+) clears neither _initialized nor sessionId, and at this instant sessionId is still undefined — so a consumer's keyed cleanup in onclose (delete transports[transport.sessionId]) is a no-op.\n3. The POST continuation resumes: messages parse, isInitializationRequest is true, the re-init guard passes (_initialized is still false), and the continuation mints this.sessionId (:849), sets _initialized = true (:850), and invokes the user's onsessioninitialized callback (:854-855).\n4. Only now does the :875 check run and return 404.\n\nWhy existing code doesn't prevent it. The handleRequest entry guard (:416) only rejects requests arriving after close; this POST entered before. The :875 check runs after the side effects. Nothing between await req.json() and :849 consults _closed. And no later cleanup path can reach the consumer: close() is re-entrancy-guarded so onclose never re-fires, and onsessionclosed only fires from handleDeleteRequest, which the :416 guard makes unreachable on a closed transport (404 first).\n\nImpact. onsessioninitialized's own JSDoc documents its purpose as registering and tracking multiple MCP sessions — the canonical pattern stores transports[sessionId] = transport and cleans up keyed on the session id in onclose/onsessionclosed. In this race the registry gains a live session id mapped to a dead transport, with no callback left that could ever remove it: a permanent server-side leak per occurrence, and any client request routed via that entry hits a closed transport and 404s. This matches the repo's Async/Lifecycle recurring catch (deferred continuations must check closed state before mutating this._* or invoking callbacks).\n\nWhy nit rather than blocking. The trigger requires a programmatic close() racing an in-flight initialize body read on a sessionful transport with onsessioninitialized set — a narrow window (the legacy-fallback abort-teardown path does hit close-during-req.json(), but it is stateless with no callback, so it is harmless there). The client observes a clean 404 and simply re-initializes; the consequence is a bounded registry-entry leak plus a misleading callback invocation, not a hang or protocol failure. Note the mint-before-check ordering predates this PR — what the PR added is the guard whose placement leaves this half of the window open while the PR discussion describes it as covering the initialization awaits.\n\nHow to fix. Hoist a _closed re-check to just before the initialization branch (right after message parsing at ~:836, or at minimum before :849), mirroring the guard the PR already added, so a closed transport can neither mint a session id nor invoke the registration callback. Keep the existing :875 check for the onsessioninitialized-await window. A regression test can mirror the existing 'should not register a POST stream after close races session initialization' test but park the body read (e.g. a ReadableStream request body released after close()) and assert onsessioninitialized was never called and transport.sessionId stays undefined.

Comment on lines 219 to 225
writeNotification(note.method, note.params);
});

if (keepAliveMs > 0) {
keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs);
// Do not hold the event loop open on idle subscriptions. Node's
// setInterval returns a Timeout with .unref(); browsers/Workers
// return a number — the cast is an environment shim, not a
// workaround for SDK typing.
(keepAliveTimer as { unref?: () => void }).unref?.();
}
keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n'));

open.add(teardown);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In listenRouter's teardown, closed = true is flipped and then the user-supplied bus's unsubscribe?.() is called unguarded before clearInterval(keepAliveTimer), abortCleanup?.(), open.delete(teardown), and controller.close() — so a throwing unsubscribe from a custom ServerEventBus (e.g. a disconnected pub/sub backend) permanently leaks the keep-alive timer this PR arms via armSseKeepAlive and a maxSubscriptions slot, and one such throw propagates out of closeAll(), aborting graceful close of every other subscription. The teardown ordering itself is pre-existing (this PR only rewrote the adjacent timer-arm line), but a one-line try/finally around unsubscribe?.() (or reordering it last) closes the gap; the same pattern applies to the pre-existing handleDeleteRequest site in streamableHttp.ts, where a throwing onsessionclosed skips close() entirely.

Extended reasoning...

What the bug is. teardown in createListenRouter (listenRouter.ts:172-201) runs, in order: closed = trueunsubscribe?.()clearInterval(keepAliveTimer)abortCleanup?.()open.delete(teardown)controller.close(). unsubscribe is whatever bus.subscribe(...) returned, and bus is the user-supplied CreateMcpHandlerOptions.bus extension point, explicitly documented for multi-process deployments to implement over their own pub/sub backend. If that user code throws (a disconnected Redis client is the canonical case), the throw skips all four remaining steps. Because closed was flipped before the fallible call, the re-entry guard (if (closed) return, line 173) makes teardown permanently uncompletable: no retry — not a later stream cancel, not the abort listener, not closeAll() — can ever run the skipped steps.

Concrete consequences. (a) The keep-alive interval this PR arms at line 222 via armSseKeepAlive fires forever — writeFrame no-ops since closed is true, but the timer is never cleared, directly contradicting the changeset's claim that timers are "stopped on cancel/finalization/close". (b) The open-set entry leaks, permanently consuming one maxSubscriptions slot (default 1024); enough throwing teardowns and every new subscriptions/listen is refused with -32603 'Subscription limit reached'. (c) The abort listener and stream controller leak. (d) Worst of the compounding effects: closeAll() iterates for (const teardown of open) teardown(true) with no try/catch, and createMcpHandler's close() calls it synchronously before closing in-flight modern servers — so ONE throwing unsubscribe aborts graceful close of every other subscription and rejects handler.close(), skipping the in-flight cleanup too.

Step-by-step proof. (1) Deploy createMcpHandler(factory, { bus: myRedisBus }) where myRedisBus.subscribe() returns an unsubscribe that calls into the Redis client. (2) Two clients open subscriptions/listen streams A and B; each arms a 15 s keep-alive timer and adds its teardown to open. (3) The Redis connection drops; the client library now throws from unsubscribe. (4) Client A disconnects → the stream's cancel() calls teardown(false)closed = trueunsubscribe() throws → clearInterval/open.delete/controller.close() never run, and the exception propagates into the ReadableStream cancel path. (5) A's timer keeps firing every 15 s forever; A's slot in open is never reclaimed. (6) The operator calls handler.close()closeAll() iterates open, hits A's teardown first, re-enters, if (closed) return — fine — but when it reaches any other still-open entry whose unsubscribe also throws, the loop aborts and B never gets its graceful-close result frame; the throw escapes handler.close() before inflight servers are closed.

Why existing code doesn't prevent it. Nothing on any call path catches the throw: the stream cancel() hook, the abort-signal listener, and closeAll() all invoke teardown bare. armSseKeepAlive unref's the timer, so the process can still exit, but within a running server the interval churns indefinitely and the capacity leak is unbounded. This is exactly the repo's Async/Lifecycle recurring catch (#1735/#1763): user-supplied or chained callbacks in close/teardown paths must be wrapped in try/finally so a throw cannot skip the remaining teardown — an explicitly requested check for this codebase, and the PR hardened this precise lifecycle class at every sibling SSE site (per-request transport, session transport) while this one call survives unguarded.

How to fix. One line: wrap the call — try { unsubscribe?.(); } catch (e) { onerror?.(e instanceof Error ? e : new Error(String(e))); } — or reorder so unsubscribe?.() runs last, after clearInterval, abortCleanup, open.delete, and controller.close(). Optionally also wrap the teardown(true) call inside closeAll() so one bad subscription can never abort the others.

Severity rationale. The teardown ordering is pre-existing — git diff shows this PR's listenRouter changes cover only the timer-arm line (now armSseKeepAlive) and the response headers, and the identical leak existed pre-PR with the raw setInterval. The default InMemoryServerEventBus never throws, so no stock configuration is affected. Filed as a nit rather than purely pre-existing because this PR arms the leaked timer inside this very closure and its changeset asserts a timer-lifecycle guarantee ("stopped on cancel/finalization/close") that this path falsifies — and the fix is a one-liner squarely within the PR's stated hardening scope.

Related pre-existing site (mention-only, not filed separately). streamableHttp.ts handleDeleteRequest has the same shape: await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); await this.close(); — a throwing/rejecting user onsessionclosed skips close() entirely, leaving the session, its SSE streams, and (post-PR) all keep-alive timers alive while the client believes DELETE terminated the session. Same fix shape: try/finally so close() always runs. That code is untouched by this PR.

Comment on lines 1113 to +1121
}
this._streamMapping.clear();

// Clear any keep-alive timers not already cleared by stream cleanup
for (const timer of this._keepAliveTimers.values()) {
clearInterval(timer);
}
this._keepAliveTimers.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue (the JSON-mode cleanup shape and the close() sweep both predate this PR): in enableJsonResponse mode, the stream entry's cleanup only deletes the _streamMapping entry and never settles the 'new Promise(resolve => ...)' returned by handlePostRequest, so close()'s sweep landing while a JSON-mode request handler is in flight (client-drivable: DELETE on the session during a slow tools/call POST) silently drops the resolveJson sink and the POST's handleRequest promise pends forever — a byte-less hung HTTP exchange plus a leaked promise and captured request context. Since this PR already fixed the equivalent close-race hang on every SSE leg, settling the deferred in the JSON entry's cleanup (e.g. resolve with the 404 'Session not found' body the transport now answers for closed transports) would complete the invariant the new lifecycle tests pin.

Extended reasoning...

What the bug is. In enableJsonResponse mode, handlePostRequest returns new Promise<Response>(resolve => ...) and registers the JSON stream entry (streamableHttp.ts:904-910) as { resolveJson: resolve, cleanup: () => { this._streamMapping.delete(streamId); } }. The cleanup deletes the mapping but never settles the deferred. close()'s sweep (streamableHttp.ts:1108-1121, extended by this PR to also clear keep-alive timers) runs for (const { cleanup } of this._streamMapping.values()) cleanup() — so a close that lands while a JSON-mode request handler is still in flight silently drops the resolveJson sink, and nothing can ever settle the returned Promise<Response> afterwards.

Why nothing downstream saves it. When transport.onclose fires, Protocol._onclose sets its transport reference to undefined and aborts in-flight handlers, so the handler's eventual response typically never reaches transport.send() at all. Even if a late send() did run, the all-responses-ready path would find stream === undefined and — in the JSON-mode branch — throw 'No connection established for request ID' into onerror without resolving anything (close() clears _requestResponseMap but not _requestToStreamMapping, so the lookup succeeds and reaches the throw). The Promise<Response> returned by handleRequest stays pending forever.

Step-by-step proof.

  1. Sessionful transport with enableJsonResponse: true; client POSTs a slow tools/call. The handler is dispatched and the JSON entry is registered — so the pre-registration _closed re-check this PR added at streamableHttp.ts:875 (commit 8a6a55a) has already passed.
  2. The same or another client sends DELETE with the session id → handleDeleteRequest awaits this.close() → the sweep invokes the JSON entry's cleanup, deleting the mapping without resolving the deferred.
  3. The tool handler is aborted (or completes into a nulled transport); no code path holds the resolve anymore.
  4. The POST's handleRequest promise never settles: in a Node adapter (await handler(req, res) in NodeStreamableHTTPServerTransport.handleRequest), the HTTP exchange stays open with zero bytes written until the client's own socket/body timeout kills it — and server-side the pending promise plus its captured request context leak for the life of the process.

Contrast with SSE mode — why this survives this PR. In SSE mode the same sweep's cleanup closes the stream controller, so the client observes stream end — the exact invariant this PR's new close-race lifecycle tests pin ('the client observes stream end instead of hanging on a dead session'). JSON mode is the one response shape where close() still produces an eternal hang instead of a terminated exchange, i.e. a surviving sibling of exactly the hang class this PR eliminated on every SSE leg (the repo's Completeness recurring catch: partial fixes leaving sibling code paths with the bug the PR addresses).

Not a duplicate of the round-3 finding. The earlier report's root cause was registering mappings after close()'s once-only sweep — fixed in 8a6a55a by the pre-registration _closed re-check and the handleRequest 404 gate. This is the post-registration window: the entry exists when the sweep runs, the guards pass, and the fix is different (settle the promise, not re-check before registering).

How to fix. Make the JSON entry's cleanup settle the deferred before deleting the mapping — e.g. resolve(this.createJsonErrorResponse(404, -32_001, 'Session not found')), matching what the transport now answers for requests on a closed transport — or, equivalently, resolve all resolveJson sinks in close()'s sweep. Either way the exchange terminates with a well-formed body instead of hanging, restoring parity with the SSE legs.

Why pre_existing. The resolveJson/non-settling-cleanup shape and the sweep loop both predate this PR; the diff only adds the keep-alive timer clears to close() and did not add or alter the JSON-mode cleanup. That said, since this PR reworks this exact function's close-race lifecycle throughout, it is the natural place to close out the last surviving hang shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant