Skip to content

fix(sse): bound what a legacy SSE stream buffers for a slow reader - #356

Open
mcollina wants to merge 1 commit into
mainfrom
fix/316-sse-backpressure
Open

mcollina wants to merge 1 commit into
mainfrom
fix/316-sse-backpressure

Conversation

@mcollina

Copy link
Copy Markdown
Member

Problem

The legacy GET /mcp SSE stream writes every event (live sends, the Last-Event-ID replay, heartbeats) with raw.write() and ignores the return value. For a client that stops reading, every broadcast stays in Node's writable buffer until the socket dies. That is 25 MB for 400 notifications of 64 KB, per stalled stream.

Fix

  • Live sends and heartbeats now go through a new writeSSE(). When write() reports back-pressure and more than 1 MB (MAX_SSE_BUFFERED_BYTES, the same limit as the 2026-07-28 listen streams on feat: implement MCP 2026-07-28 alongside the handshake revisions #170) is waiting for the client, the stream is destroyed. The events are already in the session history, so the client reconnects with Last-Event-ID and misses nothing. The destroyed stream is removed from the session like any other dead stream, so the reconnect is not refused with 409. Node's own writable buffer is the queue. There is no second queue in front of it, because the history already does that job.
  • The Last-Event-ID replay waits for drain (or close) whenever write() returns false, instead of writing the whole backlog at once. A backlog bigger than 1 MB is normal on reconnect. Closing the stream for it would make a client that is reading fine reconnect forever.

Notes for review:

Tests

test/sse-backpressure.test.ts, real server with raw sockets:

  • a stream that stops reading under 400 × 64 KB broadcasts is destroyed, and the server buffers at most ~1 MB for it (main: 25 MB, stream left open)
  • after that, a new GET for the session gets 200 (main: 409)
  • a ~3 MB Last-Event-ID replay reaches a client that keeps reading, complete and without the stream being closed
  • a ~3 MB replay to a client that stops reading is not queued whole on the server (main: 3.1 MB buffered)

Lint, typecheck and the full suite pass locally (770 tests). The new file passed 5 out of 5 repeated runs.

Fixes #316

🤖 Generated with Claude Code

https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

Every event and heartbeat on a GET /mcp stream was written with
raw.write() and its return value ignored, so a client that stopped
reading made the server queue every broadcast for it until the socket
died: 25 MB for 400 notifications of 64 KB.

Live writes now go through writeSSE(), which closes a stream once more
than 1 MB is waiting for its client. Events are in the session history,
so the client resumes with Last-Event-ID and loses nothing. The replay
of that history waits for 'drain' instead, since a backlog larger than
the limit is normal on reconnect and must not get the stream closed.

Fixes #316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

This branch has not been deployed

No deployments
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.

Legacy SSE streams buffer broadcasts without limit for a client that stops reading

1 participant