fix: send SSE keep-alive comment frames from Streamable HTTP server transport (v1.x)#2538
Conversation
…ransport 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 (Node's requestTimeout defaults to 300s), which clients observe as "SSE stream disconnected: TypeError: terminated" roughly every 5 minutes, followed by a reconnect loop. The server 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. Fixes #1211
🦋 Changeset detectedLatest commit: 2bdf8c5 The changes in this PR will be included in the next version bump. 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 |
commit: |
| private startKeepAlive(streamId: string, controller: ReadableStreamDefaultController<Uint8Array>, encoder: TextEncoder): void { | ||
| if (this._keepAliveMs <= 0) { | ||
| return; | ||
| } | ||
| const timer = setInterval(() => { | ||
| try { | ||
| controller.enqueue(encoder.encode(': keepalive\n\n')); | ||
| } catch { | ||
| this.stopKeepAlive(streamId); | ||
| } | ||
| }, this._keepAliveMs); | ||
| // Don't let the keep-alive timer hold the process open (Node.js only) | ||
| (timer as { unref?: () => void }).unref?.(); | ||
| this._keepAliveTimers.set(streamId, timer); | ||
| } |
There was a problem hiding this comment.
🔴 startKeepAlive() registers a new interval with this._keepAliveTimers.set(streamId, timer) without clearing any existing timer for that streamId, so a second call for the same stream orphans the first interval — reachable via replayEvents(), where the 409 conflict check is skipped whenever the EventStore doesn't implement the optional getStreamIdForEventId (including the SDK's own InMemoryEventStore). On a reconnect the orphaned timer's write eventually throws and its catch calls stopKeepAlive(streamId), clearing the live resumed stream's timer — silently disabling keep-alive on exactly the resumability path this PR targets, while leaking one interval per reconnect. Fix: call this.stopKeepAlive(streamId) at the top of startKeepAlive().
Extended reasoning...
The bug
startKeepAlive() (src/server/webStandardStreamableHttp.ts:271-285) unconditionally does this._keepAliveTimers.set(streamId, timer). If a timer is already registered under that streamId, the old setInterval handle is overwritten in the map but never cleared. Since both stopKeepAlive() and close() only clear timers currently in the map, the old interval becomes permanently unreachable and runs for the life of the process.
The code path that triggers it
The trigger is the replay path, and it's realistic:
replayEvents()only performs its 409 "Stream already has an active connection" check when the EventStore implements the optionalgetStreamIdForEventId()(lines 529-542). The SDK's ownInMemoryEventStore(src/examples/shared/inMemoryEventStore.ts) does not implement it, so servers built on it skip the conflict check entirely.- The replay stream's
ReadableStream.cancel()handler is a no-op (// Cleanup will be handled by the mapping, lines 562-565) — so when the client disconnects, the mapping stays and the keep-alive timer stays armed. - Reconnecting with a
Last-Event-IDfrom the same original stream re-entersreplayEvents()with the samereplayedStreamId. The second pass overwrites the_streamMappingentry (pre-existing behavior) andstartKeepAlive(replayedStreamId, …)overwrites the timer entry (new in this PR), orphaning timer A.
Why nothing prevents it
The only defenses are the 409 check (skipped without getStreamIdForEventId) and cleanup on cancel (a no-op on the replay stream). The other call sites happen to be safe — the standalone GET has its own 409 guard and a cancel handler that calls stopKeepAlive, and POST streams use a fresh crypto.randomUUID() — so the flaw is confined to, but reliably triggered on, the replay path.
Impact
Two concrete failures per reconnect cycle:
- Leaked interval: orphaned timer A keeps firing every 15s forever, enqueueing
: keepaliveinto an abandoned controller (or throwing, see below). Leaks accumulate unboundedly across reconnects in a long-lived session. - Keep-alive silently disabled on the live stream: once the abandoned stream's controller is cancelled, timer A's
enqueuethrows and itscatchcallsthis.stopKeepAlive(streamId)— which clears timer B, the live resumed stream's keep-alive (they share the samestreamIdkey). The resumed stream then goes idle and gets killed by the very proxy/idle timeouts this PR exists to defeat — reintroducing SSE stream disconnected: TypeError: terminated #1211 precisely on the resumability path.
Step-by-step proof
- Server uses
eventStore: new InMemoryEventStore()(nogetStreamIdForEventId). Client holds an event ID from streamS. - Client sends
GETwithLast-Event-ID→replayEvents()runs, no 409 check, mapsS, arms timer A forS. - Client's connection drops (proxy timeout, network blip). The replay stream's
cancel()is a no-op → mapping forSand timer A both survive; the controller is now cancelled. - Client reconnects with a
Last-Event-IDstill resolving toS→replayEvents()runs again, mapsS(overwrite), callsstartKeepAlive(S, controllerB, …)→_keepAliveTimers.set(S, timerB)overwrites timer A without clearing it. - ≤15s later, timer A fires:
controllerA.enqueue()throws (cancelled stream) →catch→stopKeepAlive(S)→clearInterval(timerB)and deletes the map entry. The live resumed stream now has no keep-alive; timer A keeps firing and throwing forever (subsequentstopKeepAlive(S)calls are no-ops). - Each further reconnect repeats steps 2-5, orphaning one more permanent interval.
Fix
One line — make startKeepAlive idempotent per stream:
private startKeepAlive(streamId: string, controller: ..., encoder: TextEncoder): void {
if (this._keepAliveMs <= 0) {
return;
}
this.stopKeepAlive(streamId); // clear any existing timer for this stream
const timer = setInterval(() => { ... });
...
}This is severity-normal rather than a nit because the failure isn't just a resource leak: the PR's own fix silently stops working in its target scenario (long-lived sessions behind idle-killing intermediaries, using resumability), and nothing surfaces the breakage — the client just resumes seeing SSE stream disconnected: TypeError: terminated.
…er the same id A replayed stream can re-register under the same stream id when the event store doesn't implement the optional getStreamIdForEventId (the 409 conflict check is skipped). startKeepAlive now clears any timer already armed for the stream id before arming a new one, so the predecessor's timer can't leak or — via its failing write's stopKeepAlive on the shared id — silently disable the live stream's keep-alive. Also formats the changeset.
|
Good catch — fixed: |
| this.startKeepAlive(streamId, streamController!, encoder); | ||
|
|
||
| // Write priming event if event store is configured (after mapping is set up) | ||
| await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion); |
There was a problem hiding this comment.
🔴 In the POST SSE path, startKeepAlive(streamId, ...) is armed immediately before await this.writePrimingEvent(...), which can reject when eventStore.storeEvent() fails; the rejection lands in the outer catch, which returns a 400 without calling stopKeepAlive(streamId) — and since the Response(readable) is never returned, the stream has no consumer, so the timer's enqueue never throws and its self-cleanup catch never fires. Each failed POST leaks a permanently-firing interval (fresh UUID streamId per request) that enqueues : keepalive into an unconsumed queue every 15s until transport close. Fix: arm the keep-alive only after writePrimingEvent succeeds, or call stopKeepAlive(streamId)/the mapping cleanup in the catch before returning the error response.
Extended reasoning...
The bug
In handlePostRequest's SSE branch, this.startKeepAlive(streamId, streamController!, encoder) (line 842) is called immediately before await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion) (line 845). writePrimingEvent awaits this._eventStore.storeEvent(streamId, {}) — an async call into user-provided storage (Redis, a database, etc.) that can reject under transient failure. When it does, the rejection propagates to the outer catch (lines 870–874), which returns a 400 JSON error response without calling stopKeepAlive(streamId) or running the stream mapping's cleanup(). The interval armed three lines earlier keeps firing forever.
Why none of the PR's safety nets catch this
The PR has three cleanup mechanisms for keep-alive timers, and this path defeats all of them:
- The timer's self-cleanup
catchrequirescontroller.enqueue()to throw. But theResponse(readable)is never returned to the caller on this path — the 400 error response is returned instead — so theReadableStreamhas no consumer and is never closed, errored, or cancelled. Per the Streams spec,enqueue()on a live controller always succeeds (the internal queue is unbounded;desiredSizejust goes negative). The: keepalivechunk is appended to an unconsumed queue every 15s, forever, and the catch never fires. cancel()/ mappingcleanup()never run: nobody holds the response, socancel()can't be invoked; andcleanup()is only reached viasend()'s all-responses-ready branch or thecloseSSEStreamcallbacks built in the onmessage dispatch loop (line 848+) — which is after the throwingawait, so no handler ever receives these messages and no response is ever produced for these request ids.transport.close()does clear_keepAliveTimers, but only at session end — a stateful session can live for hours or days.
Note that unref() only prevents the timer from holding the Node process open; it does not stop the interval from firing or free anything.
Impact
Each failed POST-with-request arms a new interval under a fresh crypto.randomUUID() streamId, so leaks accumulate one per failed request. A client retry loop against a flapping event store leaks an interval per retry, each retaining its controller, encoder, and an ever-growing chunk queue — an unbounded timer + memory leak on a long-lived transport. This is new in this PR: pre-PR, a storeEvent rejection on this path leaked only inert _streamMapping/_requestToStreamMapping entries; the actively-firing interval and unbounded enqueue are introduced here.
Step-by-step proof
- Server: stateful transport with
eventStorebacked by Redis; Redis has a transient outage. - Client (protocol version >= 2025-11-25, so
writePrimingEventactually callsstoreEvent) POSTs atools/callrequest. handlePostRequestreaches the SSE branch: createsreadable, sets the stream mapping, callsstartKeepAlive(streamId, ...)— interval armed.await this.writePrimingEvent(...)→await this._eventStore.storeEvent(...)rejects.- Outer catch returns
createJsonErrorResponse(400, -32700, 'Parse error', ...). Thereadableis abandoned with zero consumers; nostopKeepAlive, nocleanup(). - Every 15s the interval enqueues
: keepaliveinto the unconsumed queue;enqueuesucceeds, so the self-cleanup catch never runs. - Client retries → steps 3–6 repeat with a fresh UUID → a second leaked interval. Repeat for the duration of the outage.
- All leaked intervals fire until
transport.close(), potentially hours/days later.
Why this is distinct from the earlier claude[bot] finding
The timeline comment describes the replay-path same-streamId timer overwrite; its suggested fix — stopKeepAlive(streamId) at the top of startKeepAlive() — does not fix this bug, because here every leaked timer has a fresh UUID streamId that is never passed to startKeepAlive again.
Fix
Either arm the keep-alive only after writePrimingEvent succeeds (moving line 842 below line 845 — the other two call sites already follow this arm-last pattern), or wrap the post-arm section so the catch calls stopKeepAlive(streamId) (or the mapping's cleanup()) before returning the error response.
Fixes #1211
Problem
Idle SSE streams from the Streamable HTTP server transport are killed by idle-connection timeouts — Node's
server.requestTimeoutdefaults to 300s, and reverse proxies / cloud LBs have similar watchdogs. The standalone GET stream is the worst case (it can sit silent forever), but POST response streams during long-running tool calls hit the same thing. Clients observeSSE stream disconnected: TypeError: terminatedevery ~5 minutes and enter a reconnect loop.Fix
WebStandardStreamableHTTPServerTransport(and therefore the NodeStreamableHTTPServerTransportwrapper) now writes an SSE comment frame (: keepalive) to every open SSE stream — standalone GET, POST response streams, and replay streams — on an interval.keepAliveMs, defaulting to 15000 (the WHATWG SSE spec recommends a comment line roughly every 15 seconds). Set0to disable. Naming matches v2'skeepAliveMsoncreateMcpHandler.unref'd (where supported) so it never holds a Node process open, is cleared on stream cleanup/cancel and transportclose(), and clears itself if a write fails on an already-closed stream.This mirrors the fix we shipped for the same symptom in Cloudflare's
agentsMCP worker transport (cloudflare/agents#1583), where the edge closes idle SSE responses after ~5 minutes.Tests
Added a
WebStandardStreamableHTTPServerTransport SSE keep-alivesuite (fake timers): frames on idle GET stream, custom interval,keepAliveMs: 0disables, timers fully cleared on close, and frames on a POST SSE stream while a tool call is pending. Full suite (1611 tests), typecheck, and lint pass.