fix(client): ws.onclose/onerror disconnect recovery → connection-lost overlay (#93) #116
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/93-ws-onclose-handler"
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?
What
Adds the missing
ws.onclose/ws.onerrorhandlers tonet.ts. Before this,connect()bound onlyonopen+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 eventualmatchEnd. 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):
onclose/onerror→ accidental-drop → brief "connection lost" overlay →backToYard(). Removes the frozen-client symptom under every drop scenario, small + low-risk.net.tsrestructure (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/closeddiscrimination, the overlay, andbackToYard()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
deliberateflag, not LS_TOKEN-presence. The pre-flight notedclose()stripsLS_TOKENfirst, which would discriminate — but only post-matchStart(the token is set then). Adeliberateflag set inclose()beforews.close()is phase-independent: a deliberate cancel during lobby (no token yet) is correctly suppressed too. Strictly more robust; folds fork-option (c) in.handleDrop— a dropped socket firesonerrorandonclose; theclosedguard makes recovery fire once.main.ts'sonConnectionLostis independently idempotent + session-scoped (screen === 'connected').abortOverlayUntilexactly (timestamp armed → loop clears → singlebackToYard()nav point) rather than inventing a new mechanism.drawConnectionLostis deliberately distinct fromdrawDisconnectBanner(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:1764paintsdrawDisconnectBannerwhileopponentDisconnectedis set; the perceived freeze is the 25s grace wait, not a missing handler. So #93 is purely the leaver-sidews.onclosegap. (Any survivor-side grace-window-UX polish is a separate concern — flagged, not claimed.)Acceptance criteria
ws.onclosetransitions to a graceful state (no more frozen-on-'playing') —handleDrop→onConnectionLost→ overlay →backToYard().ws.onerrorroutes to the same path (both bound tohandleDrop).backToYard().versus.spec.ts#93test via the #92 substrate'ssrv.close().matchEnddoes NOT arm connection-lost; verified at source thatp.conn.Close()(main.go:111) is inreadPump's defer (fires only when the client already left), so the socket stays open for rematch andonclosenever fires on a normal game-over.Mutation experiment (discriminating)
Removed the
ws.onclose = handleDrop; ws.onerror = handleDrop;binding:#93test reds —connectionLostnever arms, client never leaves'playing'(the exact frozen-client gap).Reverted precisely (re-edit, not
git checkout). Full suite after revert: 54 passed (incl. all deliberate-close rows — lobby×Escape, gameover×Escape — confirming thedeliberateflag suppresses recovery on intentional teardowns).What this PR does NOT do
drawDisconnectBanner); out of scope.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
🤖 Generated with Claude Code
✅ 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()setsdeliberate = truebeforews.close(), so our own teardown is filtered inhandleDrop. 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.handleDrop: theclosedguard handles the onerror+onclose double-fire;onConnectionLostis independently idempotent (disconnectOverlayUntil !== null) + session-scoped (screen === 'connected'). Double-guarded.main.go:111 p.conn.Close()is inreadPump'sdefer(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 →connectionLoststays false. The full-flow test now asserts exactly that. ✓net.onClose(onConnectionLost)at all three connect sites (startMatch/startSpectate/startSolo); overlay mirrorsabortOverlayUntil(singlebackToYard()nav point);backToYardclears the flag andnet.close()s (deliberate) — clean teardown, no socket leak.drawConnectionLostis invoked (render.ts:1802, gated onconnectionLostShown) — not dead code — and correctly distinct fromdrawDisconnectBanner(the opponent-drop banner). The scope-refinement excluding the already-handled survivor side (render.ts:1764) is a good catch.npx tsc --noEmitclean; 54/54 green (CI=1fresh), new #93 row included.ws.onclose/onerrorbinding (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.resumeGracesubstrate fact + ACs + #92/#93 cross-refs). Proper deferral, not perpetuity.Edge cases I reasoned through (all safe)
onConnectionLost'sscreen === 'connected'guard suppresses any post-backToYardclose.onclosestill 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).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.