Skip to content

SSE handlers register onAbort after the priming write, so a disconnect during that await leaks the consumer/watcher and hangs the handler #1999

Description

@cliffhall

Found by Copilot reviewing the v2.2.0 milestone merge (#1993), verified against the installed hono.

#1946 added a priming write so Firefox's fetch() resolves on an SSE stream that may never emit. In both SSE handlers in core/mcp/remote/node/server.ts that write is awaited before stream.onAbort(...) is registered:

  • ~core/mcp/remote/node/server.ts:851-862 — the session event stream (setEventConsumer already installed)
  • ~core/mcp/remote/node/server.ts:2433-2444 — the server-list change stream (serverEventSubscribers.add(send) + ensureWatcher() already done)

Hono's StreamingApi.onAbort is a plain push with no already-aborted replay (node_modules/hono/dist/utils/stream.js:65-66), and abort() fires only the subscribers registered at that moment (:73-76). So if the client disconnects while the priming write is in flight, every onAbort handler registered afterwards never runs:

  1. the event consumer / serverEventSubscribers entry is never cleared, and maybeStopWatcher() never runs — the mcp.json watcher stays alive with a dead subscriber, and later writes go to a closed stream;
  2. the session is never deleted from sessions;
  3. the terminal await new Promise(resolve => stream.onAbort(resolve)) never resolves — the handler is parked forever.

The window is narrow (a disconnect inside a single small write) but it is a real leak, and it did not exist before the priming write introduced an await at that point.

Constraint

The ordering is not accidental and must be preserved: :2427-2432 documents that the subscriber and watcher are installed before priming on purpose, because callers treat the stream's first bytes as proof they are subscribed. The fix is to move the cleanup registration earlier, not the priming.

Suggested shape

Register one abort promise that performs the cleanup before the first await, then await it after priming — e.g.

const aborted = new Promise<void>((resolve) => {
  stream.onAbort(() => { /* cleanup */ resolve(); });
});
await stream.write(SSE_PRIMING_COMMENT);
await aborted;

This also collapses the current duplicate onAbort registrations (one for cleanup, one to resolve the hold-open promise) into a single handler.

Test

An integration test that aborts the request while the priming write is pending should observe the subscriber removed and the watcher stopped.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Issues and PRs for v2

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions