fix(tui): retire the disconnect token on a server switch, not just clear

Copilot round 3 (PR #2039): clearing the banner when `selectedServer`
changes is not enough. Start disconnecting A, switch A → B → A, and by
the time the original request rejects both guards match again — the
attempt token is unchanged and the selected server is A once more — so
the stale failure lands on the freshly re-selected A. Bump the token on
every switch, so a round trip invalidates prior disconnects the same way
a one-way switch does.

New test drives exactly that interleaving; it fails without the bump
(the one-way test does not, which is why this needed its own).

Also attaches the before/after TUI screenshots the review asked for —
the review was right that "no UI change" stopped being true once the
banner was added — and corrects the PR description accordingly.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 22:11:40 -04:00
commit 85e3215534
2 changed files with 29 additions and 1 deletions

View file

@ -722,6 +722,30 @@ describe("App (foundation)", () => {
expect(r.lastFrame() ?? "").not.toContain("stale-alpha-failure");
});
it("drops a stale disconnect rejection across an A → B → A round trip", async () => {
// The server-name check alone passes here: by the time the rejection
// lands, alpha is selected again. Only retiring the attempt token on
// every switch catches it.
h.ctrl.status = "connected";
let rejectDisconnect: (err: Error) => void = () => {};
h.disconnect.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectDisconnect = reject;
}),
);
const r = await mount(stdioServer());
r.stdin.write("d"); // alpha's disconnect starts, and hangs
await tick();
await press(r, [DOWN]); // alpha -> beta
await expectFrame(r, "b.js");
await press(r, [UP]); // beta -> alpha again
await expectFrame(r, "s.js");
rejectDisconnect(new Error("round-trip-failure"));
await waitUntil(() => (r.lastFrame() ?? "").includes("round-trip-failure"));
expect(r.lastFrame() ?? "").not.toContain("round-trip-failure");
});
it("switches tabs via accelerator keys", async () => {
const r = await mount(stdioServer());
await press(r, ["t"]); // tools tab (server is auto-selected)

View file

@ -446,7 +446,11 @@ function App({
setOauthStatus("idle");
setOauthMessage(null);
// The header banner is server-scoped, so a failure from the server we just
// left must not be read as this one's.
// left must not be read as this one's. Clearing is not enough: a switch
// away and back (A → B → A) would leave both the attempt token and the
// server name matching again, so the stale rejection would land on the
// re-selected A. Retiring the token on every switch is what closes that.
disconnectAttemptRef.current++;
setDisconnectError(null);
const stepUp = pendingStepUpRef.current;
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {