Skip to content

fix(ws): WebSocket durable-delivery resilience — reconnect, per-client scoping, ACK re-drive#149

Open
aakhter wants to merge 6 commits into
Ark0N:masterfrom
aakhter:cod-165-ws-resilience
Open

fix(ws): WebSocket durable-delivery resilience — reconnect, per-client scoping, ACK re-drive#149
aakhter wants to merge 6 commits into
Ark0N:masterfrom
aakhter:cod-165-ws-resilience

Conversation

@aakhter

@aakhter aakhter commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Six bug-fix commits hardening the WebSocket layer against reconnect races, spurious disconnects, stale ACKs, and redundant DOM work. Each is self-contained and builds cleanly on the previous.

COD-133 — Fix header WS status indicator + typing lag

Restores the connection-state chip (broken since v1.1.15 merge) and removes a keydown handler that was blocking the UI thread on every keystroke.

COD-134 — Fix WS flap loop + reconnect resilience

  • Fixes undefined onopen call crash on rapid connect/disconnect cycles that caused an infinite flap loop.
  • Adds _wsLastRecvAt / _wsState tracking so reconnect back-off is state-machine driven rather than timer-soup.
  • Exports CodemanWsReconnect / planWsReconnect from constants.js for testability.

COD-135 — Re-drive lost input ACK on a live WebSocket

When an input frame is sent but the ACK never arrives (connection drop between send and response), the client now re-drives the pending frame on the next live connection rather than silently dropping it. Closes the durable-delivery gap without requiring a page reload.

COD-137 — Scope WS per-session limit by clientId

Upstream's per-session connection cap (max 1 WS per session) was keyed on sessionId alone, causing spurious 4008 closes when a client reconnected before the server had cleaned up the old socket. New WsConnectionRegistry (src/web/ws-connection-registry.ts) scopes the limit by (sessionId, clientId) pair so reconnects are never rejected.

COD-136 — Skip redundant connection-indicator DOM writes on hot input path

_updateConnectionIndicator() was called on every received message even when the indicator state hadn't changed. Added a dirty-check; saves several classList mutations per second on busy sessions.

COD-144 — Flush queued SSE output on empty buffer-load

When a new shell session starts with an empty terminal buffer, the SSE flush was gated behind a non-empty buffer check, causing the first few output lines to be silently dropped until the next poll cycle. Removed the gate; queued output now flushes immediately on an empty-buffer load.

Test plan

  • test/connection-indicator.test.ts (new, 24 tests)
  • test/input-send-order.test.ts (new, 7 tests)
  • test/ws-reconnect-plan.test.ts (new, 6 tests)
  • test/ws-connection-registry.test.ts (new, 6 tests)
  • test/routes/ws-routes.test.ts (existing + 23 additive tests = 26 total)
  • test/terminal-buffer-flush.test.ts (new, 5 tests)
  • tsc --noEmit: 0 errors
  • npm run build: clean
  • Prettier: clean

aakhter added 6 commits July 10, 2026 14:36
The upstream v1.1.15 merge spliced upstream's transport-object indicator
body onto local's _connectionStatus-based _updateConnectionIndicator()
without defining `transport`, so every transport.* reference threw
ReferenceError on any queued state. That hid the "WS" status and, because
_reliableSend() updates the indicator before _drainSession(), made every
keystroke skip immediate delivery (input flushed only on the 2s sweep =
typing lag).

- Rewrite _updateConnectionIndicator() to show the terminal WebSocket
  transport from _wsState (WS / HTTP / WS… / Offline), falling back to the
  SSE _connectionStatus only on the idle dashboard.
- Only annotate a backlog (· N queued) above 4 bytes so normal typing no
  longer flickers "sending 1B" on each key press.
- test/connection-indicator.test.ts (new): transport labels, the >4B
  threshold, an exhaustive never-throws guard for the ReferenceError, and
  the _reliableSend -> _drainSession invariant (typing-lag guard).
- test/input-send-order.test.ts: reconcile to local's durable input layer
  (the prior coalescing-fallback tests had been failing since 1255e28).
…ce + logging

Root cause of the WS->HTTP->WS flapping: the v1.1.15 input-delivery merge left a
call to the now-undefined _flushHttpFallbackQueuesViaWs() in ws.onopen, so every
(re)connect threw a TypeError BEFORE _onWsReady() ran -- durable input was never
re-flushed over the fresh socket, the 2s redeliver sweep then saw stale unacked
frames and force-closed the socket, reconnect, throw again: a self-sustaining
flap loop. Remove the dead call (_onWsReady, 10 lines below, is its replacement).

Resilience + observability:
- Pure CodemanWsReconnect.plan(code, attempt) (constants.js, TDD, 6 tests):
  <4004 -> fast reconnect (immediate jittered first retry, faster backoff);
  4008/unknown->=4004 -> bounded retry-fallback (HTTP no longer sticks until a
  tab switch); 4004/4009 -> give up (session gone). Wired into onclose.
- Redeliver sweep force-closes only a SILENT socket (no recent recv), not one
  actively delivering output/ACKs -- stops self-inflicted flaps while typing.
- Client logs WS close code/reason to crash-diag; server logs [ws]
  open/close/terminate/4008 (console -> journald; Fastify runs logger:false).

Verified: 6/6 unit, tsc 0, frontend-syntax + prettier clean, build; beta WS
reaches connected with zero console errors (onopen TypeError gone),
_wsLastRecvAt tracked, server [ws] lines emit.
… gap)

A reliable-input frame could be stranded forever if its server ACK
({t:'ia',seq}) was lost while the WebSocket kept delivering other output.
_drainSession's WS fast path skips records with sentAt!==0, and after
COD-134 the sweep only force-closes a *silent* socket -- so a lost ACK on
an otherwise-live socket (stale && !silent) was never re-sent.

_redeliverSweep now, for an active-WS session whose oldest unacked frame
is stale but the socket is NOT silent, resets sentAt=0 on every stale
unacked frame and lets the existing _drainSession re-drive them over the
live socket (server dedups by seq). The stale && silent force-close
remains the fallback for a genuinely half-open socket. Restores the
exactly-once recovery guarantee without reintroducing the flap.

Tests: new failing-first COD-135 cases in test/input-send-order.test.ts
(re-drive on live socket; leave not-yet-stale alone; keep stale+silent
force-close). 18/18 across input-send-order + reliable-input-dedup +
ws-reconnect-plan; tsc 0, frontend-syntax, build all clean.
…reconnect)

MAX_WS_PER_SESSION was gated by a bare Map<sessionId,number> counter,
incremented on upgrade and decremented only on the old socket's async
close. A client that dropped and immediately reconnected could land its
new upgrade before the old socket's close fired, briefly over-counting and
tripping a spurious 4008 (-> HTTP fallback). The limit also counted raw
sockets, so a reconnecting client consumed a new slot instead of its own.

Replace the counter with WsConnectionRegistry (new pure, unit-tested module)
that tracks live sockets per session keyed by clientId. A same-cid upgrade
SUPERSEDES its own socket (evicts the stale one with close 4010, reuses the
slot, no net count change) -> a reconnect can never be rejected by the cap.
The reliable-input protocol (shouldApplyInput(cid,seq)) already assumes one
logical client per cid per session, so same-cid eviction is principled, not
a regression of multi-tab (which already collides on seq). Slots are freed
EAGERLY on error/terminate, not just async close; close is identity-matched
so a superseded socket's late close is a no-op. cid-less upgrades are
admitted anonymously up to the cap and never evict (backward-compat).
Client sends cid on the WS upgrade URL (?cid=, encoded, omitted if absent).

Tests: ws-connection-registry.test.ts (reconnect-reclaim at cap, rejects
N+1th distinct, eager-terminate frees slot, cid-less up-to-limit + no-evict,
late-close-no-evict, per-session isolation) + route integration in
ws-routes.test.ts (real upgrade through the cap). 45/45 across registry +
ws-routes + input-send-order + ws-reconnect-plan; tsc 0, build, prettier,
frontend-syntax clean.
_updateConnectionIndicator() ran on every keystroke (_reliableSend) and
every ACK (_ackDelivery), unconditionally writing display/className/
textContent/title. During fast typing the rendered output is usually
identical between calls, so those were wasted main-thread DOM writes.

Extracted the branch logic into a pure DOM-free _computeConnectionDescriptor()
returning { display, dotClass, text, title } (every branch/string preserved
verbatim; hidden state normalizes the three non-display fields to '' so the
compare is well-defined). _updateConnectionIndicator() now computes the
descriptor, compares all four fields against a cached _lastIndicatorDescriptor,
and early-returns when unchanged — otherwise caches and writes the DOM exactly
as before (display always; dotClass/text/title only when shown). First call
renders (cache starts null). Perf only, no behavior change.

Tests: test/connection-indicator.test.ts — 9 descriptor cases pinning the
exact strings per state + 4 skip cases (first call writes; two identical calls
write DOM once via counting setters; state change and hidden->shown re-render).
31/31 with input-send-order regression; build, frontend-syntax, prettier clean.
…int immediately

A freshly created shell session rendered blank until a tab-switch. selectSession()
fetches the terminal buffer, but for a just-started shell that fetch resolves before
the PTY emits its prompt, so the buffer is empty; the prompt then arrives as a live
SSE event queued during the load and _finishBufferLoad() discarded it. The discard is
correct for an established session (its fetched buffer already contains that output),
but harmful when the load painted nothing.

_finishBufferLoad(owner, { flushQueued }) now REPLAYS the queued events through
batchTerminalWrite (after _isLoadingBuffer is cleared, so they write through, not
re-queue) instead of discarding. selectSession passes flushQueued only in the empty
branch (no fresh buffer + no cache), so the established-session de-dup path is
unchanged. TDD: test/terminal-buffer-flush.test.ts exercises the real begin/finish
mixin (vm-harness, no jsdom).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant