Skip to content

Fix SSE keep-alive timer lifecycle in Streamable HTTP server transport (v1.x) - #2547

Merged
felixweinberger merged 7 commits into
v1.xfrom
fweinberger/sse-keepalive-lifecycle
Jul 27, 2026
Merged

Fix SSE keep-alive timer lifecycle in Streamable HTTP server transport (v1.x)#2547
felixweinberger merged 7 commits into
v1.xfrom
fweinberger/sse-keepalive-lifecycle

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2538. Keep-alive timers are now owned by the stream they were armed for instead of being tracked in a transport-level map keyed by stream id: startKeepAlive returns the handle, the stream's own cancel/cleanup clears it, and the interval clears itself on write failure. The shared timer map, stopKeepAlive, and the close() timer sweep are removed. Cancel callbacks only delete the stream mapping when it still points at their own stream (matching main), a resume closes the superseded stream instead of leaving it hanging, the POST path arms after its fallible awaits, and non-finite keepAliveMs values disable keep-alive (values above 2^31-1 are clamped).

Motivation and Context

The standalone GET stream and its resumed successors share one stream id, so with the shared timer map a few ordinary disconnect/reconnect orderings tear down the wrong timer:

  • After a client reconnects and resumes, the stale connection's cancel callback stops the resumed stream's keep-alive and deletes its stream mapping. The reconnecting client silently stops receiving frames and server notifications — the disconnect loop from SSE stream disconnected: TypeError: terminated #1211 comes back for exactly the clients keep-alive is meant to protect.
  • In the POST path the timer is armed before await writePrimingEvent(...). If eventStore.storeEvent rejects, the handler returns 400 and the Response is discarded, so nothing can ever cancel the stream; the timer keeps firing until close().
  • close() during a replayEventsAfter await doesn't stop the continuation from arming a timer afterwards, which nothing can clear.
  • keepAliveMs: NaN / Infinity / values above 2^31-1 pass the <= 0 guard, and setInterval clamps such delays to ~1ms — flooding every SSE stream with comment frames. Number(process.env.SOME_UNSET_VAR) is an easy way to hit this.

How Has This Been Tested?

Seven new regression tests (fake timers), each failing on current v1.x before the fix. Full test suite passes. Also verified against a real http.Server over raw sockets: after the stale connection drops, the resumed stream keeps receiving keep-alive frames and server notifications, and keepAliveMs: NaN writes nothing.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Update (second commit): the review comments on the first commit pointed at the same stale-capture class inside send(). Rather than spot-guards, the second commit gives send() a consistent model: the event store is the source of truth, live streams are best-effort delivery.

  • Response events are stored even when the request stream is disconnected (matching the standalone stream path), so a polling client can always retrieve the response via Last-Event-ID.
  • After the store write, the stream registration is re-read, and events the resumed stream's replay already delivered are skipped (tracked per registration), preventing lost or duplicated responses when a resume races an in-flight write. The standalone notification path gets the same guard.
  • Request correlations are released once a response is safely replayable; without an event store, or in JSON response mode, completing against a missing stream still surfaces an error.
  • Session initialization and stream registration are also guarded against a transport that closed while the request body or the onsessioninitialized callback was pending.

Seven more regression tests, each pinned red-green.

Keep-alive timers were tracked in a transport-level map keyed by stream
id. The standalone GET stream and its resumed successors share one id,
so a stale connection's cancel callback could stop the resumed stream's
keep-alive and delete its stream mapping, and the interval's error
handler could clear the wrong timer.

Timers are now owned by the stream they were armed for: startKeepAlive
returns the handle, the stream's own cancel/cleanup clears it, and the
interval clears itself on write failure. Cancel callbacks only delete
the stream mapping when it still points at their own stream. A resume
closes the superseded stream cleanly, and a resume that completes after
the transport closed (or after its stream was cancelled) returns 404
instead of registering a stream nothing can clean up.

The POST path now arms keep-alive after its fallible awaits and
releases the stream and request correlations if they fail. Non-finite
keepAliveMs values disable keep-alive, and values above 2^31-1 are
clamped instead of firing every millisecond.
@felixweinberger
felixweinberger requested a review from a team as a code owner July 24, 2026 15:42
@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f983d0

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2547

commit: 0f983d0

Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
@mattzcarey

Copy link
Copy Markdown
Contributor

Hmm yeh this is better :) thanks!!

A response completing while its SSE stream was being resumed could be
written to the evicted stream (lost) or delivered twice when the event
store made it replay-visible before the write resolved. A stream that
disconnected before its response completed also left the response
unstored, so a polling client could never retrieve it, and the request
correlation maps leaked.

send() now stores response events even when the request stream is
disconnected (matching the standalone stream path), re-reads the stream
registration after the store write, skips events the resumed stream's
replay already delivered, and releases request correlations once the
response is safely replayable. Without an event store, or in JSON
response mode where a replay can never settle the pending response,
completing against a missing stream still surfaces an error.

Also guards session initialization and stream registration against a
transport that closed while the request body or the session
initialization callback was pending.
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
felixweinberger and others added 2 commits July 27, 2026 14:05
Extract keep-alive validation into a shared sseKeepAlive helper matching
main: non-finite and sub-millisecond intervals disable keep-alive, and
oversized delays are clamped instead of firing every millisecond.

Also ported from the main-branch implementation: handleRequest returns
404 once the transport is closed instead of registering streams nothing
can clean up, SSE responses send X-Accel-Buffering: no (and the POST
stream gains no-transform) so proxy buffering does not swallow frames,
and a DELETE request closes the transport even when the onsessionclosed
callback throws.

send() now also releases request correlations before reporting an
undeliverable response, so the entries don't outlive it.
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread .changeset/sse-keepalive-timer-lifecycle.md Outdated
send() released a completed response's correlations to Last-Event-ID
replay whenever an event store was configured, but a client can only
resume a stream if it actually holds a cursor: clients that never
received an id-bearing event (for example pre-2025-11-25 clients whose
stream carried no notifications) can never issue the resume, so their
lost responses were released silently where they previously surfaced
through onerror.

Request streams are now marked resumable when an id-bearing event is
delivered on them - the priming event, a stored notification, or a
replay - and the silent hand-off applies only to those streams. A
response completing while the transport closes is a no-op instead of a
spurious error, failed POST registrations release their tracking entry,
and the changeset now also describes the send() delivery-model changes.

@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, two candidates were examined and ruled out this pass: the sseKeepAlive.ts camelCase filename (matches the existing convention of its siblings, e.g. webStandardStreamableHttp.ts, despite CLAUDE.md's hyphens rule), and the resume supersede path applying to a half-open predecessor — when the event store implements getStreamIdForEventId, the 409 conflict check runs first, so the supersede-without-conflict-check shape only exists for stores without that optional method.

Extended reasoning...

This run's inline comments (two nits, one pre-existing InMemoryEventStore issue) are posted separately; this note only records what else was examined and refuted so a later pass doesn't re-explore it from scratch. No prior run left a ruled-out note on this PR, so this is the first and only such record. It is informational, not a correctness guarantee.

Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
The closed-transport no-op in send() ran after the response was recorded
in _requestResponseMap, so a send parked on the event-store write when
close() swept the maps re-populated them and stranded the response
payload and its correlations on the closed transport. Release them
before returning.
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Repeated close() re-ran the stream sweep and re-fired onclose. The main
branch guards close() re-entry; this restores the same guard, which the
DELETE handler's unconditional close in its finally block now relies on.
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
Comment thread src/server/webStandardStreamableHttp.ts
@felixweinberger
felixweinberger merged commit e3f3daa into v1.x Jul 27, 2026
12 checks passed
@felixweinberger
felixweinberger deleted the fweinberger/sse-keepalive-lifecycle branch July 27, 2026 17:25
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.

2 participants