Skip to content

fix: #780 onerror and other listener not remove after client close#1322

Open
shellRaining wants to merge 3 commits intomodelcontextprotocol:mainfrom
shellRaining:main
Open

fix: #780 onerror and other listener not remove after client close#1322
shellRaining wants to merge 3 commits intomodelcontextprotocol:mainfrom
shellRaining:main

Conversation

@shellRaining
Copy link
Copy Markdown

Added logic to clean up residual listeners after calling transport.close().

This is my third attempt to submit a PR to the repository. I hope anybody can pay some attention to review it, I'd be truly grateful. and also point out any mistakes if there are any.

Motivation and Context

see #780

How Has This Been Tested?

After making this modification, residual listeners will not be triggered after closing the transport, and only "close" will be printed in the console.

and past all test suits

Breaking Changes

No breaking changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@shellRaining shellRaining requested a review from a team as a code owner December 20, 2025 03:14
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Dec 20, 2025

🦋 Changeset detected

Latest commit: b67ce5e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/client Patch

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Dec 20, 2025

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1322

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1322

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1322

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1322

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1322

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1322

commit: b67ce5e

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[deleted]

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct and well-structured, but since this touches a core transport implementation in a widely-used SDK, I'd recommend a human maintainer give it a quick look before merging.

Extended reasoning...

Overview

This PR fixes issue #780 by properly detaching stdout/stdin listeners when StdioClientTransport.close() is called. The implementation stores handler references as instance fields (_onServerDataHandler, _onServerErrorHandler) and adds a cleanupListeners() method that calls .off() on close. It also improves on('spawn') and on('close') to use once() since these are one-time events. The changeset and regression test are included.

Security risks

No security risks identified. The change only affects listener lifecycle management on stdio streams — no new inputs are accepted, no permissions or auth logic is affected.

Level of scrutiny

This warrants moderate scrutiny. While the fix follows a well-established Node.js pattern (storing handler refs for later .off()), it touches the stdio transport layer of the MCP client SDK, which is a critical communication path. The refactoring reorders listener setup and changes on to once for spawn/close events, which are correct but worth a human confirming. The PR has been open since December 2025 with no human reviews.

Other factors

The regression test structurally verifies the fix by asserting .off('data', handler) was called. The only bug found is a minor nit: the behavioral assertion (onerror not called) passes trivially due to ReadBuffer swallowing SyntaxError from non-JSON lines (commit 64897f7), rather than due to listener detachment. This doesn't affect the production code correctness — just means the test's behavioral assertion is weaker than it appears. The structural assertions adequately cover the fix.

shellRaining and others added 2 commits March 30, 2026 13:32
- Add test verifying stdout listeners are detached on close() so
  late non-JSON output from the child process no longer fires onerror
- Add patch-level changeset
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines +154 to 160
this._process.once('close', _code => {
if (this._process) {
this.cleanupListeners(this._process);
}
this._process = undefined;
this.onclose?.();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The start() Promise can hang permanently if close() is called before the spawned process emits spawn and the process subsequently errors (e.g. ENOENT). This is a regression introduced by this PR: the new cleanupListeners() call in close() removes _onProcessErrorHandler — the only function that invokes reject() — before the process has a chance to emit error. The once("close") handler in start() only calls onclose?.() and never reject(), so the Promise leaks. Fix by holding a separate reject reference outside the cleanup lifecycle, or by checking whether the Promise has already settled before clearing the error handler.

Extended reasoning...

What the bug is and how it manifests

This PR introduces cleanupListeners() to fix spurious post-close onerror callbacks (issue #780). As part of that fix, _onProcessErrorHandler is stored as a named field so it can be removed via .off(). However, _onProcessErrorHandler is also the only function that calls reject() inside start()s Promise executor. When close() is called before the process emits spawn, it calls cleanupListeners() which removes _onProcessErrorHandler from the process. If the process then emits error (e.g. ENOENT because the command was not found), there is no longer any listener to call reject(), and the start() Promise is permanently pending.

The specific code path that triggers it

In stdio.ts at lines 154-160, the once("close", ...) handler only calls this.onclose?.() — it never calls resolve() or reject(). The only reject() call is inside _onProcessErrorHandler. So once that handler is removed by cleanupListeners(), the Promise has no settlement path for error cases.

Why existing code does not prevent it

The once("close") handler in start() does fire after the process exits, but it only calls this.onclose?.(). Even though the process closed (and therefore failed), reject() is never invoked. Additionally, the guard if (this._process) inside that handler is already false (because close() set this._process = undefined before yielding), so cleanupListeners is also skipped there — but that is irrelevant since _onProcessErrorHandler is already gone.

What the impact is

Any caller that does const p = transport.start(); await transport.close(); await p; with a command that errors during startup (ENOENT, permission denied, etc.) will hang indefinitely awaiting p. This is a realistic pattern for connection timeouts, cancellation during initialization, and test teardown. Before this PR, the anonymous error handler was never removed by close(), so reject() would always fire when the process errored — the behavior was correct even if imperfect.

How to fix it

Capture reject in a variable that lives outside the cleanup lifecycle. For example:

let startReject: ((e: Error) => void) | undefined;
return new Promise((resolve, reject) => {
    startReject = reject;
    // ... spawn ...
    this._onProcessErrorHandler = error => {
        startReject?.(error);  // call reject even if handler is detached
        this.onerror?.(error);
    };
    // In once("close"):
    this._process.once("close", _code => {
        startReject = undefined;  // settled
        ...
    });
});

Alternatively, track a started boolean and only remove _onProcessErrorHandler after spawn fires.

Step-by-step proof

  1. Caller invokes const p = transport.start(). Inside the Promise executor, _onProcessErrorHandler = error => { reject(error); onerror?.(error); } is registered on the process.
  2. Caller immediately invokes transport.close(). Before any await, close() calls cleanupListeners(this._process), which calls process.off("error", this._onProcessErrorHandler) — removing the only reject() pathway.
  3. close() sets this._process = undefined.
  4. close() hits await Promise.race([closePromise, ...]) and yields to the event loop.
  5. The OS reports ENOENT: the process emits "error". _onProcessErrorHandler was removed in step 2, so no listener fires. reject() is never called.
  6. The process emits "close". The once("close") handler in start() runs: if (this._process) is false (set in step 3), so cleanupListeners is skipped. Only this.onclose?.() is called. Still no reject().
  7. closePromise resolves. close() returns.
  8. await p hangs forever — the start() Promise is permanently pending.

Comment on lines 220 to 225
async close(): Promise<void> {
if (this._process) {
this.cleanupListeners(this._process);
const processToClose = this._process;
this._process = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In close(), cleanupListeners() removes the stdin error listener before processToClose.stdin?.end() is called, creating a window where async EPIPE errors from stdin have no handler. The try/catch around stdin.end() only catches synchronous throws — async error events emitted by the stream after the call returns are unhandled, causing Node.js to throw an uncaught exception. Fix: call processToClose.stdin?.end() before cleanupListeners(), or attach a temporary no-op error listener to stdin that outlives the end() call.

Extended reasoning...

What the bug is and how it manifests

In close(), the sequence is: (1) cleanupListeners(this._process) removes _onServerErrorHandler from stdin via process.stdin?.off('error', ...), (2) this._process = undefined, then (3) processToClose.stdin?.end() is called with no error listener on stdin.

If the child process has already exited (its read end of the pipe is closed), calling stdin.end() flushes any buffered data and closes the write side. The OS may respond with an EPIPE, which Node.js translates into an asynchronous error event emitted on the stdin Writable stream.

The specific code path that triggers it

Before the PR, the anonymous stdin error listener was registered and never removed, so EPIPE was routed (spuriously) to onerror. After the PR, cleanupListeners() correctly removes all stored listeners, but does so before stdin.end() is called. When the async EPIPE event fires on stdin, Node.js finds zero listeners and throws an uncaught exception per standard EventEmitter semantics.

Why existing safeguards do not prevent it

The try/catch block around processToClose.stdin?.end() only intercepts synchronous exceptions thrown during the end() call itself. EPIPE is an asynchronous OS event delivered after end() returns — the call stack is unwound, so the try/catch is no longer active. The process-level _onProcessErrorHandler lives on the ChildProcess EventEmitter (a different object from the stdin Writable stream) and provides no protection against errors emitted on stdin.

Impact

This is a regression introduced by the PR. Before the PR, EPIPE on stdin was silently routed to onerror (spurious but harmless). After the PR, EPIPE on stdin crashes the process with an unhandled error. This is common: any time a child MCP server exits before the client calls close(), its stdin pipe is broken, and the race between cleanupListeners and async EPIPE delivery is real and platform-dependent.

Step-by-step proof

  1. Child process exits — its stdin read end closes, pipe is broken
  2. Caller invokes transport.close()
  3. close() calls cleanupListeners(this._process)process.stdin?.off('error', ...) removes the only error listener from stdin
  4. processToClose.stdin?.end() is called — attempts to flush/close the write side of the broken pipe
  5. OS returns EPIPE; Node.js queues an error event on stdin's EventEmitter
  6. Event loop delivers the error event — stdin.listenerCount('error') === 0
  7. Node.js throws since error was emitted with no listener — uncaught exception, process crash

How to fix it

Reorder: call processToClose.stdin?.end() before cleanupListeners(), so the error listener is still active during and after the end() call. Alternatively, attach a temporary no-op error handler to stdin before end() and remove it once the stream closes.

Comment on lines +207 to +218
private cleanupListeners(process: ChildProcess) {
if (this._onServerDataHandler) {
process.stdout?.off('data', this._onServerDataHandler);
}
if (this._onServerErrorHandler) {
process.stdout?.off('error', this._onServerErrorHandler);
process.stdin?.off('error', this._onServerErrorHandler);
}
if (this._onProcessErrorHandler) {
process.off('error', this._onProcessErrorHandler);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The cleanupListeners(process: ChildProcess) parameter at line 207 shadows the module-level import process from 'node:process' at line 2. Inside the method, process.off("error", ...) reads as if it removes a Node.js global process error handler, when it actually operates on the ChildProcess argument — renaming to childProcess or proc eliminates the ambiguity.

Extended reasoning...

What the bug is

In packages/client/src/client/stdio.ts, the method signature private cleanupListeners(process: ChildProcess) at line 207 uses process as a parameter name. This shadows the top-level import process from 'node:process' at line 2. Every reference to process inside the method body therefore resolves to the ChildProcess argument, not Node's global process object.

The specific code path

Inside cleanupListeners, the call process.off('error', this._onProcessErrorHandler) at line 216 looks syntactically identical to removing a handler from Node's global process object — a common pattern for deregistering global uncaught-exception or unhandledRejection handlers. A reader unfamiliar with the local shadowing would reasonably misinterpret this line.

Why existing code doesn't prevent it

The current code is functionally correct: every use of process inside cleanupListeners legitimately targets the ChildProcess argument (.stdout?.off(...), .stdin?.off(...), .off(...)). TypeScript's type system provides some protection — accessing process.env or process.platform would produce a compile error since ChildProcess lacks those properties. However, ChildProcess extends EventEmitter and shares off(), on(), and emit() with the global process object, so TypeScript would not catch an accidental process.off(...) call intended for the global process.

Impact

This is a maintenance trap rather than an active bug. Any developer adding code inside cleanupListeners that intends to read process.env, call process.exit(), or check process.platform would silently receive the ChildProcess object instead, producing a runtime error or silent misbehavior. The naming also creates genuine cognitive overhead every time a reviewer reads process.off("error", ...) in this context.

Step-by-step proof of the shadowing

  1. File header: import process from 'node:process'process refers to Node global.
  2. cleanupListeners(process: ChildProcess) is declared — the parameter process shadows the import within this scope.
  3. Inside the method, process.stdout?.off(...) and process.off(...) all resolve to the ChildProcess argument.
  4. If a developer adds process.env.SOME_VAR inside this method expecting the Node global, TypeScript raises a type error (ChildProcess has no env) — partial protection, but the naming confusion still exists.
  5. If a developer adds process.off('exit', someHandler) intending the global process, it would compile successfully (both share off()) and silently target the wrong object.

Fix

Rename the parameter to childProcess (or proc) throughout the method:

private cleanupListeners(childProcess: ChildProcess) {
    if (this._onServerDataHandler) {
        childProcess.stdout?.off('data', this._onServerDataHandler);
    }
    // ...
    if (this._onProcessErrorHandler) {
        childProcess.off('error', this._onProcessErrorHandler);
    }
}

This is a one-line rename with zero behavioral impact.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants