-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix: #780 onerror and other listener not remove after client close #1322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Detach stdio transport stdout/stdin listeners on `close()`. Previously, if the child process wrote to stdout during shutdown (after `close()` was called), the still-attached data listener would attempt to parse it as JSON-RPC and emit a spurious parse error via `onerror`. Fixes #780. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,9 @@ export class StdioClientTransport implements Transport { | |
| private _readBuffer: ReadBuffer = new ReadBuffer(); | ||
| private _serverParams: StdioServerParameters; | ||
| private _stderrStream: PassThrough | null = null; | ||
| private _onServerDataHandler?: (chunk: Buffer) => void; | ||
| private _onServerErrorHandler?: (error: Error) => void; | ||
| private _onProcessErrorHandler?: (error: Error) => void; | ||
|
|
||
| onclose?: () => void; | ||
| onerror?: (error: Error) => void; | ||
|
|
@@ -130,33 +133,32 @@ export class StdioClientTransport implements Transport { | |
| cwd: this._serverParams.cwd | ||
| }); | ||
|
|
||
| this._process.on('error', error => { | ||
| reject(error); | ||
| this._onServerDataHandler = (chunk: Buffer) => { | ||
| this._readBuffer.append(chunk); | ||
| this.processReadBuffer(); | ||
| }; | ||
| this._onServerErrorHandler = (error: Error) => { | ||
| this.onerror?.(error); | ||
| }); | ||
| }; | ||
|
|
||
| this._process.on('spawn', () => { | ||
| resolve(); | ||
| }); | ||
| this._process.stdout?.on('data', this._onServerDataHandler); | ||
| this._process.stdout?.on('error', this._onServerErrorHandler); | ||
| this._process.stdin?.on('error', this._onServerErrorHandler); | ||
|
|
||
| this._process.on('close', _code => { | ||
| this._onProcessErrorHandler = error => { | ||
| reject(error); | ||
| this.onerror?.(error); | ||
| }; | ||
| this._process.on('error', this._onProcessErrorHandler); | ||
| this._process.once('spawn', () => resolve()); | ||
| this._process.once('close', _code => { | ||
| if (this._process) { | ||
| this.cleanupListeners(this._process); | ||
| } | ||
| this._process = undefined; | ||
| this.onclose?.(); | ||
| }); | ||
|
|
||
| this._process.stdin?.on('error', error => { | ||
| this.onerror?.(error); | ||
| }); | ||
|
|
||
| this._process.stdout?.on('data', chunk => { | ||
| this._readBuffer.append(chunk); | ||
| this.processReadBuffer(); | ||
| }); | ||
|
|
||
| this._process.stdout?.on('error', error => { | ||
| this.onerror?.(error); | ||
| }); | ||
|
|
||
| if (this._stderrStream && this._process.stderr) { | ||
| this._process.stderr.pipe(this._stderrStream); | ||
| } | ||
|
|
@@ -202,8 +204,22 @@ export class StdioClientTransport implements Transport { | |
| } | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
|
Comment on lines
+207
to
+218
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...What the bug isIn The specific code pathInside Why existing code doesn't prevent itThe current code is functionally correct: every use of ImpactThis is a maintenance trap rather than an active bug. Any developer adding code inside Step-by-step proof of the shadowing
FixRename the parameter to 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. |
||
|
|
||
| async close(): Promise<void> { | ||
| if (this._process) { | ||
| this.cleanupListeners(this._process); | ||
| const processToClose = this._process; | ||
| this._process = undefined; | ||
|
|
||
|
Comment on lines
220
to
225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 In Extended reasoning...What the bug is and how it manifestsIn If the child process has already exited (its read end of the pipe is closed), calling The specific code path that triggers itBefore the PR, the anonymous stdin error listener was registered and never removed, so EPIPE was routed (spuriously) to Why existing safeguards do not prevent itThe ImpactThis is a regression introduced by the PR. Before the PR, EPIPE on stdin was silently routed to Step-by-step proof
How to fix itReorder: call |
||
|
|
||
There was a problem hiding this comment.
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 ifclose()is called before the spawned process emitsspawnand the process subsequently errors (e.g. ENOENT). This is a regression introduced by this PR: the newcleanupListeners()call inclose()removes_onProcessErrorHandler— the only function that invokesreject()— before the process has a chance to emiterror. Theonce("close")handler instart()only callsonclose?.()and neverreject(), so the Promise leaks. Fix by holding a separaterejectreference 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-closeonerrorcallbacks (issue #780). As part of that fix,_onProcessErrorHandleris stored as a named field so it can be removed via.off(). However,_onProcessErrorHandleris also the only function that callsreject()insidestart()s Promise executor. Whenclose()is called before the process emitsspawn, it callscleanupListeners()which removes_onProcessErrorHandlerfrom the process. If the process then emitserror(e.g. ENOENT because the command was not found), there is no longer any listener to callreject(), and thestart()Promise is permanently pending.The specific code path that triggers it
In
stdio.tsat lines 154-160, theonce("close", ...)handler only callsthis.onclose?.()— it never callsresolve()orreject(). The onlyreject()call is inside_onProcessErrorHandler. So once that handler is removed bycleanupListeners(), the Promise has no settlement path for error cases.Why existing code does not prevent it
The
once("close")handler instart()does fire after the process exits, but it only callsthis.onclose?.(). Even though the process closed (and therefore failed),reject()is never invoked. Additionally, the guardif (this._process)inside that handler is alreadyfalse(becauseclose()setthis._process = undefinedbefore yielding), socleanupListenersis also skipped there — but that is irrelevant since_onProcessErrorHandleris 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 awaitingp. This is a realistic pattern for connection timeouts, cancellation during initialization, and test teardown. Before this PR, the anonymous error handler was never removed byclose(), soreject()would always fire when the process errored — the behavior was correct even if imperfect.How to fix it
Capture
rejectin a variable that lives outside the cleanup lifecycle. For example:Alternatively, track a
startedboolean and only remove_onProcessErrorHandlerafterspawnfires.Step-by-step proof
const p = transport.start(). Inside the Promise executor,_onProcessErrorHandler = error => { reject(error); onerror?.(error); }is registered on the process.transport.close(). Before anyawait,close()callscleanupListeners(this._process), which callsprocess.off("error", this._onProcessErrorHandler)— removing the onlyreject()pathway.close()setsthis._process = undefined.close()hitsawait Promise.race([closePromise, ...])and yields to the event loop."error"._onProcessErrorHandlerwas removed in step 2, so no listener fires.reject()is never called."close". Theonce("close")handler instart()runs:if (this._process)isfalse(set in step 3), socleanupListenersis skipped. Onlythis.onclose?.()is called. Still noreject().closePromiseresolves.close()returns.await phangs forever — thestart()Promise is permanently pending.