Skip to content

fix: settle the deliberately in-flight tool calls in the progress tests - #1958

Open
cliffhall wants to merge 4 commits into
v2/mainfrom
v2/fix/unhandled-rejections-progress-tests
Open

fix: settle the deliberately in-flight tool calls in the progress tests#1958
cliffhall wants to merge 4 commits into
v2/mainfrom
v2/fix/unhandled-rejections-progress-tests

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #1947

Cause

Not a teardown race in InspectorClient.disconnect() — that path already try/catches client.close().

Two tests in inspectorClient.test.ts fire client.callTool(...) without holding the promise (:2224 and :2352), deliberately: they assert on the progress notifications the call streams while it is still in flight. disconnect() then closes the SDK client, which rejects every pending request with Connection closed. With nothing holding the promise, that lands as an unhandled rejection — which vitest counts as a run error, so the run exits 1 with every test passing, aborting ci at coverage and silently skipping verify:build-gate, smoke, and Storybook.

This also explains the environment sensitivity the issue flagged as worth establishing: on a slower machine the tool call completes before the assertions finish, so there is no pending request left for the close to reject and the run is green. Nothing platform-specific about it — just a timing window this machine loses and the GitHub runners happen to win. It is latent on CI, not absent.

Fix

A settleInFlight() helper that attaches the handler at call time — not after the assertions, which would leave a window the rejection can escape through — and returns a promise each test awaits after disconnect() so teardown stays ordered. It asserts neither outcome, since the call may legitimately complete before the teardown or reject with it; asserting either way would reintroduce the same race as a flake.

Per the issue's note, this is not vitest-config-level swallowing: the rejection is handled at the exact call that produced it, and the runtime path is unchanged.

Verification

  • clients/web integration file standalone: 140 passed, 0 errors (was 140 passed / 2 errors).
  • Full npm run ci from the root: exit 0, all five stages reached — the chain is && and the final Storybook stage ran (110 files / 466 tests). That is the first clean end-to-end run of the gate.

No UI or TUI change, so no screenshots.

Note

Nothing structurally prevents the next fire-and-forget call from reintroducing this. @typescript-eslint/no-floating-promises would catch the class, but it needs type-aware linting turned on for the test globs — worth a separate issue rather than folding into this fix.

Two progress tests fire `client.callTool()` without holding the promise so
they can assert on the notifications the call streams while it is still in
flight. `disconnect()` then closes the SDK client, which rejects every
pending request with "Connection closed" — with nothing holding the
promise that lands as an unhandled rejection, which vitest counts as a run
error and fails `npm run ci` at `coverage` even though all tests pass.

Add a `settleInFlight()` helper that attaches the handler at call time (not
after the assertions, which would leave a window the rejection can escape
through) and returns a promise the test awaits after `disconnect()` so
teardown stays ordered. Neither outcome is asserted: the call may
legitimately complete before the teardown or reject with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Handles deliberately in-flight tool calls in progress integration tests to prevent unhandled rejections during disconnect.

Changes:

  • Adds an in-flight promise settlement helper.
  • Awaits pending calls after disconnect in two progress tests.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/web/src/test/integration/mcp/inspectorClient.test.ts Outdated
Review feedback: the blanket rejection handler also converted a genuine
`callTool` failure into a pass. That matters here because these tests
assert on the progress notifications, which arrive before the result — so
a call that emitted all three progress events and then failed would have
satisfied every assertion and been swallowed.

Narrow the handler to absorb only `SdkError` / `CONNECTION_CLOSED` and
re-throw anything else. The handler is still attached at call time, so an
unexpected rejection stays *handled* and surfaces as an ordinary test
failure at the `await inFlight` after `disconnect()` rather than as another
unhandled rejection. Fulfillment is still accepted: whether the call beats
the teardown is a race, so asserting either outcome would reintroduce the
original bug as a flake.

Verified the narrowed predicate is actually exercised rather than dead
code — both call sites reject with SdkError/CONNECTION_CLOSED on every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Suppressed comments (1)

clients/web/src/test/integration/mcp/inspectorClient.test.ts:132

  • Re-throwing here rejects the new promise returned by then; that promise has no rejection handler until the later await inFlight. If an unexpected tool failure occurs while the test is still waiting for progress, Node can report it as an unhandled rejection—the same run-level failure this helper is intended to prevent. Attach an immediate observer to the derived promise while still returning it, so the later await continues to fail the test normally.
      throw error;

…unhandled

Review feedback (round 2). Narrowing the handler in the previous commit
fixed the silent-pass but reintroduced #1947's own failure class on the
unexpected path: `then` returns a *derived* promise, and the re-throw
rejects that one rather than `call`. The caller doesn't await it until
after `disconnect()`, so a rejection arriving while the test is still
waiting on progress notifications sits unobserved for seconds and Node
reports it as an unhandled rejection.

Attach an observer to the derived promise the moment it exists. Nothing is
swallowed — it stays rejected, so the caller's `await` still fails the test.

Verified both directions with a probe rejecting with a non-ConnectionClosed
error and awaiting after a 1.5s gap: without the observer the run reports
"Unhandled Rejection" + "Errors 1 error"; with it, the test fails and the
run stays clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Suppressed comments (1)

clients/web/src/test/integration/mcp/inspectorClient.test.ts:127

  • This accepts every ConnectionClosed failure, not only one caused by the explicit teardown as the helper documents. If the transport closes unexpectedly after emitting the expected progress notifications but before disconnect() starts, all assertions still pass and this branch hides the regression. Track whether teardown has begun (for example, pass a teardown-state predicate/token into the helper and set it immediately before disconnect()), and only absorb ConnectionClosed in that state; otherwise rethrow it like any other call failure.
      if (
        error instanceof SdkError &&
        error.code === SdkErrorCode.ConnectionClosed
      ) {
        return;

Review feedback (round 3). The predicate absorbed *any* CONNECTION_CLOSED,
while the doc comment claimed it absorbed only the teardown's — so a
transport dropping on its own after the progress notifications but before
`disconnect()` would have been swallowed. These tests assert only on the
notifications, so that regression would have passed green.

Gate the absorption on a teardown flag owned by the helper, which now
returns an `InFlightCall` exposing `disconnectAndSettle()`. Keeping the flag
inside means it can't be raised too early (which would silently reopen the
hole) and the settle can't be forgotten; call sites also collapse from two
statements to one.

Verified: a pre-teardown ConnectionClosed now fails the test and stays
observed (no run-level error), while a teardown-time one is still absorbed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

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

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npm run ci cannot pass: two pre-existing unhandled rejections in inspectorClient.test.ts abort the gate at coverage

2 participants