Skip to content

subscriptions/listen retains abort listeners after immediately completed streams #3163

Open

Description

@JalenBuildsHub

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.mts

Actual 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

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Nov 9, 2026
  2. media-ctrl commented on Nov 9, 2026

    @media-ctrl

    The mechanism holds when traced through packages/server/src/server/listenRouter.ts at 1e608ca, and the ordering is the whole bug.

    ReadableStream's start() runs synchronously inside the new ReadableStream(...) constructor (line 204). For an empty honored filter the callback acks, then calls teardown(true) and returns (lines 216 to 218). Teardown sets closed = true at line 188 and calls abortCleanup?.() at 195, but abortCleanup is still undefined at that point, and open.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.aborted is false, so it registers the listener at 246 and stores the remover in abortCleanup. That remover is only ever called from teardown, which now returns immediately at the if (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 iterates open, and this stream never entered open.

    Two ways to close it, both small:

    1. Move the abort registration above the new ReadableStream(...) construction. Teardown already calls abortCleanup?.(), so the complete-immediately path then removes the listener on its way out, and the abort path is unchanged.
    2. Keep the current order and guard the registration on the stream state: register only when !closed, since a stream that completed during start() can never be aborted into anything.

    I prefer the first, because the second leaves two places that must agree about closed and the next early-return added inside start() reintroduces the same leak. Moving the registration up also makes the signal.aborted check 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 openCount and bus.listenerCount both 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 assert getEventListeners(signal, 'abort').length === 0, and that it is still 0 after closeAll().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions