reconnect¶
Provenance¶
- Source:
.spec/spexcode/spec-dashboard/dashboard-ui/session-console/terminal-io/reconnect/spec.md - Source SHA-256:
22817ca0102cc6efc460dfaf25356d203a73ffb0eefbe3af147c04b92d9f2aaf
reconnect¶
A live terminal pane must never need a manual page refresh to come back. The [[live-view]] backend fix
removed the cause that used to freeze a pane — a per-session bridge dying under a still-open socket — so
bridge churn no longer closes the socket at all. What remains are the two cases the backend cannot mask:
a backend process restart (the zero-downtime supervisor reload), which genuinely drops every socket —
and a link that dies silently. The second is the treacherous one: an idle terminal socket crossing a
NAT / tunnel / reverse-proxy (the public gateway path) can be torn down in the middle with no close event
ever reaching the browser — a half-open connection whose readyState still says OPEN. The pane then
looks alive but is deaf: frames stop arriving, a resize sent on it vanishes, and (before this contract)
nothing ever noticed — the frozen-terminal-until-manual-refresh bug, exactly what reconnection exists to
prevent. The same silence can strand a new socket in CONNECTING or a server-initiated close handshake in
CLOSING; a browser's eventual handshake timeout is not this module's liveness guarantee.
So a dead link must be detectable from traffic alone, which makes liveness a bidirectional heartbeat
contract, same shape as the board stream's ([[dashboard-shell]]). On one fixed cadence (10s), the server sends
both a WebSocket protocol ping and a small text ping. The browser network stack answers the protocol pong
without application JavaScript; the text ping reaches JavaScript and re-arms its inbound dead-man switch. Each
side therefore holds the other to the promise it can actually observe: the client gives every current socket it
did not intentionally close — CONNECTING, OPEN, and CLOSING — one 2.5× cadence silence deadline, while the
server forcibly removes a viewer that produces no protocol pong inside that same window. Neither client
handshake borrows an eventual platform timeout. Server expiry owns cleanup directly rather than waiting for a
transport close event that a half-open link may never deliver, so [[live-view]] cannot retain a ghost tmux
client or size claim. The
cadence is the contract's one primitive number, and on the client it lives in ONE place: the shared
heartbeat module (heartbeat.js) that the board SSE stream reads too — a single constant for the whole
client, held equal to the server's ping cadences by test, the dead window derived from it, never a
free-standing magic number or a per-channel copy. Detection itself is likewise the shared module's
dead-man's switch — event-driven, not a polling loop: one one-shot timer armed when a socket is constructed
and re-armed when it opens and by every inbound message — so on a healthy link nothing ever wakes, and the
switch fires exactly once at the current phase's silence deadline. No separate
recovery path: detection is the only new act; a presumed-dead drop reopens, backs off, and announces itself
exactly like a genuine drop.
Heartbeat deployment is rolling-compatible. A backend reload may meet a page still running the previous hashed frontend bundle, so server-side liveness can never require a newly-added JavaScript reply: doing that turns every old open tab into a deterministic 25-second reconnect loop until the human refreshes. Protocol pong is the stable browser capability across bundles. The current client still answers each text ping with a text pong, and the server still accepts it, so the opposite rolling order (new frontend against the immediately previous backend) remains live too. This overlap is transport-version compatibility, not a second product heartbeat or a requirement for atomic frontend/backend deployment.
The socket reopens itself:
on an unexpected close it retries with capped, escalating backoff, indefinitely, while surfacing a
visible "reconnecting…" state — the pane tracks a small connecting | open | reconnecting health and
shows it in a corner caption, so recovery is loud, never a silently dead pane. A connection that
stays healthy a few seconds resets the backoff; a flapping server escalates to the cap instead of
hammering it. The single intentional close — the pane unmounting, when a session goes offline and the
header swaps in the relaunch panel — stops reopening for good.
Recovery is a stateless reopen, not a resync or sequence protocol. The endpoint is addressed by a stable session id and the backend holds all state in tmux, so a reopen is answered exactly like a first connect: a single coherent full repaint onto a freshly reset screen. There is nothing on the client to replay or reconcile — which is why reconnection here is a thin transport concern, not a correctness mechanism, and why it does not reintroduce the snapshot-splice scramble [[live-view]] warns against.
The reconnect lives in a small, framework-agnostic helper that the terminal wires its open / message / state callbacks into; its WebSocket implementation and timers are injectable, so the reconnect state machine — backoff schedule, stable-vs-flapping reset, intentional-close suppression, the dead-man switch's presumed-dead drop in every socket phase, state transitions — is verifiable headlessly, with no browser and no real network.