fix: settle the deliberately in-flight tool calls in the progress tests - #1958
Open
cliffhall wants to merge 4 commits into
Open
fix: settle the deliberately in-flight tool calls in the progress tests#1958cliffhall wants to merge 4 commits into
cliffhall wants to merge 4 commits into
Conversation
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
There was a problem hiding this comment.
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.
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
There was a problem hiding this comment.
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 laterawait 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
There was a problem hiding this comment.
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
ConnectionClosedfailure, not only one caused by the explicit teardown as the helper documents. If the transport closes unexpectedly after emitting the expected progress notifications but beforedisconnect()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 beforedisconnect()), and only absorbConnectionClosedin 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
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #1947
Cause
Not a teardown race in
InspectorClient.disconnect()— that path alreadytry/catchesclient.close().Two tests in
inspectorClient.test.tsfireclient.callTool(...)without holding the promise (:2224and: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 withConnection 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, abortingciatcoverageand silently skippingverify: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 afterdisconnect()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/webintegration file standalone: 140 passed, 0 errors (was 140 passed / 2 errors).npm run cifrom 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-promiseswould 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.