chore(web): address Copilot review on #2130

Three findings, all valid on verification.

`onToggleConnection` and `onDisconnect` do not own every rejection, so a
bare `void` on them turns an ordinary Connect/Disconnect click into a
global unhandled rejection with nothing shown to the user. The toggle has
three escape paths outside its own try/catch — awaiting
`initialConfigSettledRef`, constructing the client via
`setupClientForServer`, and the already-connected disconnect branch,
whose `try/finally` runs `finalizeExplicitDisconnect()` and then lets a
failed transport close propagate — and `onDisconnect` is that same
`try/finally` on its own. Both are now terminated with a `.catch` that
toasts the failure.

The blanket comment claiming all seven wrappers own their failures was
wrong. The five command handlers (`onCallTool`, `onGetPrompt`,
`onReadResource`, `onCancelTask`, `onOpenApp`) do each end in a `catch`,
so `void` stays correct for them; the comment now splits the two groups
and says which is which.

Adds the two tests the review asked for, covering the rejected-close path
through both the toggle and the explicit Disconnect. Each fails against
the previous `void` code, so they pin the fix rather than merely passing.
The `InspectorView` double grows a `disconnect` button, since the
standalone `onDisconnect` was otherwise unreachable from it.

Also restores the `PropOverrides` doc example, which the scripted
regrouping had rewritten inside its own JSDoc into a triple-nested shape
that would not type-check if copied.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 00:18:03 -04:00
commit 37add967cd

View file

@ -499,6 +499,11 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
<button onClick={() => props.connection.onToggleConnection("B")}>
connect-b
</button>
{/* The header's explicit Disconnect, which routes to the standalone
`onDisconnect` rather than through the toggle. */}
<button onClick={() => props.connection.onDisconnect()}>
disconnect
</button>
<button onClick={() => props.servers.onConnectionInfo("A")}>
open-connection-info
</button>
@ -3878,6 +3883,70 @@ describe("App background command rejections (#2049)", () => {
rejections.stop();
}
});
// `onToggleConnection` and `onDisconnect` both close a live session with
// `try { await disconnect() } finally { finalizeExplicitDisconnect() }` — a
// `finally`, not a `catch` — so a transport that fails to close rejects out
// of the handler. App discards neither: both are terminated with a `.catch`
// that toasts, because a bare `void` would turn an ordinary click into a
// global unhandled rejection with nothing shown to the user (#2130 review).
const CLOSE_FAILURE = new Error("close boom");
it("toasts a failed transport close from the connection toggle instead of leaking it", async () => {
const rejections = captureUnhandledRejections();
try {
const user = await connect();
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
// Second click on the same, now-connected server takes the disconnect
// branch of the toggle.
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to change the connection",
message: "close boom",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
it("toasts a failed transport close from the explicit Disconnect instead of leaking it", async () => {
const rejections = captureUnhandledRejections();
try {
const user = await connect();
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
await user.click(screen.getByText("disconnect"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to disconnect",
message: "close boom",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
});
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */