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:
- 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;
- the session is never deleted from
sessions;
- 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.
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 incore/mcp/remote/node/server.tsthat write isawaited beforestream.onAbort(...)is registered:~core/mcp/remote/node/server.ts:851-862— the session event stream (setEventConsumeralready installed)~core/mcp/remote/node/server.ts:2433-2444— the server-list change stream (serverEventSubscribers.add(send)+ensureWatcher()already done)Hono's
StreamingApi.onAbortis a plain push with no already-aborted replay (node_modules/hono/dist/utils/stream.js:65-66), andabort()fires only the subscribers registered at that moment (:73-76). So if the client disconnects while the priming write is in flight, everyonAborthandler registered afterwards never runs:serverEventSubscribersentry is never cleared, andmaybeStopWatcher()never runs — themcp.jsonwatcher stays alive with a dead subscriber, and later writes go to a closed stream;sessions;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
awaitat that point.Constraint
The ordering is not accidental and must be preserved:
:2427-2432documents 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.This also collapses the current duplicate
onAbortregistrations (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.