Skip to content

fix(webui): surface the idle disconnect through the event channel's health (Q-b) - #26

Merged
stevenjj33 merged 1 commit into
webuifrom
fix/webui-idle-disconnect-visibility
Oct 5, 2026
Merged

stevenjj33 merged 1 commit into
webuifrom
fix/webui-idle-disconnect-visibility

Conversation

@stevenjj33

Copy link
Copy Markdown
Collaborator

Change

Roadmap Q-b: 空闲时断线,页面什么都不显示. Reproduced first against the built client, then fixed.

Reproduction (pre-fix, built client, real Chromium): with the app idle on #session=A (no turn running), every live socket was dropped and new connections gated shut via the fixture's new gateSockets(false) + dropAll(). After 1.5s: connection banner count 0, role=alert count 0, and no 重连 / 连接失败 text anywhere on the page — the disconnect was completely invisible.

Why: the WebUI holds no single connection — every request opens its own WebSocket, and the Q-1 banner reads the session stream's phase, which stays idle (projected "connected") between turns. The only long-lived link in the idle state is the watchEvents subscription the shell keeps from boot; when it drops, the transport's own 250ms reconnect loop retries silently and tells nobody.

Fix — three small pieces:

  1. connection-health.ts (new): a module store for the event channel's health. 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, so a page load never flashes a reconnecting banner over a link that never existed. Aggregation: any once-healthy watcher being down degrades the channel; a watcher that unsubscribes stops counting.
  2. transport.ts watchEvents: registers on subscribe, marks healthy when the server accepts the watch (the same frame onReconnect keys on), marks down on socket close, unregisters on unsubscribe. Reporting lives inside the transport, so all three current watchers (shell + two workspace panels) — and any future one — are covered with zero call-site changes and zero WebuiClientEventWatcher contract changes.
  3. ConnectionStatus: merges the two signals — a terminal stream failed still outranks everything; otherwise the stream's reconnecting or a degraded channel shows 正在重连 (「连接中断,正在自动恢复」); both fine → hidden. The channel recovers on its own 250ms loop, so no manual retry affordance is added for it — verified that the banner leaves by itself when the link returns.

Post-fix verification (same environment as the repro): idle + healthy → banner count 0; drop+gate → banner reconnecting「正在重连 · 连接中断,正在自动恢复」 and it holds while the outage holds; gate reopened → banner clears by itself once the watcher's reconnect is accepted; a second outage shows the banner again (the state machine does not wedge off after recovery).

Validation

  • pnpm test:webui — 82 files / 1647 tests passed, including new connection-health.test.ts (8: store aggregation — boot stays quiet, healthy→down→healthy, multi-watcher any-down, unregister stops counting, unknown tokens ignored; transport reporting through a fake socket — accept/close, reconnect-accepted recovery with onReconnect per acceptance, unsubscribe unregisters) and 3 new merge cases in connection-status.test.tsx (idle+degraded → 正在重连; boot/healthy → hidden; failed outranks degraded, reason and retry button intact).
  • npx playwright test — 86/86 passed, including the new end-to-end idle-disconnect case in connection-status.spec.mjs (appear → hold → auto-clear → reappear).
  • pnpm typecheck:webui-full, pnpm check:source (inventory regenerated), pnpm verify — passed, 20 gates on darwin.
  • Fixture change is additive: gateSockets(open) + dropAll(); existing specs untouched (default gate open = previous behaviour).

NOT RUN / boundaries

  • The gated-outage simulation runs against the fixture transport, not a real server process being killed; the shape it drives (sockets dead, new connections refused, then the backend returns) is the same wire-level behaviour. No live-service acceptance claimed.
  • Windows contract left to CI.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes (if any): none.

Shared-source port: not needed.

… about

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.
@stevenjj33 stevenjj33 added the bug Something isn't working label Oct 5, 2026
@stevenjj33
stevenjj33 merged commit 2f5c1c4 into webui Oct 5, 2026
9 checks passed
@stevenjj33
stevenjj33 deleted the fix/webui-idle-disconnect-visibility branch October 5, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant