From 6ecf6f3781d12e9ade3329918ed12afaf6f01819 Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:31:18 +0800 Subject: [PATCH] fix(webui): show the idle disconnect the event channel's health knows about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q-b, reproduced first against the built client: with no turn running, killing every socket and gating new ones left the page with no visible signal at all — no banner, no alert, no 重连 text anywhere. The connection banner from Q-1 reads the session stream's phase, which stays idle ("connected") between turns, and the one long-lived link in that state — the watchEvents subscription, kept by the shell from boot — reconnected silently on its 250ms loop while telling nobody. watchEvents now reports its socket's health into a small module store (connection-health.ts): healthy on the server's acceptance of the watch, down on socket close, unregistered on unsubscribe. Reporting lives inside the transport, so every current and future watcher — the shell's and the workspace panels' — counts without any call site wiring callbacks, and the WebuiClientEventWatcher contract is unchanged. The store's aggregation rule is the load-bearing part: a watcher is degraded only once it has been healthy and then went down. A never-accepted watcher (boot in progress, or a host that never answers events) is unknown, not degraded — otherwise every page load would flash a reconnecting banner over a link that never existed. ConnectionStatus merges the two signals: a terminal stream failure still outranks everything; otherwise either the stream reconnecting or a degraded channel shows 正在重连; the region stays hidden when both are fine. The channel recovers on its own loop, so no retry affordance is added for it — the banner leaves by itself when the watch is accepted again. The browser fixture gains gateSockets(open) + dropAll(): drop every live socket and decide whether new connections may form — the between-turns outage shape, deterministic in both directions. Tests: connection-health.test.ts (store aggregation — boot quiet, healthy→down→healthy, multi-watcher, unregister stops counting — plus the transport's reporting through a fake socket, including the reconnect-accepted recovery); connection-status.test.tsx gains the merge cases (idle+degraded shows 正在重连, boot/healthy stays hidden, failed outranks degraded); connection-status.spec.mjs gains the end-to-end idle disconnect: banner appears, holds while the outage holds, clears on the watcher's own reconnect, and reappears for a second outage. --- .../webui/src/client/ConnectionStatus.tsx | 15 +- .../webui/src/client/connection-health.ts | 98 +++++++++ packages/webui/src/client/transport.ts | 15 ++ .../webui/test/unit/connection-health.test.ts | 200 ++++++++++++++++++ .../test/unit/connection-status.test.tsx | 57 ++++- release/public-source.json | 2 + test/vitest-suites.json | 1 + test/webui-browser/connection-status.spec.mjs | 42 ++++ test/webui-browser/fixture.mjs | 22 ++ 9 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 packages/webui/src/client/connection-health.ts create mode 100644 packages/webui/test/unit/connection-health.test.ts diff --git a/packages/webui/src/client/ConnectionStatus.tsx b/packages/webui/src/client/ConnectionStatus.tsx index f59b1728..423f28aa 100644 --- a/packages/webui/src/client/ConnectionStatus.tsx +++ b/packages/webui/src/client/ConnectionStatus.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; import { useSessionRuntimeState } from "./session-runtime-store.js"; +import { useWebuiEventChannelDegraded } from "./connection-health.js"; import type { WebuiStreamState } from "./stream.js"; /** @@ -88,7 +89,19 @@ export function ConnectionStatus({ retryLabel = "重试连接", }: ConnectionStatusProps): ReactElement | null { const { state } = useSessionRuntimeState(sessionId); - const connection = projectWebuiConnectionState(state.stream.phase); + // Two independent signals, merged: the selected session's stream phase (a + // turn that failed mid-flight) and the event channel's health (the + // always-on watcher, which is the only live link while the page is idle). + // Either one saying "trouble" shows the region; a terminal stream failure + // outranks a degraded-but-retrying channel. + const streamConnection = projectWebuiConnectionState(state.stream.phase); + const channelDegraded = useWebuiEventChannelDegraded(); + const connection = + streamConnection === "failed" + ? "failed" + : streamConnection === "reconnecting" || channelDegraded + ? "reconnecting" + : "connected"; if (hideWhenConnected && connection === "connected") return null; const copy = COPY[connection]; // A failure usually carries the server's own reason (`refusal`), and `status` diff --git a/packages/webui/src/client/connection-health.ts b/packages/webui/src/client/connection-health.ts new file mode 100644 index 00000000..e3d935d1 --- /dev/null +++ b/packages/webui/src/client/connection-health.ts @@ -0,0 +1,98 @@ +// Event-channel connection health — the transport's always-on watcher, +// surfaced. +// +// The WebUI holds no single "connection" to lose: every request opens its own +// WebSocket. The one socket that IS long-lived is the `watchEvents` +// subscription — the shell keeps one from boot, and while no turn is running +// it is the only live link to the server. When it drops between turns, the +// session stream's phase stays `idle` (which the connection banner reads as +// "connected"), the transport's reconnect loop retries silently every 250ms, +// and the page shows nothing at all. That silent gap is what this module +// closes: `watchEvents` reports its socket's health here, and the banner +// merges the signal with the per-session stream phase. +// +// Aggregation rule — a watcher is *degraded* only once it has been healthy +// and then went down. A watcher that has never been accepted (boot in +// progress, or a host that never answers events) is "unknown", not degraded: +// flagging it would flash 重连 at every page load, and the honest statement +// about a link that never existed is nothing, not "reconnecting". With +// several watchers mounted (the shell's plus the workspace panels'), any +// once-healthy watcher being down degrades the channel; the transport owns +// the reporting, so every current and future `watchEvents` caller is covered +// without each of them wiring callbacks. + +import { useEffect, useState } from "react"; + +interface WebuiWatcherHealth { + readonly token: number; + healthy: boolean; + everHealthy: boolean; +} + +const watchers = new Map(); +const listeners = new Set<() => void>(); +let nextToken = 0; + +function degraded(): boolean { + for (const watcher of watchers.values()) + if (watcher.everHealthy && !watcher.healthy) return true; + return false; +} + +function notifyIfChanged(before: boolean): void { + if (degraded() !== before) for (const listener of listeners) listener(); +} + +/** Registers one `watchEvents` subscription. Called by the transport only. */ +export function registerWebuiEventWatcher(): number { + nextToken += 1; + watchers.set(nextToken, { token: nextToken, healthy: false, everHealthy: false }); + return nextToken; +} + +/** The watcher's `watchEvents` request was accepted — its socket is live. */ +export function markWebuiEventWatcherHealthy(token: number): void { + const watcher = watchers.get(token); + if (!watcher) return; + const before = degraded(); + watcher.healthy = true; + watcher.everHealthy = true; + notifyIfChanged(before); +} + +/** The watcher's socket closed — it will reconnect on the transport's own loop. */ +export function markWebuiEventWatcherDown(token: number): void { + const watcher = watchers.get(token); + if (!watcher) return; + const before = degraded(); + watcher.healthy = false; + notifyIfChanged(before); +} + +/** The subscription unsubscribed (component unmounted); it no longer counts. */ +export function unregisterWebuiEventWatcher(token: number): void { + const before = degraded(); + watchers.delete(token); + notifyIfChanged(before); +} + +/** + * Whether the event channel has lost a link it previously held. Subscribe + * through the hook; read imperatively only for assertions in tests. + */ +export function isWebuiEventChannelDegraded(): boolean { + return degraded(); +} + +export function useWebuiEventChannelDegraded(): boolean { + const [value, setValue] = useState(degraded); + useEffect(() => { + setValue(degraded()); + const listener = () => setValue(degraded()); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, []); + return value; +} diff --git a/packages/webui/src/client/transport.ts b/packages/webui/src/client/transport.ts index d895f8b6..5accf8a8 100644 --- a/packages/webui/src/client/transport.ts +++ b/packages/webui/src/client/transport.ts @@ -48,6 +48,12 @@ import type { WebuiGoalPatchRequest, WebuiGoalEnabledResult, } from "../server/port.js"; +import { + markWebuiEventWatcherDown, + markWebuiEventWatcherHealthy, + registerWebuiEventWatcher, + unregisterWebuiEventWatcher, +} from "./connection-health.js"; declare const document: { readonly visibilityState: string; @@ -248,6 +254,12 @@ export function createWebuiTransport({ */ onReconnect?: () => void, ): () => void { + // This socket is the page's only long-lived link while no turn runs, so + // its health is the user's "am I still connected" signal between turns. + // Reported from inside the transport so every watcher — the shell's and + // any panel's — counts without each call site wiring callbacks; the + // store's ever-healthy rule keeps boot quiet. + const watcherToken = registerWebuiEventWatcher(); let stopped = false; let socket: WebuiSocket | undefined; let reconnectTimer: ReturnType | undefined; @@ -287,6 +299,7 @@ export function createWebuiTransport({ if (frame.requestId !== requestId) return; if (frame.kind === "error") return; if (frame.kind === "response") { + markWebuiEventWatcherHealthy(watcherToken); if (acknowledged) return; acknowledged = true; // Fires once per connection: the first ack is the initial @@ -302,6 +315,7 @@ export function createWebuiTransport({ ws.addEventListener("close", () => { if (stopped || socket !== ws) return; socket = undefined; + markWebuiEventWatcherDown(watcherToken); reconnectTimer = setTimeout(connect, 250); }); ws.addEventListener("error", () => undefined); @@ -332,6 +346,7 @@ export function createWebuiTransport({ if (typeof window !== "undefined") window.removeEventListener("online", reconnectWhenAvailable); socket?.close(); + unregisterWebuiEventWatcher(watcherToken); }; } diff --git a/packages/webui/test/unit/connection-health.test.ts b/packages/webui/test/unit/connection-health.test.ts new file mode 100644 index 00000000..6d5c5b9a --- /dev/null +++ b/packages/webui/test/unit/connection-health.test.ts @@ -0,0 +1,200 @@ +// Unit tests for the event-channel health store and the transport's +// reporting into it. +// +// Q-b's defect, reproduced first against the built client: while the page is +// idle (no turn running), killing every socket and gating new ones produced +// NO visible signal at all — the connection banner reads the session +// stream's phase, which stays `idle` ("connected") between turns, and the +// transport's 250ms silent reconnect loop told nobody. The watcher socket is +// the only long-lived link in that state, so its health is the signal. +// +// Two layers here: +// * the store's aggregation rule — degraded means "lost a link it +// previously held"; a never-accepted watcher is unknown, not degraded, +// so a booting page never flashes a reconnecting banner; +// * the transport wiring — `watchEvents` registers, marks healthy on the +// server's acceptance, marks down on socket close, and unregisters on +// unsubscribe, so every current and future watcher counts without its +// call site changing. +// +// The store is module-level (the runtime-store precedent), so every case +// unregisters in `afterEach` — a leaked once-healthy watcher would degrade +// every later suite in this worker. + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + isWebuiEventChannelDegraded, + markWebuiEventWatcherDown, + markWebuiEventWatcherHealthy, + registerWebuiEventWatcher, + unregisterWebuiEventWatcher, +} from "../../src/client/connection-health.js"; +import { createWebuiTransport } from "../../src/client/transport.js"; + +const tracked: number[] = []; + +function register(): number { + const token = registerWebuiEventWatcher(); + tracked.push(token); + return token; +} + +afterEach(() => { + for (const token of tracked.splice(0)) unregisterWebuiEventWatcher(token); + expect(isWebuiEventChannelDegraded()).toBe(false); +}); + +describe("the watcher health store", () => { + it("treats a never-accepted watcher as unknown, not degraded", () => { + // Boot shape: the socket exists but the server has not answered yet. + // Flagging this would flash 正在重连 on every page load. + register(); + expect(isWebuiEventChannelDegraded()).toBe(false); + }); + + it("degrades only after a healthy watcher goes down", () => { + const token = register(); + markWebuiEventWatcherHealthy(token); + expect(isWebuiEventChannelDegraded()).toBe(false); + markWebuiEventWatcherDown(token); + expect(isWebuiEventChannelDegraded()).toBe(true); + markWebuiEventWatcherHealthy(token); + expect(isWebuiEventChannelDegraded()).toBe(false); + }); + + it("aggregates: any once-healthy watcher down degrades the channel", () => { + // The shell keeps one watcher from boot; the workspace panels open more. + // One dying link is a degraded channel even while the others stream. + const shell = register(); + const panel = register(); + markWebuiEventWatcherHealthy(shell); + markWebuiEventWatcherHealthy(panel); + markWebuiEventWatcherDown(panel); + expect(isWebuiEventChannelDegraded()).toBe(true); + markWebuiEventWatcherHealthy(panel); + expect(isWebuiEventChannelDegraded()).toBe(false); + }); + + it("unregistering a down watcher stops it from counting", () => { + // A panel unmounting its watcher mid-outage must not pin the channel + // degraded forever — the shell's link is the one that matters. + const shell = register(); + const panel = register(); + markWebuiEventWatcherHealthy(shell); + markWebuiEventWatcherHealthy(panel); + markWebuiEventWatcherDown(panel); + expect(isWebuiEventChannelDegraded()).toBe(true); + unregisterWebuiEventWatcher(panel); + expect(isWebuiEventChannelDegraded()).toBe(false); + }); + + it("marks on unknown tokens are ignored", () => { + markWebuiEventWatcherHealthy(999_999); + markWebuiEventWatcherDown(999_999); + unregisterWebuiEventWatcher(999_999); + expect(isWebuiEventChannelDegraded()).toBe(false); + }); +}); + +/** Minimal WebuiSocket double: enough open/message/close/error for watchEvents. */ +class FakeSocket { + readonly listeners = new Map void>>(); + readonly sent: string[] = []; + closed = false; + + addEventListener(type: "open" | "message" | "error" | "close", listener: (event: { data?: unknown }) => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + emit(type: "open" | "message" | "error" | "close", event: { data?: unknown } = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.emit("close"); + } +} + +function watchEventsHarness() { + const sockets: FakeSocket[] = []; + class Socket extends FakeSocket { + constructor() { + super(); + sockets.push(this); + } + } + const transport = createWebuiTransport({ + websocketUrl: "ws://harness.invalid", + token: "t", + webSocket: Socket as unknown as new (url: string) => FakeSocket, + }); + return { transport, sockets }; +} + +/** Drives one watcher socket through acceptance. */ +function accept(socket: FakeSocket): void { + socket.emit("open"); + const request = JSON.parse(socket.sent[0] ?? "{}") as { requestId?: string }; + socket.emit("message", { + data: JSON.stringify({ protocolVersion: 1, kind: "response", requestId: request.requestId, body: { ok: true } }), + }); +} + +describe("the transport's watcher reporting", () => { + it("marks healthy on the server's acceptance and down on socket close", () => { + const { transport, sockets } = watchEventsHarness(); + const unsubscribe = transport.watchEvents(() => undefined); + expect(isWebuiEventChannelDegraded()).toBe(false); + + const socket = sockets[0]!; + accept(socket); + expect(isWebuiEventChannelDegraded()).toBe(false); + + socket.close(); + expect(isWebuiEventChannelDegraded()).toBe(true); + unsubscribe(); + }); + + it("clears the degradation when the reconnect is accepted", async () => { + const { transport, sockets } = watchEventsHarness(); + const onReconnect = vi.fn(); + const unsubscribe = transport.watchEvents(() => undefined, onReconnect); + accept(sockets[0]!); + sockets[0]!.close(); + expect(isWebuiEventChannelDegraded()).toBe(true); + + // The 250ms reconnect timer opens a fresh socket; accepting it restores + // the channel. Advance the timer by waiting it out for real — the delay + // is short, and fake timers would also have to drive the microtasks the + // acceptance path relies on. + await new Promise((resolve) => setTimeout(resolve, 320)); + const reopened = sockets.at(-1)!; + expect(reopened).not.toBe(sockets[0]!); + accept(reopened); + expect(isWebuiEventChannelDegraded()).toBe(false); + // onReconnect fires per acceptance — the first ack plus this one. + expect(onReconnect).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("unsubscribing unregisters the watcher", () => { + const { transport, sockets } = watchEventsHarness(); + const unsubscribe = transport.watchEvents(() => undefined); + accept(sockets[0]!); + sockets[0]!.close(); + expect(isWebuiEventChannelDegraded()).toBe(true); + unsubscribe(); + // The watcher no longer counts, so the channel is not degraded even + // though its socket never came back before the unsubscribe. + expect(isWebuiEventChannelDegraded()).toBe(false); + }); +}); diff --git a/packages/webui/test/unit/connection-status.test.tsx b/packages/webui/test/unit/connection-status.test.tsx index e0bebab5..39f2615e 100644 --- a/packages/webui/test/unit/connection-status.test.tsx +++ b/packages/webui/test/unit/connection-status.test.tsx @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, afterEach } from "vitest"; import { readFileSync } from "node:fs"; import { renderToStaticMarkup } from "react-dom/server"; +import { + markWebuiEventWatcherDown, + markWebuiEventWatcherHealthy, + registerWebuiEventWatcher, + unregisterWebuiEventWatcher, +} from "../../src/client/connection-health.js"; import { ConnectionStatus, projectWebuiConnectionState, @@ -175,3 +181,52 @@ describe("ConnectionStatus host layout calls", () => { expect(mount).toContain('sessionId={selectedSessionId}'); }); }); + +describe("ConnectionStatus and the idle event channel", () => { + // The health store is module-level and shared with every other suite in + // this worker, so each case's watcher is unregistered before the next. + let token: number | undefined; + afterEach(() => { + if (token !== undefined) unregisterWebuiEventWatcher(token); + token = undefined; + }); + + it("shows 正在重连 when the event channel degrades while the stream is idle", () => { + // Q-b's exact shape: no turn running (phase idle, which alone reads as + // connected) while the always-on watcher's link is down. Before the + // merge this rendered nothing at all. + token = registerWebuiEventWatcher(); + markWebuiEventWatcherHealthy(token); + markWebuiEventWatcherDown(token); + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-connection-state="reconnecting"'); + expect(markup).toContain("正在重连"); + expect(markup).toContain("连接中断,正在自动恢复"); + }); + + it("stays hidden when the channel is merely booting or healthy again", () => { + // Never-accepted (boot) is unknown, not degraded; a restored link clears + // it. Both halves keep the idle page free of a standing banner. + token = registerWebuiEventWatcher(); + expect(renderToStaticMarkup()).toBe(""); + markWebuiEventWatcherHealthy(token); + expect(renderToStaticMarkup()).toBe(""); + }); + + it("keeps a terminal stream failure outranking a degraded channel", () => { + token = registerWebuiEventWatcher(); + markWebuiEventWatcherHealthy(token); + markWebuiEventWatcherDown(token); + const failed = seed("cs-both", { phase: "refused", refusal: "连接被重置" }); + // Both signals are live; the region must name the failure (with its + // reason and its retry affordance), not the recoverable reconnect. + const markup = renderToStaticMarkup( + {}} />, + ); + expect(markup).toContain('data-connection-state="failed"'); + expect(markup).toContain("连接被重置"); + expect(markup).toContain('data-testid="webui-connection-status-retry"'); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index eff4b05e..8d41f734 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3561,6 +3561,7 @@ "packages/webui/src/client/components/WorkspacePanels.tsx", "packages/webui/src/client/components/settings-search-highlight.ts", "packages/webui/src/client/components/settings/UsageModelSettings.tsx", + "packages/webui/src/client/connection-health.ts", "packages/webui/src/client/contracts.ts", "packages/webui/src/client/global.d.ts", "packages/webui/src/client/icons.tsx", @@ -3676,6 +3677,7 @@ "packages/webui/test/unit/commands.test.ts", "packages/webui/test/unit/composer-history.test.ts", "packages/webui/test/unit/composer-intent.test.ts", + "packages/webui/test/unit/connection-health.test.ts", "packages/webui/test/unit/connection-status.test.tsx", "packages/webui/test/unit/context-breakdown.test.ts", "packages/webui/test/unit/context-usage-popover.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 19fb291f..35503db4 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -302,6 +302,7 @@ "packages/webui/test/unit/webui-workspace-canvas.test.tsx", "packages/webui/test/unit/webui-workspace-archive.test.ts", "packages/webui/test/unit/webui-error-boundary.test.tsx", + "packages/webui/test/unit/connection-health.test.ts", "packages/webui/test/unit/connection-status.test.tsx" ] } diff --git a/test/webui-browser/connection-status.spec.mjs b/test/webui-browser/connection-status.spec.mjs index 05b9c4de..8829df26 100644 --- a/test/webui-browser/connection-status.spec.mjs +++ b/test/webui-browser/connection-status.spec.mjs @@ -100,3 +100,45 @@ test("an unresumable drop shows 连接失败 with its reason, and 重试连接 d await emitStream(page, "A", { dataJson: "[DONE]" }); await expect(page.getByText("重试后恢复的回复")).toBeVisible(); }); + +test("an idle disconnect between turns shows 正在重连 and clears when the link returns", async ({ page }) => { + await openApp(page, "#session=A"); + + // Idle and healthy: no banner — the stream phase alone reads connected and + // the event channel holds its link. This negative is what makes the next + // assertion "appeared", not "was always there". + const banner = page.locator('[data-testid="webui-connection-status"]'); + await expect(banner).toHaveCount(0); + + // The idle-disconnect shape (Q-b): no turn running, every live socket + // dropped, new connections gated shut — a server that went away between + // turns. Before the fix this produced no visible signal at all (reproduced + // against the unfixed build: banner count 0, no role=alert, no 重连 text + // anywhere on the page). + await page.evaluate(() => { + window.__fixture.gateSockets(false); + window.__fixture.dropAll(); + }); + await expect(banner).toBeVisible(); + await expect(banner).toHaveAttribute("data-connection-state", "reconnecting"); + await expect(banner).toContainText("正在重连"); + + // It stays: the transport's 250ms reconnect loop keeps hitting the gate, + // and an honest signal does not blink out while the outage holds. + await page.waitForTimeout(600); + await expect(banner).toBeVisible(); + + // The server returns; the watcher's own reconnect (no manual action) is + // accepted and the banner leaves on its own. + await page.evaluate(() => window.__fixture.gateSockets(true)); + await expect(banner).toHaveCount(0); + + // And the state machine repeats: a second outage shows again, so recovery + // did not wedge the signal off. + await page.evaluate(() => { + window.__fixture.gateSockets(false); + window.__fixture.dropAll(); + }); + await expect(banner).toBeVisible(); + await expect(banner).toHaveAttribute("data-connection-state", "reconnecting"); +}); diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs index 3c09129b..ecc8b735 100644 --- a/test/webui-browser/fixture.mjs +++ b/test/webui-browser/fixture.mjs @@ -58,6 +58,9 @@ export function installFixtureTransport() { const pending = []; const delayed = []; const held = []; + // See the constructor: open = sockets behave normally; closed = new sockets + // die on arrival. + let socketGateOpen = true; // The questionnaire the pending-questionnaire poll will answer. Mutable for // the same reason `setPage` is: the composer polls `getPendingQuestionnaire` // on mount and on every session switch, so a test that wants to stage a @@ -100,6 +103,19 @@ export function installFixtureTransport() { this.listeners = new Map(); this.closed = false; this.request = undefined; + // The gate simulates a dead server for NEW connections: the socket + // errors and closes without ever opening, which is exactly what a + // real WebSocket to a downed backend does. Existing sockets are + // unaffected until `dropAll()` closes them — that pair is what an + // "idle disconnect" is: everything was healthy, then the server went + // away between turns. + if (!socketGateOpen) { + queueMicrotask(() => { + this.emit("error", {}); + this.close(); + }); + return; + } sockets.add(this); queueMicrotask(() => this.emit("open", {})); } @@ -212,6 +228,12 @@ export function installFixtureTransport() { // is inert on purpose, so a staged questionnaire stays pending until the // test answers, dismisses, or replaces it. setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; }, + // Kill every live socket (the "server went away" moment) and/or decide + // whether new connections may form. Between a dropAll() and reopening + // the gate, the client is fully disconnected — with no turn running, + // which is the idle-disconnect shape. + gateSockets(open) { socketGateOpen = open; }, + dropAll() { for (const socket of [...sockets]) if (!socket.closed) socket.close(); }, resolve(operation, condition, result) { const index = pending.findIndex((entry) => entry.operation === operation && matches(entry.body, condition)); if (index < 0) throw new Error(`No pending fixture request: ${operation} ${JSON.stringify(condition)}`);