Client robustness gap: no ws.onclose / ws.onerror handler — accidental drops freeze the client #93
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/cellblock#93
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
Cross-refs
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."
Empirical confirmation from operator playtest 2026-06-22 (held privately during chamber-playtest independence-preservation window, now surfacing):
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.
Second empirical-confirmation from operator playtest 2026-06-22 (post round-18):
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.
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.tsconnect()bindsws.onopen+ws.onmessageonly — nows.onclose, nows.onerror. The deliberate teardownclose()removesLS_TOKENthen callsws.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 noonclosethe client sits on the lastplayingframe forever (it never hears the server's eventualmatchEndbecause its socket is gone).3. Survivor side is already handled (de-scopes the operator's "other session froze ~1min"):
render.ts:1764drawsdrawDisconnectBannerwhilestate.opponentDisconnectedis true. The survivor isn't un-handled — it shows the banner over a non-advancing opponent board for the grace window, thenmatchEnd→win. The perceived "freeze" is the grace wait, not a missing handler. So #93 scope = leaver-sidews.onclose/onerroronly. (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'sonopenwhenLS_TOKENis 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()stripsLS_TOKENbeforews.close(). So inonclose: 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):
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 viaLS_TOKEN(folds (c)). Small, low-risk, immediately unfreezes the client.backToYard(). Best UX for transient blips (seamless resume). Cost: a realnet.tsrestructure — the socket is currently a singleconst 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()drivesws.onclosedirectly — the ws-drop-during-playingtransition (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).
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/onerrorregistration, the LS_TOKEN accidental-vs-deliberate discrimination, the "connection lost" overlay, andbackToYard()as the terminal fallback (B's on-reconnect-fail path). The only genuine rework is (1) the onclose routing body — a few lines,→ backToYardbecomes→ attemptReconnect, and (2) one harness assertion shifting (drop→backToYard becomes drop→reconnect-fail→backToYard; thesrv.close()scaffolding transfers). B'sconst 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-handleroff latest main.AC-tick — PR #116 (Option A) ready for review. Head
ee22df2.ws.onclose→ graceful state (no more frozen-on-'playing')ws.onerror→ same path (both → one-shothandleDrop)backToYard()on explicit close OR overlay timeoutversus.spec.ts#93, via #92srv.close())matchEnddoes NOT arm connection-lost; source-verifiedmain.go:111close is inreadPump's defer → socket stays open for rematch)Discriminating-mutation proven (remove the binding →
#93reds, full-flow green); 54/54 after precise revert, incl. all deliberate-close rows. Surveyor review requested.