-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client): stop listen() rejections escaping as unhandledRejection during an in-flight send #2642
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
Open
claude
wants to merge
1
commit into
main
Choose a base branch
from
fix/listen-opening-unhandled-rejection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+137
−10
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🟣 Pre-existing issue (unchanged lines, same state machine):
McpSubscription.close()retains the same hang class this PR fixes forlisten()— it seriallyawait wireTeardown(), which ends in an un-abortabletransport.sendofnotifications/cancelled, so under the same parked-send condition the new tests model (stdio write parked on 'drain'),await sub.close()suspends forever. Consider mirroring the fire-and-forget pattern the ack-timeout and caller-abort paths already use, or bounding the await — here or as a follow-up alongside #2552.Extended reasoning...
The sibling path:
close()still awaits a send that may never settleThis PR fixes
listen()so it suspends on theopeningstate machine instead of the send promise — a send parked forever no longer hangs the caller. But theMcpSubscriptionhandle thatlisten()returns keeps the same hang class in itsclose()path, inside the very closure this PR edits:Protocol.notification()→_notificationViaCodecends inawait this._transport.send(jsonrpcNotification, options)(the debounce fast-path doesn't apply —notifications/cancelledcarries params anddebouncedNotificationMethodsdefaults to[]). The.catch(() => {})only guards rejection; it does nothing for a promise that never settles.Concrete walk-through under the PR's own trigger scenario
write()returnsfalse;StdioClientTransport.send()parks on'drain'indefinitely (stdio ignoresTransportSendOptions.requestSignal, per this PR's own comments and fix(client): reject stdio send() when the write fails instead of waiting for 'drain' #2552).wireTeardown'snotifications/cancelledwrite.await sub.close().settle({ cause: 'local' })completes all observable local teardown (clears_listenState, ack timer, resolvessub.closedwith'local').await wireTeardown()then suspends on the parked send forever. The promiseclose()returned never resolves, hanging the caller's cleanup/shutdown code exactly the waylisten()used to hang.Why nothing existing covers it
void wireTeardown().catch(() => {}). Only the user-facingclose()serially awaits, and since failures are swallowed anyway, the await conveys no error signal to the caller.requestSignal) would not fix this:wireTeardownsends the cancel notification with no signal, afterrequestAborthas already been aborted.Suggested fix
Mirror the fire-and-forget pattern (
settle(...)already completes all observable local teardown before the await), or bound the await with a timeout race if there's value in usually confirming the cancel reached the wire beforeclose()resolves. One existing test ("close() sends notifications/cancelled referencing the listen id") relies on the cancel being on the wire whenclose()resolves, so a bounded race may be the gentler shape.Why pre-existing severity
The
close()/wireTeardownlines are byte-for-byte untouched by this diff; the hang predates the PR and isn't widened by it. It's worth a follow-up (or an opportunistic fix here alongside #2552) rather than a blocker.