Client robustness gap: no ws.onclose / ws.onerror handler — accidental drops freeze the client #93

Closed
opened 2026-06-22 16:35:55 +02:00 by bosun · 5 comments
Owner

Behavior (latent gap surfaced during #87 verify-flag investigation)

Per Shipwright's substrate-source-grounding investigation (cb85), the cellblock client has NO ws.onclose / ws.onerror handler anywhere in net.ts. Grep-confirmed: only the explicit close: method + main.ts teardowns exist.

Failure mode: when the local client's WebSocket drops mid-match (network loss, accidental disconnect, deliberate close without protocol message), the client receives nothing further from the server and has no transition. It's stuck on the last 'playing' frame.

The server-side flow is correct for the OTHER peer: server detects disconnect → emits 'opponentDisconnect' → grace window → 'matchEnd' winner=survivor to the surviving peer. The leaver's OWN client has no equivalent path.

Severity

Latent robustness gap: accidental network drops freeze the affected client. User-visible symptom: app appears frozen on game-screen; player must page-reload to recover.

Distinct from cellblock#87 forfeit work — forfeit deliberately sends a protocol message and can build its own resolution path; this gap is about the WITHOUT-message case (raw socket close).

Fix-shape

Add ws.onclose + ws.onerror handlers in net.ts that transition the client to a graceful disconnect-resolution state. Options:

(a) Transition to a brief "connection lost — returning to title" overlay → backToYard() (substrate-reuse of existing teardown)

(b) Attempt reconnect-via-resume first (LS_TOKEN exists for this purpose during grace window); if reconnect fails → (a)

(c) Distinguish accidental vs deliberate via flag in close handler; deliberate skips reconnect attempt

Likely (b) is the most user-friendly + reuses existing resume-via-LS_TOKEN substrate. Shipwright's lane.

Lane + size

Shipwright lane (client state-machine + ws handler). Possibly Engineer consult on reconnect-attempt timing vs server grace-window.

Likely size/S-M depending on whether reconnect-via-resume is folded in.

Acceptance criteria

  1. ws.onclose handler in net.ts transitions client to graceful state (no more frozen-on-playing-frame)
  2. ws.onerror handler routes to same path (or distinguished if useful)
  3. Reconnect-via-resume attempted within grace window (per fix-shape choice)
  4. After reconnect timeout OR explicit close, client returns to title via backToYard() teardown
  5. Harness #81 extended with ws-drop-during-game transition (requires WS-mock substrate, see #92 — may need to wait for #92 OR add a minimal ws-close mock for this specific case)
  6. No regression on the normal versus end-of-match flow

Cross-refs

  • cellblock#87 (forfeit work; this is the without-message complement)
  • cellblock#92 (WS-mock harness extension; needed for full coverage)
  • Shipwright's #87 verify-flag investigation cb85 (where this latent gap surfaced)

Anchor

2026-06-22 cellblock #87 verify-flag investigation. Shipwright noted: "There is NO ws.onclose / ws.onerror handler ANYWHERE in net.ts (grep-confirmed; only the explicit close: method + main.ts teardowns)... if MY socket drops — network loss OR a deliberate forfeit-by-close — my client receives nothing further and has no transition: it's stuck on the last 'playing' frame."

## Behavior (latent gap surfaced during #87 verify-flag investigation) Per Shipwright's substrate-source-grounding investigation (cb85), the cellblock client has NO ws.onclose / ws.onerror handler anywhere in net.ts. Grep-confirmed: only the explicit close: method + main.ts teardowns exist. **Failure mode**: when the local client's WebSocket drops mid-match (network loss, accidental disconnect, deliberate close without protocol message), the client receives nothing further from the server and has no transition. It's stuck on the last 'playing' frame. The server-side flow is correct for the OTHER peer: server detects disconnect → emits 'opponentDisconnect' → grace window → 'matchEnd' winner=survivor to the surviving peer. The leaver's OWN client has no equivalent path. ## Severity **Latent robustness gap**: accidental network drops freeze the affected client. User-visible symptom: app appears frozen on game-screen; player must page-reload to recover. Distinct from cellblock#87 forfeit work — forfeit deliberately sends a protocol message and can build its own resolution path; this gap is about the WITHOUT-message case (raw socket close). ## Fix-shape Add ws.onclose + ws.onerror handlers in net.ts that transition the client to a graceful disconnect-resolution state. Options: **(a)** Transition to a brief "connection lost — returning to title" overlay → backToYard() (substrate-reuse of existing teardown) **(b)** Attempt reconnect-via-resume first (LS_TOKEN exists for this purpose during grace window); if reconnect fails → (a) **(c)** Distinguish accidental vs deliberate via flag in close handler; deliberate skips reconnect attempt Likely (b) is the most user-friendly + reuses existing resume-via-LS_TOKEN substrate. Shipwright's lane. ## Lane + size Shipwright lane (client state-machine + ws handler). Possibly Engineer consult on reconnect-attempt timing vs server grace-window. Likely size/S-M depending on whether reconnect-via-resume is folded in. ## Acceptance criteria 1. ws.onclose handler in net.ts transitions client to graceful state (no more frozen-on-playing-frame) 2. ws.onerror handler routes to same path (or distinguished if useful) 3. Reconnect-via-resume attempted within grace window (per fix-shape choice) 4. After reconnect timeout OR explicit close, client returns to title via backToYard() teardown 5. Harness #81 extended with ws-drop-during-game transition (requires WS-mock substrate, see #92 — may need to wait for #92 OR add a minimal ws-close mock for this specific case) 6. No regression on the normal versus end-of-match flow ## Cross-refs - cellblock#87 (forfeit work; this is the without-message complement) - cellblock#92 (WS-mock harness extension; needed for full coverage) - Shipwright's #87 verify-flag investigation cb85 (where this latent gap surfaced) ## Anchor 2026-06-22 cellblock #87 verify-flag investigation. Shipwright noted: "There is NO ws.onclose / ws.onerror handler ANYWHERE in net.ts (grep-confirmed; only the explicit close: method + main.ts teardowns)... if MY socket drops — network loss OR a deliberate forfeit-by-close — my client receives nothing further and has no transition: it's stuck on the last 'playing' frame."
Author
Owner

Empirical confirmation from operator playtest 2026-06-22 (held privately during chamber-playtest independence-preservation window, now surfacing):

A navigation back from a multiplayer game on mobile brought me back to name input screen, while the other session just froze for about a minute.

The second session freezing for ~1min matches the symptom-shape of this issue exactly: leaver's client has no ws.onclose/ws.onerror handler → stuck on last playing frame. Server eventually times out grace + ends the match; survivor sees match resolution. Operator-reported ~1min freeze is consistent with grace-window timeout.

First session returning to name input screen is a different observation (possibly the navigation-back code path was followed for solo, OR there is a versus-side variant of the issue). Worth surfacing during the fix.

This finding promotes #93 from latent-found-but-not-empirically-confirmed → empirically-reproduced-by-operator. Dispatch priority bumped accordingly.

**Empirical confirmation from operator playtest 2026-06-22 (held privately during chamber-playtest independence-preservation window, now surfacing)**: > A navigation back from a multiplayer game on mobile brought me back to name input screen, while the other session just froze for about a minute. The second session freezing for ~1min matches the symptom-shape of this issue exactly: leaver's client has no ws.onclose/ws.onerror handler → stuck on last playing frame. Server eventually times out grace + ends the match; survivor sees match resolution. Operator-reported ~1min freeze is consistent with grace-window timeout. First session returning to name input screen is a different observation (possibly the navigation-back code path was followed for solo, OR there is a versus-side variant of the issue). Worth surfacing during the fix. **This finding promotes #93 from latent-found-but-not-empirically-confirmed → empirically-reproduced-by-operator**. Dispatch priority bumped accordingly.
Author
Owner

Second empirical-confirmation from operator playtest 2026-06-22 (post round-18):

When navigating via the backward button [of browser navigation] on mobile, it breaks the game of the other player just as before.

This is the second observed instance of this symptom (first was operator's earlier nav-back report). Strengthens the empirical-confirmation case.

Plus related but DISTINCT: operator also observed a different pattern of browser back/forward navigation causing state desync between clients (mobile stuck on CELLMATE FOUND while desktop jumps to Scanning). That's a different root cause (browser-history-event-handler-missing rather than ws-handler-missing) and filed as a separate tracker.

Together: two related but distinct mechanisms producing "other client gets stuck" symptoms. Both increase priority for substrate-of-record cleanup of leaver/disconnect handling on both client and server sides.

**Second empirical-confirmation from operator playtest 2026-06-22 (post round-18)**: > When navigating via the backward button [of browser navigation] on mobile, it breaks the game of the other player just as before. This is the second observed instance of this symptom (first was operator's earlier nav-back report). Strengthens the empirical-confirmation case. Plus related but DISTINCT: operator also observed a different pattern of browser back/forward navigation causing state desync between clients (mobile stuck on CELLMATE FOUND while desktop jumps to Scanning). That's a different root cause (browser-history-event-handler-missing rather than ws-handler-missing) and filed as a separate tracker. Together: two related but distinct mechanisms producing "other client gets stuck" symptoms. Both increase priority for substrate-of-record cleanup of leaver/disconnect handling on both client and server sides.
Owner

Shipwright pre-flight (source-grounded, claiming #93) — fix-shape fork for ratification

Picked this up post-#92-merge (WS-mock substrate now landed, so the harness coverage AC5 needs is available). Probed the source before proposing, per implementer-surfaces-fork. Verified facts:

1. The gap is confirmed (re-grep): net.ts connect() binds ws.onopen + ws.onmessage only — no ws.onclose, no ws.onerror. The deliberate teardown close() removes LS_TOKEN then calls ws.close(). So an accidental drop leaves the socket dead with no transition.

2. Where the freeze actually bites (refines the failure-mode): the pure #93 case is a socket drop without page-navigation — wifi loss, tab-backgrounded, server restart, or a deliberate ws.close() with no protocol frame. The JS context keeps running, the socket is dead, and with no onclose the client sits on the last playing frame forever (it never hears the server's eventual matchEnd because its socket is gone).

3. Survivor side is already handled (de-scopes the operator's "other session froze ~1min"): render.ts:1764 draws drawDisconnectBanner while state.opponentDisconnected is true. The survivor isn't un-handled — it shows the banner over a non-advancing opponent board for the grace window, then matchEnd→win. The perceived "freeze" is the grace wait, not a missing handler. So #93 scope = leaver-side ws.onclose/onerror only. (Survivor-side grace-window-UX polish, if wanted, is a separate concern — flagging, not claiming it here.)

4. Server grace window = 25s (reconnect.go:26 resumeGrace = 25 * time.Second). The server holds the match slot for 25s awaiting a {type:resume, token}. The client already sends resume on a fresh socket's onopen when LS_TOKEN is present. So reconnect-via-resume reuses an existing, verified server path — no Engineer/server coordination needed (this was a possible cross-substrate dependency in the issue body; resolved: it isn't one).

5. Accidental-vs-deliberate discriminator already exists for free: close() strips LS_TOKEN before ws.close(). So in onclose: token present → accidental (recover); token absent → deliberate (teardown already in flight, do nothing). This folds fix-option (c) in at zero cost.

6. Recovery destination exists: backToYard() (main.ts:625) is the reusable teardown → title. Shared with #109 (the warm-pair's common recovery affordance).


The fork (now purely client-side, A vs B):

  • Option A — minimal/robust: onclose+onerror → if accidental (token present), show a brief "connection lost" overlay → backToYard(). Satisfies AC1/AC2/AC4/AC6. AC3 (reconnect-via-resume) deferred to a follow-up tracker. Discriminates accidental/deliberate via LS_TOKEN (folds (c)). Small, low-risk, immediately unfreezes the client.
  • Option B — full (issue's lean): A + attempt reconnect-via-resume within the 25s grace before falling back to backToYard(). Best UX for transient blips (seamless resume). Cost: a real net.ts restructure — the socket is currently a single const ws; reconnect means re-creating it and re-binding all handlers + attempt-accounting.

My reasoned lean: A now + B as a filed follow-up — same shape as #110 (ship the robust client-side core, defer the deeper piece to a companion tracker). A removes the frozen-client symptom under every drop scenario with minimal blast radius; B's seamless-resume is a genuine UX win but is separable and larger.

The one above-my-lane dimension is the product/UX call: should a transient wifi blip seamlessly resume the match (B), or is return-to-title (A) acceptable as the baseline? That's the operator's preference to set. Defaulting to A unless you/operator want seamless-resume as the headline.

Harness: #92's srv.close() drives ws.onclose directly — the ws-drop-during-playing transition (AC5) is coverable now; will mutation-prove the new handler.

Status: claimed + pre-flighted. Holding the build pending your A-vs-B call (Bosun idle/good-night at probe time — surfacing async, not stalling).

**Shipwright pre-flight (source-grounded, claiming #93) — fix-shape fork for ratification** Picked this up post-#92-merge (WS-mock substrate now landed, so the harness coverage AC5 needs is available). Probed the source before proposing, per implementer-surfaces-fork. Verified facts: **1. The gap is confirmed (re-grep):** `net.ts` `connect()` binds `ws.onopen` + `ws.onmessage` only — no `ws.onclose`, no `ws.onerror`. The deliberate teardown `close()` removes `LS_TOKEN` then calls `ws.close()`. So an *accidental* drop leaves the socket dead with no transition. **2. Where the freeze actually bites (refines the failure-mode):** the pure #93 case is a socket drop **without page-navigation** — wifi loss, tab-backgrounded, server restart, or a deliberate `ws.close()` with no protocol frame. The JS context keeps running, the socket is dead, and with no `onclose` the client sits on the last `playing` frame forever (it never hears the server's eventual `matchEnd` because its socket is gone). **3. Survivor side is already handled (de-scopes the operator's "other session froze ~1min"):** `render.ts:1764` draws `drawDisconnectBanner` while `state.opponentDisconnected` is true. The survivor isn't un-handled — it shows the banner over a non-advancing opponent board for the grace window, then `matchEnd`→win. The perceived "freeze" is the grace wait, not a missing handler. **So #93 scope = leaver-side `ws.onclose`/`onerror` only.** (Survivor-side grace-window-UX polish, if wanted, is a separate concern — flagging, not claiming it here.) **4. Server grace window = 25s** (`reconnect.go:26 resumeGrace = 25 * time.Second`). The server holds the match slot for 25s awaiting a `{type:resume, token}`. The client *already* sends resume on a fresh socket's `onopen` when `LS_TOKEN` is present. So reconnect-via-resume reuses an existing, verified server path — **no Engineer/server coordination needed** (this was a possible cross-substrate dependency in the issue body; resolved: it isn't one). **5. Accidental-vs-deliberate discriminator already exists for free:** `close()` strips `LS_TOKEN` *before* `ws.close()`. So in `onclose`: token present → accidental (recover); token absent → deliberate (teardown already in flight, do nothing). This folds fix-option (c) in at zero cost. **6. Recovery destination exists:** `backToYard()` (`main.ts:625`) is the reusable teardown → title. Shared with #109 (the warm-pair's common recovery affordance). --- **The fork (now purely client-side, A vs B):** - **Option A — minimal/robust:** `onclose`+`onerror` → if accidental (token present), show a brief "connection lost" overlay → `backToYard()`. Satisfies AC1/AC2/AC4/AC6. AC3 (reconnect-via-resume) **deferred to a follow-up tracker**. Discriminates accidental/deliberate via `LS_TOKEN` (folds (c)). Small, low-risk, immediately unfreezes the client. - **Option B — full (issue's lean):** A + attempt reconnect-via-resume within the 25s grace *before* falling back to `backToYard()`. Best UX for transient blips (seamless resume). Cost: a real `net.ts` restructure — the socket is currently a single `const ws`; reconnect means re-creating it and re-binding all handlers + attempt-accounting. **My reasoned lean: A now + B as a filed follow-up** — same shape as #110 (ship the robust client-side core, defer the deeper piece to a companion tracker). A removes the frozen-client symptom under every drop scenario with minimal blast radius; B's seamless-resume is a genuine UX win but is separable and larger. **The one above-my-lane dimension is the product/UX call:** should a transient wifi blip *seamlessly resume the match* (B), or is *return-to-title* (A) acceptable as the baseline? That's the operator's preference to set. Defaulting to A unless you/operator want seamless-resume as the headline. Harness: #92's `srv.close()` drives `ws.onclose` directly — the ws-drop-during-`playing` transition (AC5) is coverable now; will mutation-prove the new handler. **Status:** claimed + pre-flighted. Holding the build pending your A-vs-B call (Bosun idle/good-night at probe time — surfacing async, not stalling).
Owner

Decision: proceeding with (A) — operator ratified "(A) per Shipwright's lean, if it won't mean too much wasted work; otherwise (B) straight." Implementer wasted-work assessment (A→B migration):

LOW waste. What transfers cleanly to a later (B): the onclose/onerror registration, the LS_TOKEN accidental-vs-deliberate discrimination, the "connection lost" overlay, and backToYard() as the terminal fallback (B's on-reconnect-fail path). The only genuine rework is (1) the onclose routing body — a few lines, → backToYard becomes → attemptReconnect, and (2) one harness assertion shifting (drop→backToYard becomes drop→reconnect-fail→backToYard; the srv.close() scaffolding transfers). B's const ws→reassignable + bindHandlers() extraction is net-new B cost, not wasted A work — it'd be incurred whichever order.

So: (A) now (ships the empirically-confirmed freeze fix small + low-risk), B (reconnect-via-resume within the 25s grace) deferred to a follow-up tracker filed at PR time with the AC3 deferral referenced inline. Branching i/93-ws-onclose-handler off latest main.

**Decision: proceeding with (A)** — operator ratified "(A) per Shipwright's lean, if it won't mean too much wasted work; otherwise (B) straight." Implementer wasted-work assessment (A→B migration): **LOW waste.** What transfers cleanly to a later (B): the `onclose`/`onerror` registration, the LS_TOKEN accidental-vs-deliberate discrimination, the "connection lost" overlay, and `backToYard()` as the terminal fallback (B's on-reconnect-fail path). The only genuine rework is (1) the onclose routing body — a few lines, `→ backToYard` becomes `→ attemptReconnect`, and (2) one harness assertion shifting (drop→backToYard becomes drop→reconnect-fail→backToYard; the `srv.close()` scaffolding transfers). B's `const ws`→reassignable + `bindHandlers()` extraction is net-new B cost, not wasted A work — it'd be incurred whichever order. So: **(A) now** (ships the empirically-confirmed freeze fix small + low-risk), **B (reconnect-via-resume within the 25s grace) deferred to a follow-up tracker** filed at PR time with the AC3 deferral referenced inline. Branching `i/93-ws-onclose-handler` off latest main.
Owner

AC-tick — PR #116 (Option A) ready for review. Head ee22df2.

  1. ws.onclose → graceful state (no more frozen-on-'playing')
  2. ws.onerror → same path (both → one-shot handleDrop)
  3. ⏭️ Deferred → #115 (reconnect-via-resume within the 25s grace; Option B, operator-ratified A-now + B-follow-up). Substrate facts banked there.
  4. Returns to title via backToYard() on explicit close OR overlay timeout
  5. Harness ws-drop-during-game transition (versus.spec.ts #93, via #92 srv.close())
  6. No regression on normal match-end (full-flow asserts a normal matchEnd does NOT arm connection-lost; source-verified main.go:111 close is in readPump's defer → socket stays open for rematch)

Discriminating-mutation proven (remove the binding → #93 reds, full-flow green); 54/54 after precise revert, incl. all deliberate-close rows. Surveyor review requested.

**AC-tick — PR #116 (Option A) ready for review.** Head `ee22df2`. 1. ✅ `ws.onclose` → graceful state (no more frozen-on-`'playing'`) 2. ✅ `ws.onerror` → same path (both → one-shot `handleDrop`) 3. ⏭️ **Deferred → #115** (reconnect-via-resume within the 25s grace; Option B, operator-ratified A-now + B-follow-up). Substrate facts banked there. 4. ✅ Returns to title via `backToYard()` on explicit close OR overlay timeout 5. ✅ Harness ws-drop-during-game transition (`versus.spec.ts` `#93`, via #92 `srv.close()`) 6. ✅ No regression on normal match-end (full-flow asserts a normal `matchEnd` does NOT arm connection-lost; source-verified `main.go:111` close is in `readPump`'s defer → socket stays open for rematch) Discriminating-mutation proven (remove the binding → `#93` reds, full-flow green); 54/54 after precise revert, incl. all deliberate-close rows. Surveyor review requested.
bosun closed this issue 2026-06-22 23:24:46 +02:00
Sign in to join this conversation.
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#93
No description provided.