Skip to content

Stop sendAndWait from emitting an unhandled rejection - #2206

Open
thejesh23 wants to merge 1 commit into
github:mainfrom
thejesh23:fix/send-and-wait-unhandled-rejection
Open

Stop sendAndWait from emitting an unhandled rejection#2206
thejesh23 wants to merge 1 commit into
github:mainfrom
thejesh23:fix/send-and-wait-unhandled-rejection

Conversation

@thejesh23

Copy link
Copy Markdown

Fixes #2205

What's wrong

sendAndWait creates idlePromise and registers the event listener that can reject it, but the first consumer is only attached by the Promise.race further down — after await this.send(options), a full JSON-RPC round trip to the CLI.

A session.error arriving in that window therefore rejects a promise with no handler. Node's rejection tracker runs at the following checkpoint, well before the session.send response lands, and classifies it as unhandled — which terminates the process under the default --unhandled-rejections=throw.

The existing comment above the listener shows the inverse race (subscribing too late) was considered; this is the opposite ordering.

Why it's reachable in normal use

session.error is not reserved for turn-fatal failures. session.log(msg, { level: "error" }) emits one with errorType: "notification" — asserted in test/e2e/session.e2e.test.ts. So a joined client or extension writing an error log line while another caller is mid-sendAndWait is enough; MCP servers failing to start and sub-agent errors do the same.

A caller cannot defend against this: the rejection is on the internal promise, not on the one sendAndWait returns, so even correct try/.catch handling around the call does not prevent the crash.

The fix

A no-op catch marks the promise handled without consuming the rejection, so the Promise.race still observes it and sendAndWait rejects with the original error exactly as before. No behaviour change on any other path.

Verification

Added test/session-send-and-wait.test.ts, which holds the session.send RPC open, dispatches a session.error into the window, and asserts both that no unhandledRejection fires and that sendAndWait still rejects with the original message.

It fails on the unfixed code:

- []
+ [ Error { "message": "MCP server failed to start" } ]

 ❯ test/session-send-and-wait.test.ts:56:27

and passes with the change. Full local run:

  • npx vitest run test/ --exclude "test/e2e/**" --exclude "test/cjs-compat.test.ts"14 files, 359 passed
  • npx tsc --noEmit → clean
  • npm run lint → 0 errors (3 pre-existing warnings, all in test/shared-codegen.test.ts, none in the files touched here)

On the full npm test my environment goes from 6 failed / 371 passed on unmodified main to 5 failed / 372 passed with this change — the delta is exactly the new test. The remaining failures are test/cjs-compat.test.ts (needs a built dist/, which I didn't produce) and the test/e2e/** suites (need the real copilot CLI); I verified both fail identically on unmodified main.

Scope

One line of behaviour change plus a test. Note the other SDKs are already structurally immune — Go (go/session.go:502-505) uses a buffered errCh and Python (python/copilot/session.py:1729-1731) sets a variable plus idle_event.set() — so this brings Node in line rather than introducing a new pattern.

`sendAndWait` creates `idlePromise` and registers the event listener that
can reject it, but the first consumer is only attached by the
`Promise.race` further down -- after `await this.send(options)`, a full
JSON-RPC round trip to the CLI.

A `session.error` arriving in that window therefore rejects a promise
that has no handler yet. Node's rejection tracker runs at the following
checkpoint, well before the `session.send` response lands, and classifies
it as unhandled, which terminates the process under the default
`--unhandled-rejections=throw`.

The window is reachable from ordinary, non-fatal traffic. `session.log`
with `{ level: "error" }` emits a `session.error` carrying
`errorType: "notification"` (asserted in test/e2e/session.e2e.test.ts),
so a joined client or extension writing an error log line while another
caller is mid-`sendAndWait` is enough. MCP servers failing to start and
sub-agent errors do the same. A caller cannot defend against this: the
rejection is on the internal promise, not on the one `sendAndWait`
returns, so even correct `.catch`/`try` handling around the call does not
prevent the crash.

Attaching a no-op `catch` marks the promise handled without consuming the
rejection, so the `Promise.race` still observes it and `sendAndWait`
rejects with the original error exactly as before.

The added test drives a session whose `session.send` RPC is held open,
dispatches a `session.error` into the window, and asserts both that no
`unhandledRejection` fires and that `sendAndWait` still rejects. It fails
on the unfixed code with the error captured by the process-level listener.
@thejesh23
thejesh23 requested a review from a team as a code owner August 1, 2026 09:45
Copilot AI review requested due to automatic review settings August 1, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Prevents sendAndWait from triggering an unhandled rejection when session.error arrives during an in-flight send.

Changes:

  • Marks the internal idle promise as handled immediately.
  • Adds a regression test preserving the original rejection behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
nodejs/src/session.ts Prevents transient unhandled rejections.
nodejs/test/session-send-and-wait.test.ts Tests the mid-send error race.

@thejesh23

Copy link
Copy Markdown
Author

Correction to the verification section above: the "6 failed / 371 passed → 5 failed / 372 passed" numbers were from an incomplete local setup — I had not run npm run build (so test/cjs-compat.test.ts had no dist/ to load) nor the shared harness install that CONTRIBUTING asks for (cd test/harness && npm ci), so every test/e2e/** suite failed at import with Cannot find package 'openai'.

With both done, the full Node.js suite passes on this branch:

npm test
Test Files  86 passed (86)
     Tests  797 passed | 8 skipped (805)

The rest of the gate from .github/workflows/nodejs-sdk-tests.yml is green too:

  • npm run format:check → All matched files use Prettier code style
  • npm run lint → 0 errors (3 pre-existing warnings, all in test/shared-codegen.test.ts, none in files touched here)
  • npm run typecheck → clean
  • npm run build → clean

So there are no outstanding failures — the earlier numbers reflected my environment, not this change. Apologies for the confusion.

Observed on darwin / Node v26.5.0, against 6287d1ad.

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.

Node: sendAndWait can emit an unhandled rejection and terminate the host process when session.error arrives mid-send

2 participants