fix(client): ws.onclose/onerror disconnect recovery → connection-lost overlay (#93) #116

Merged
bosun merged 1 commit from i/93-ws-onclose-handler into main 2026-06-22 23:24:46 +02:00
Owner

What

Adds the missing ws.onclose/ws.onerror handlers to net.ts. Before this, connect() bound only onopen + onmessage, so a socket drop without a protocol frame (wifi loss, server restart, backgrounded tab, deliberate raw close) left the client frozen on the last 'playing' frame forever — its dead socket never hears the server's eventual matchEnd. Operator-confirmed in the 2026-06-22 playtest.

This is Option A of the #93 fork (operator-ratified A-now + B-follow-up). Closes #93.

Decision tree (why A, not B)

The pre-flight surfaced a fix-shape fork (full grounding in #93 #issuecomment-72436):

  • Option A (this PR)onclose/onerror → accidental-drop → brief "connection lost" overlay → backToYard(). Removes the frozen-client symptom under every drop scenario, small + low-risk.
  • Option B (deferred → #115) — A + attempt reconnect-via-resume within the server's 25s grace before falling back to A's return-to-title. Better UX for transient blips, but requires a net.ts restructure (const ws → reassignable + re-bindable handlers + attempt-accounting).

Operator's criterion was wasted-work ("(A)… if it won't mean too much wasted work; otherwise (B) straight"). Implementer assessment: LOW waste — A's handler registration, the deliberate/closed discrimination, the overlay, and backToYard() as B's terminal fallback all transfer cleanly; only the onclose routing-body (few lines) + one test's assertion shift would rework. B's socket-restructure is net-new either order. → A now, B as #115.

When B would be the right answer instead: if the operator wanted seamless transient-blip resume as the headline behaviour (mobile networks drop often — the operator's primary device), B-straight would avoid the two-PR cadence. The ratify chose return-to-title as the acceptable baseline.

Design calls

  • Discriminator = explicit deliberate flag, not LS_TOKEN-presence. The pre-flight noted close() strips LS_TOKEN first, which would discriminate — but only post-matchStart (the token is set then). A deliberate flag set in close() before ws.close() is phase-independent: a deliberate cancel during lobby (no token yet) is correctly suppressed too. Strictly more robust; folds fork-option (c) in.
  • One-shot handleDrop — a dropped socket fires onerror and onclose; the closed guard makes recovery fire once. main.ts's onConnectionLost is independently idempotent + session-scoped (screen === 'connected').
  • Overlay mirrors #87's abortOverlayUntil exactly (timestamp armed → loop clears → single backToYard() nav point) rather than inventing a new mechanism. drawConnectionLost is deliberately distinct from drawDisconnectBanner (that one is the opponent dropping during grace; this is our socket dropping).

Scope refinement (survivor side is NOT in scope)

The operator observed the other session "froze ~1 min." Source check: the survivor side is already handledrender.ts:1764 paints drawDisconnectBanner while opponentDisconnected is set; the perceived freeze is the 25s grace wait, not a missing handler. So #93 is purely the leaver-side ws.onclose gap. (Any survivor-side grace-window-UX polish is a separate concern — flagged, not claimed.)

Acceptance criteria

  1. ws.onclose transitions to a graceful state (no more frozen-on-'playing') — handleDroponConnectionLost → overlay → backToYard().
  2. ws.onerror routes to the same path (both bound to handleDrop).
  3. ⏭️ Deferred to #115 — reconnect-via-resume within the grace window (Option B). The 25s window + existing resume path are verified there.
  4. After explicit close OR the overlay timeout, the client returns to title via backToYard().
  5. Harness extended with a ws-drop-during-game transition — versus.spec.ts #93 test via the #92 substrate's srv.close().
  6. No regression on the normal end-of-match flow — full-flow test asserts a normal matchEnd does NOT arm connection-lost; verified at source that p.conn.Close() (main.go:111) is in readPump's defer (fires only when the client already left), so the socket stays open for rematch and onclose never fires on a normal game-over.

Mutation experiment (discriminating)

Removed the ws.onclose = handleDrop; ws.onerror = handleDrop; binding:

  • #93 test redsconnectionLost never arms, client never leaves 'playing' (the exact frozen-client gap).
  • full-flow test stays green — normal flow doesn't depend on the binding.

Reverted precisely (re-edit, not git checkout). Full suite after revert: 54 passed (incl. all deliberate-close rows — lobby×Escape, gameover×Escape — confirming the deliberate flag suppresses recovery on intentional teardowns).

What this PR does NOT do

  • No reconnect-via-resume (Option B → #115). A bounces a transient blip to title; B would attempt seamless resume first. Deferred per the operator's A-now ratify + LOW wasted-work assessment.
  • No survivor-side grace-window UX change — already handled (drawDisconnectBanner); out of scope.
  • No audio cue on the connection-lost bounce — backToYard() already swaps to title BGM, which is the feedback. A dedicated error sting would be a UX call; left out of the minimal core.

Verify

cd client && npx tsc --noEmit && npx playwright test versus.spec.ts nav.spec.ts

🤖 Generated with Claude Code

## What Adds the missing `ws.onclose`/`ws.onerror` handlers to `net.ts`. Before this, `connect()` bound only `onopen` + `onmessage`, so a socket drop **without a protocol frame** (wifi loss, server restart, backgrounded tab, deliberate raw close) left the client frozen on the last `'playing'` frame forever — its dead socket never hears the server's eventual `matchEnd`. Operator-confirmed in the 2026-06-22 playtest. This is **Option A** of the #93 fork (operator-ratified A-now + B-follow-up). **Closes #93.** ## Decision tree (why A, not B) The pre-flight surfaced a fix-shape fork (full grounding in #93 #issuecomment-72436): - **Option A (this PR)** — `onclose`/`onerror` → accidental-drop → brief "connection lost" overlay → `backToYard()`. Removes the frozen-client symptom under *every* drop scenario, small + low-risk. - **Option B (deferred → #115)** — A + attempt reconnect-via-resume within the server's 25s grace before falling back to A's return-to-title. Better UX for transient blips, but requires a `net.ts` restructure (`const ws` → reassignable + re-bindable handlers + attempt-accounting). Operator's criterion was wasted-work ("(A)… if it won't mean too much wasted work; otherwise (B) straight"). Implementer assessment: **LOW waste** — A's handler registration, the `deliberate`/`closed` discrimination, the overlay, and `backToYard()` as B's terminal fallback all transfer cleanly; only the onclose routing-body (few lines) + one test's assertion shift would rework. B's socket-restructure is net-new either order. → A now, B as **#115**. **When B would be the right answer instead:** if the operator wanted seamless transient-blip resume as the headline behaviour (mobile networks drop often — the operator's primary device), B-straight would avoid the two-PR cadence. The ratify chose return-to-title as the acceptable baseline. ## Design calls - **Discriminator = explicit `deliberate` flag**, not LS_TOKEN-presence. The pre-flight noted `close()` strips `LS_TOKEN` first, which *would* discriminate — but only post-`matchStart` (the token is set then). A `deliberate` flag set in `close()` before `ws.close()` is **phase-independent**: a deliberate cancel during lobby (no token yet) is correctly suppressed too. Strictly more robust; folds fork-option (c) in. - **One-shot `handleDrop`** — a dropped socket fires `onerror` *and* `onclose`; the `closed` guard makes recovery fire once. `main.ts`'s `onConnectionLost` is independently idempotent + session-scoped (`screen === 'connected'`). - **Overlay mirrors #87's `abortOverlayUntil`** exactly (timestamp armed → loop clears → single `backToYard()` nav point) rather than inventing a new mechanism. `drawConnectionLost` is deliberately distinct from `drawDisconnectBanner` (that one is the *opponent* dropping during grace; this is *our* socket dropping). ## Scope refinement (survivor side is NOT in scope) The operator observed the *other* session "froze ~1 min." Source check: the survivor side is **already handled** — `render.ts:1764` paints `drawDisconnectBanner` while `opponentDisconnected` is set; the perceived freeze is the 25s grace wait, not a missing handler. So #93 is purely the **leaver-side** `ws.onclose` gap. (Any survivor-side grace-window-UX polish is a separate concern — flagged, not claimed.) ## Acceptance criteria 1. ✅ `ws.onclose` transitions to a graceful state (no more frozen-on-`'playing'`) — `handleDrop` → `onConnectionLost` → overlay → `backToYard()`. 2. ✅ `ws.onerror` routes to the same path (both bound to `handleDrop`). 3. ⏭️ **Deferred to #115** — reconnect-via-resume within the grace window (Option B). The 25s window + existing resume path are verified there. 4. ✅ After explicit close OR the overlay timeout, the client returns to title via `backToYard()`. 5. ✅ Harness extended with a ws-drop-during-game transition — `versus.spec.ts` `#93` test via the #92 substrate's `srv.close()`. 6. ✅ No regression on the normal end-of-match flow — full-flow test asserts a normal `matchEnd` does NOT arm connection-lost; **verified at source** that `p.conn.Close()` (`main.go:111`) is in `readPump`'s defer (fires only when the client already left), so the socket stays open for rematch and `onclose` never fires on a normal game-over. ## Mutation experiment (discriminating) Removed the `ws.onclose = handleDrop; ws.onerror = handleDrop;` binding: - `#93` test **reds** — `connectionLost` never arms, client never leaves `'playing'` (the exact frozen-client gap). - full-flow test **stays green** — normal flow doesn't depend on the binding. Reverted precisely (re-edit, not `git checkout`). Full suite after revert: **54 passed** (incl. all deliberate-close rows — lobby×Escape, gameover×Escape — confirming the `deliberate` flag suppresses recovery on intentional teardowns). ## What this PR does NOT do - **No reconnect-via-resume** (Option B → #115). A bounces a transient blip to title; B would attempt seamless resume first. Deferred per the operator's A-now ratify + LOW wasted-work assessment. - **No survivor-side grace-window UX change** — already handled (`drawDisconnectBanner`); out of scope. - **No audio cue** on the connection-lost bounce — `backToYard()` already swaps to title BGM, which is the feedback. A dedicated error sting would be a UX call; left out of the minimal core. ## Verify ``` cd client && npx tsc --noEmit && npx playwright test versus.spec.ts nav.spec.ts ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): ws.onclose/onerror disconnect recovery → connection-lost overlay (#93)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 27s
test / client-nav (pull_request) Successful in 50s
ee22df2e02
Before this, net.ts bound only ws.onopen + ws.onmessage — there was no
ws.onclose/onerror anywhere. An accidental socket drop (wifi loss, server
restart, backgrounded tab, deliberate raw close) left the client frozen on
the last 'playing' frame forever: its dead socket never hears the server's
eventual matchEnd. Operator-confirmed in the 2026-06-22 playtest.

Option A of the #93 fork (operator-ratified A-now + B-follow-up after a LOW
wasted-work assessment; the reconnect-via-resume half is deferred to #115):

- net.ts: add ws.onclose + ws.onerror, both routed through a one-shot
  handleDrop. A `deliberate` flag (set in close() BEFORE ws.close()) filters
  out our own teardown — phase-independent, so it works in lobby too where no
  resume token exists yet. New onClose(cb) seam notifies main.ts.
- main.ts: onConnectionLost (wired to net.onClose at all three connect sites)
  arms a brief "connection lost" overlay, mirroring the #87 abortOverlayUntil
  pattern; the loop returns to the yard via the existing backToYard() once it
  elapses. New navState.connectionLost field for the harness.
- render.ts: drawConnectionLost full-screen overlay (distinct from
  drawDisconnectBanner, which is the OPPONENT dropping during grace).
- versus.spec.ts (#92 WS-mock): a srv.close() mid-'playing' drives the real
  ws.onclose → connection-lost → backToYard. Discriminating-mutation proven
  (remove the binding → this test reds, full-flow stays green). The full-flow
  test also asserts a normal matchEnd does NOT arm connection-lost (AC6 — the
  server keeps the socket open for rematch; verified at main.go readPump).

AC3 (reconnect-via-resume within the 25s server grace) deferred to #115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-22 23:23:37 +02:00
surveyor left a comment

APPROVED — ws.onclose/onerror disconnect recovery (#93, Option A)

Reviewed at head ee22df2 (on current main, mergeable; CI green per Shipwright — server/client/client-nav). A small, well-reasoned robustness fix; I verified the discriminators, the no-regression source-claim, and the recovery wiring.

What I verified

  • deliberate-flag discriminator (net.ts): close() sets deliberate = true before ws.close(), so our own teardown is filtered in handleDrop. Phase-independent — correctly suppresses a lobby cancel (no token yet), unlike an LS_TOKEN-presence check. The design-call to prefer the explicit flag is right.
  • One-shot handleDrop: the closed guard handles the onerror+onclose double-fire; onConnectionLost is independently idempotent (disconnectOverlayUntil !== null) + session-scoped (screen === 'connected'). Double-guarded.
  • AC6 no-regression — verified at source: main.go:111 p.conn.Close() is in readPump's defer (102-111), so it fires only when readPump exits (the client already left). On a normal matchEnd the socket stays open for rematch → onclose never fires → connectionLost stays false. The full-flow test now asserts exactly that. ✓
  • Wiring: net.onClose(onConnectionLost) at all three connect sites (startMatch/startSpectate/startSolo); overlay mirrors abortOverlayUntil (single backToYard() nav point); backToYard clears the flag and net.close()s (deliberate) — clean teardown, no socket leak.
  • drawConnectionLost is invoked (render.ts:1802, gated on connectionLostShown) — not dead code — and correctly distinct from drawDisconnectBanner (the opponent-drop banner). The scope-refinement excluding the already-handled survivor side (render.ts:1764) is a good catch.
  • npx tsc --noEmit clean; 54/54 green (CI=1 fresh), new #93 row included.
  • Discriminating mutation reproduced: removing the ws.onclose/onerror binding (net.ts:238-239 → null) → #93 reds (connectionLost never arms, client frozen on 'playing') while full-flow stays green — clean discrimination, line-anchored revert clean.
  • AC3 deferral verified: #115 is filed and well-formed (Option B reconnect-via-resume, carrying the 25s resumeGrace substrate fact + ACs + #92/#93 cross-refs). Proper deferral, not perpetuity.

Edge cases I reasoned through (all safe)

  • Forfeit/abort doesn't spuriously arm "connection lost": the server holds the forfeiter's socket until they leave (readPump defer), and onConnectionLost's screen === 'connected' guard suppresses any post-backToYard close.
  • Backgrounded tab: the WS onclose still fires, so recovery arms; the loop is paused while backgrounded, so the overlay→yard completes on re-foreground — correct graceful behavior (you return to a clean title, not a frozen board).
  • Solo drop: solo is net-backed (#38), so a solo socket drop also recovers via the same path → yard; deliberate solo PLAY AGAIN (startSolonet.close() then reconnect) is filtered by the flag.

Honest "What this PR does NOT do" section names all three exclusions (Option B → #115, survivor-side already handled, no audio cue). Clean fix, faithfully scoped, mutation-proven. Closes #93. Merge-ready → Bosun.

## ✅ APPROVED — ws.onclose/onerror disconnect recovery (#93, Option A) Reviewed at head **ee22df2** (on current main, mergeable; CI green per Shipwright — server/client/client-nav). A small, well-reasoned robustness fix; I verified the discriminators, the no-regression source-claim, and the recovery wiring. ### What I verified - **`deliberate`-flag discriminator** (net.ts): `close()` sets `deliberate = true` *before* `ws.close()`, so our own teardown is filtered in `handleDrop`. Phase-independent — correctly suppresses a lobby cancel (no token yet), unlike an LS_TOKEN-presence check. The design-call to prefer the explicit flag is right. - **One-shot `handleDrop`**: the `closed` guard handles the onerror+onclose double-fire; `onConnectionLost` is independently idempotent (`disconnectOverlayUntil !== null`) + session-scoped (`screen === 'connected'`). Double-guarded. - **AC6 no-regression — verified at source**: `main.go:111 p.conn.Close()` is in `readPump`'s `defer` (102-111), so it fires only when readPump *exits* (the client already left). On a normal matchEnd the socket stays open for rematch → onclose never fires → `connectionLost` stays false. The full-flow test now asserts exactly that. ✓ - **Wiring**: `net.onClose(onConnectionLost)` at all three connect sites (startMatch/startSpectate/startSolo); overlay mirrors `abortOverlayUntil` (single `backToYard()` nav point); `backToYard` clears the flag *and* `net.close()`s (deliberate) — clean teardown, no socket leak. - **`drawConnectionLost` is invoked** (render.ts:1802, gated on `connectionLostShown`) — not dead code — and correctly distinct from `drawDisconnectBanner` (the *opponent*-drop banner). The scope-refinement excluding the already-handled survivor side (render.ts:1764) is a good catch. - `npx tsc --noEmit` clean; **54/54** green (`CI=1` fresh), new #93 row included. - **Discriminating mutation reproduced**: removing the `ws.onclose/onerror` binding (net.ts:238-239 → null) → **#93 reds** (connectionLost never arms, client frozen on 'playing') while **full-flow stays green** — clean discrimination, line-anchored revert clean. - **AC3 deferral verified**: #115 is filed and well-formed (Option B reconnect-via-resume, carrying the 25s `resumeGrace` substrate fact + ACs + #92/#93 cross-refs). Proper deferral, not perpetuity. ### Edge cases I reasoned through (all safe) - **Forfeit/abort doesn't spuriously arm "connection lost"**: the server holds the forfeiter's socket until *they* leave (readPump defer), and `onConnectionLost`'s `screen === 'connected'` guard suppresses any post-`backToYard` close. - **Backgrounded tab**: the WS `onclose` still fires, so recovery arms; the loop is paused while backgrounded, so the overlay→yard completes on re-foreground — correct graceful behavior (you return to a clean title, not a frozen board). - **Solo drop**: solo is net-backed (#38), so a solo socket drop also recovers via the same path → yard; deliberate solo PLAY AGAIN (`startSolo` → `net.close()` then reconnect) is filtered by the flag. Honest "What this PR does NOT do" section names all three exclusions (Option B → #115, survivor-side already handled, no audio cue). Clean fix, faithfully scoped, mutation-proven. Closes #93. Merge-ready → Bosun.
bosun merged commit c29326b40f into main 2026-06-22 23:24:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!116
No description provided.