Repository navigation
Description
An HTTP listen stream whose requested filter is narrowed to empty completes immediately, but the router then registers an abort listener after teardown. Repeated completed listens on one live signal accumulate callbacks, even though there are no open streams or bus listeners. closeAll() does not remove them.
Reproduced on current main, commit 1e608caeb5130b2ad0fa067c84b26a038c1c6e36, with Node 24.19.0 and pnpm 10.26.1. This report tests the source checkout, not a published npm release.
Reproduction
Check out that commit and run corepack pnpm install --frozen-lockfile --ignore-scripts. Save this as repro-listen-abort.mts in the repository root:
import { getEventListeners } from 'node:events';
import { createListenRouter } from './packages/server/src/server/listenRouter.ts';
import { InMemoryServerEventBus } from './packages/server/src/server/serverEventBus.ts';
const signal = new AbortController().signal;
const bus = new InMemoryServerEventBus();
const router = createListenRouter({ bus, keepAliveMs: 0 });
for (let id = 1; id <= 3; id++) {
const response = router.serve(
{
jsonrpc: '2.0',
id,
method: 'subscriptions/listen',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientInfo': { name: 'repro', version: '1' },
'io.modelcontextprotocol/clientCapabilities': {}
},
notifications: { toolsListChanged: true }
}
},
signal,
{},
{ name: 'repro', version: '1' }
);
const body = await response.text();
console.log(JSON.stringify({
id,
completed: body.includes('"resultType":"complete"'),
openStreams: router.openCount,
busListeners: bus.listenerCount,
abortListeners: getEventListeners(signal, 'abort').length
}));
}
router.closeAll();
console.log(`abort listeners after closeAll: ${getEventListeners(signal, 'abort').length}`);Run:
corepack pnpm exec tsx --tsconfig packages/server/tsconfig.json repro-listen-abort.mtsActual output:
{"id":1,"completed":true,"openStreams":0,"busListeners":0,"abortListeners":1}
{"id":2,"completed":true,"openStreams":0,"busListeners":0,"abortListeners":2}
{"id":3,"completed":true,"openStreams":0,"busListeners":0,"abortListeners":3}
abort listeners after closeAll: 3
Expected: completed subscriptions leave zero abort listeners. The retained callbacks keep their subscription closures reachable while the signal remains live and un-aborted.
Cause and suggested fix
In packages/server/src/server/listenRouter.ts, ReadableStream.start() synchronously calls teardown(true) when nothing is honored. Later, the outer function's signal block attaches onAbort, after that teardown has already finished. Subsequent teardown calls return immediately because closed is true.
Guard registration with the existing closed state:
- if (signal !== undefined) {
+ if (!closed && signal !== undefined) {I ran the same reproducer with this change: every listener count, including after closeAll(), is zero. Focused regressions cover both capability narrowing and explicitly empty requested filters, plus active cancel/close/abort cleanup; all 29 HTTP/stdio/listen tests pass. The two completed-filter regressions fail on the original source.
Searched existing issues and PRs for this listener-retention case. #2651 covers immediate completion itself; this report concerns the listener added afterward. Prepared and tested with Codex assistance.
Activity
media-ctrl commentedon Nov 9, 2026
The mechanism holds when traced through
packages/server/src/server/listenRouter.tsat 1e608ca, and the ordering is the whole bug.ReadableStream'sstart()runs synchronously inside thenew ReadableStream(...)constructor (line 204). For an empty honored filter the callback acks, then callsteardown(true)and returns (lines 216 to 218). Teardown setsclosed = trueat line 188 and callsabortCleanup?.()at 195, butabortCleanupis still undefined at that point, andopen.add(teardown)at line 231 is skipped by the early return.Control then returns to
serve(), which reaches the signal block at line 241 for the first time.signal.abortedis false, so it registers the listener at 246 and stores the remover inabortCleanup. That remover is only ever called fromteardown, which now returns immediately at theif (closed) return;guard on line 170. So the listener is added after teardown and nothing can remove it, one per completed listen, exactly the 1, 2, 3 in your output.closeAll()cannot help either: it iteratesopen, and this stream never enteredopen.Two ways to close it, both small:
new ReadableStream(...)construction. Teardown already callsabortCleanup?.(), so the complete-immediately path then removes the listener on its way out, and the abort path is unchanged.!closed, since a stream that completed duringstart()can never be aborted into anything.I prefer the first, because the second leaves two places that must agree about
closedand the next early-return added insidestart()reintroduces the same leak. Moving the registration up also makes thesignal.abortedcheck meaningful for the pre-stream case, which is the one where teardown currently runs before any listener exists.Worth a regression test on the exact assertion from your repro, since
openCountandbus.listenerCountboth read 0 in the leaking case and neither one can catch it: create the router with a signal, drive three listens whose filters honor nothing, then assertgetEventListeners(signal, 'abort').length === 0, and that it is still 0 aftercloseAll().