Repository navigation
Conversation
…ners A subscriptions/listen request honored down to nothing is torn down inside ReadableStream.start(), before the request-signal wiring runs. Registering onAbort after that leaves a listener no teardown path can remove - the stream is already closed, and closeAll() no longer sees it - so a caller that reuses one live signal accumulates a listener per completed listen. Guard the registration with the router's existing closed state and cover both completed-filter shapes, with a control case that keeps the still-open abort path intact. Closes modelcontextprotocol#3163
Drop the per-connection-signal phrasing: the shipped HTTP entry passes request.signal from each POST (createMcpHandler.ts:814), so the retention shows up for callers that reuse one live signal across completed listens.
🦋 Changeset detectedLatest commit: 66f1f66 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Verified the exact head The guard matches the synchronous |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
koriyoshi2041
left a comment
There was a problem hiding this comment.
Rechecked the merge-updated head 66f1f66. The two PR commits are byte-identical in the range-diff, and the completed-listen guard/tests are unchanged. Diff hygiene passes, and the refreshed build, conformance, Node 20/22/24, E2E, Bun, Deno, and package-publish checks are all green. I did not find a merge-introduced regression in this scope.
What
subscriptions/listenno longer registers anabortlistener on the caller's signal when the listen stream has already completed. One guard line inpackages/server/src/server/listenRouter.ts, plus a test and a changeset.Related: this is the defect reported in #3163, and the guard implemented here is the one suggested there. The issue's repro —
abortListenersgoing 1 → 2 → 3 across three completed listens on one live signal, withopenStreamsandbusListenersboth at 0 and the count unchanged aftercloseAll()— matches the failing assertion quoted under Validation below. Credit to @JalenBuildsHub for the write-up; if they have a PR of their own in flight for it, this one can be closed in favour of theirs.Why
serve()creates the SSEReadableStreamatlistenRouter.ts:204, andReadableStreamrunsstart()synchronously. When the honored filter ends up empty — either because the client asked for{}or because the negotiatedhonoredSubset(filter, capabilities)narrowed it down to nothing —start()completes the subscription right there (listenRouter.ts:216-220):teardown()starts withif (closed) return;(listenRouter.ts:169-170) and setsclosed = trueatlistenRouter.ts:188, so that call insidestart()is the only one that ever runs to completion — and at that momentabortCleanupis stillundefined, because the variable is only assigned further down (listenRouter.ts:247). By the time control returns tolistenRouter.ts:241the stream is dead, yet the block still runs:That listener is now unreachable by both removal paths:
teardown()returns at line 170 becauseclosedis already set, so it never reachesabortCleanup?.()at line 195; andcloseAll()(listenRouter.ts:264-266) only iterates theopenset, whichstart()never added this subscription to (listenRouter.ts:231is after the earlyreturn). So the handler stays registered — along with its closure overteardown,controllerand the router's state — for as long as the signal itself lives.How visible this is depends on whose signal it is:
createMcpHandler.ts:814passesrequest.signal, so the signal is per-request and the retention dies with the request. Every other listen path (aborted,cancel(),closeAll(), a normal teardown) removes its listener; this is the one path that leaves one behind, and the listener can never fire anything but a no-op.serve()a signal outliving one request, it accumulates one stranded listener plus closure per completed listen, and past Node's default limit that signal also starts emittingMaxListenersExceededWarning.createListenRouteris currently internal (onlylistenRouter.tsandcreateMcpHandler.tsreference it), so that accumulation is reached today by importing the module from inside the package — as the repro in subscriptions/listen retains abort listeners after immediately completed streams #3163 does — rather than through the published API. The guard is therefore both a dead-listener fix for the HTTP path and a correctness fix for the router's own invariant.The change is one guard line: it removes wiring that cannot do anything, and makes the router's "no listener outlives its subscription" invariant hold for the empty-honored case too.
How
Guard the wiring on the stream still being open. A stream that completed inside
start()has nothing left to tear down, so there is nothing to listen for:The already-aborted branch is unaffected: with
!closedfalse the stream is complete, so callingteardown(false)there would be a no-op anyway.Validation
Monorepo checked out at
main=1e608caeb5130b2ad0fa067c84b26a038c1c6e36, Node 24.21.0, Windows.pnpm installonly — I did not runpnpm build:all, which is why the twodist-dependent test files below fail (they fail identically on unmodifiedmain).Before — the new test against unmodified
listenRouter.ts(pnpm --filter @modelcontextprotocol/server exec vitest run test/server/listenRouterAbortCleanup.test.ts):(The third case is the control: a stream that stays open must still get exactly one listener and release it on abort. It passes before and after, so the fix does not remove the wiring that is actually needed.)
After — same command with the fix:
packages/serverfull suite, pristine vs fixed.pnpm --filter @modelcontextprotocol/server test:mainTest Files 2 failed | 53 passed (55)/Tests 654 passed | 20 skipped (674)Test Files 2 failed | 54 passed (56)/Tests 657 passed | 20 skipped (677)Both runs fail the same two pre-existing files, and the extracted
FAILlines (ANSI stripped, sorted) are byte-identical between the two runs — 3 lines, non-empty:Both need a built
dist/resolved through the package's published export map, which my checkout does not have (pnpm build:allwas not run); they fail identically on unmodifiedmain, and both pass in CI on this PR, which is consistent with that explanation. Passed count goes 654 → 657, exactly the three tests added here.Lint and typecheck,
pnpm --filter @modelcontextprotocol/server lint/typecheck:(both exit 0; typecheck only prints Node's
process.execve is an experimental featurewarning)CI at submission. All of the required checks (
build,client-conformance,server-conformance,test (20/22/24),test-e2e (20/22/24),test-runtimes) plusexamples (build + e2e)pass on commit45f2c96c. The new file is picked up by the repo's own runner on all three matrices, e.g.test (22):which also confirms the two locally-failing files are only dist-dependent, not broken.
The one red check is
pkg-publish("Publish Any Commit"), which is not among the required checks: itsPublish preview packagesstep exits 1 about a second after starting, on a 404 from pkg-pr-new's own endpoint —— before pnpm has resolved or packed anything, so nothing in this diff is reached. I have not re-run it; flagging it here rather than claiming a clean board.
Docs / changeset
A
patchchangeset for@modelcontextprotocol/serveris included, describing the user-visible fix rather than the internal guard.The regression test is
packages/server/test/server/listenRouterAbortCleanup.test.ts— undertest/, per the repo note that a test file placed next to its source never runs.AI assistance
This PR was authored by an AI coding agent (Qoder) running as the account owner's standing daily contribution automation. The agent read
listenRouter.ts, wrote the failing test, applied the guard, and executed every command whose output is quoted above; the outputs are copied from those runs, not reconstructed. The account owner reviews merged/open PRs rather than pre-approving each patch, so please treat the reasoning above as the agent's own analysis and file any follow-up questions on this thread — the automation revisits PRs daily and will respond to review comments and CI.