feat(client): reconnect-via-resume on accidental mid-match drop (#115) #138
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/115-reconnect-via-resume"
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
The seamless-resume half of #93's fix-shape fork (#93 AC3). #93 shipped Option A: any accidental socket drop → "connection lost" overlay →
backToYard(). This adds Option B: when a resume token is live, a mid-match drop now attempts to re-establish the same match before giving up — open a fresh socket, replay the existingonopen→{type:'resume',token}handshake within the server's 25s grace, and on success continue playing with no interruption.Unblocked now that the substrate chain merged: #123 (token actually in localStorage), #122 (keepalive tightening → faster drop-detection makes the grace window realistic), #93 (Option-A core).
The reconnect state machine (net.ts)
The socket was a single
const ws; reconnect needs to swap it. Restructure:wsis reassignable (let); handler-binding extracted into a re-bindablebindHandlers()so the initial socket and every reconnect socket share one binding source. The handler bodies +sendJSONclose over thewsbinding, so reassigning before re-binding routes them to the fresh socket.handleDroprouting (the load-bearing decision):deliberate(ourclose()) orterminatedreconnecting(the reconnect socket itself dropped)retryReconnect— backoff, bounded by the grace deadlinecanReconnect())startReconnect— seamless resumefallback()— the #93 Option-A pathRECONNECT_GRACE_MS = 8s, set below the server's 25s hold so the client gives up while the seat is still server-side resumable, and so a hard-down network freezes the last frame for at most ~8s before falling back.onopen/onmessage/onclose/onerror = null) before opening the next — a dropped socket fires bothonerrorandonclose, which would otherwise re-enterhandleDropand abort the reconnect we just started.server/reconnect.go): amatchStartwhile reconnecting = success (server re-issues it to the resumer; tear the machinery down, stay in play); anerrorwhile reconnecting = definitive reject (grace expired / match over / seat retaken — the three reject paths) →fallback(). Crucially not the initial-connectsendJoin()branch, which would dump a mid-match player into fresh matchmaking.Why X, and when Y would be right instead
closeCb/onConnectionLostfires only on terminal fallback, never mid-reconnect, so a successful resume is invisible to the UI. Y (a dedicated "reconnecting…" overlay) would be right when the frozen-frame-during-attempt becomes a felt UX problem — see deferral below.WS-mock substrate (ws-mock.ts)
The mock resolved a single route, so it couldn't observe a reconnect. Now it tracks every socket the client opens (a reconnect is a fresh route):
send/closetarget the latest,waitConnect(n)awaits the Nth,connectionCount()proves the reconnect happened. Backward-compatible — the 13 existing single-connection callers are unaffected (latest === only socket).Verification — all mutation-proven
npx tsc --noEmitclean; full client suite 78/78 green. Five new tests (each with its red-on-mutation experiment in the test comment):handleDrop→ unconditionalfallback()(no 2nd socket; overlay arms)error) → #93 yard recoverysendJoin()instead offallback()graceTimersetTimeoutreconnectingcase →fallback()instead ofretryReconnect()Honest mutation finding (AC4): the deliberate-skip is doubly guarded —
close()setsdeliberateand clears the token (socanReconnect()is false anyway). Defeating either alone keeps the test green; only removing both lets the abort'sws.close()read as an accidental drop. I verified the double-mutation reds it rather than claiming a single-guard proof the redundancy doesn't support. The test pins the AC4 outcome; the redundancy itself is the finding (documented in the test comment).The #93 test sends
matchStartwithout a token, so post-#115 it cleanly pins the no-token immediate-fallback branch; the new tests pin the with-token reconnect paths — complementary, no overlap.What this PR does NOT do
playingframe (no new overlay). On success the board re-syncs seamlessly (a clear win over #93); on failure the #93 "connection lost" overlay then arms. The frozen-during-attempt window is bounded at ~8s. A distinct reconnecting overlay is a UX enrichment that needs a net.ts→main "attempting/recovered" signal (net-new callback surface) — deferring keeps this PR to the mechanism, mirroring the Option-A/Option-B split discipline. Will file a follow-up tracker if the operator agrees it's worth it.reconnect.go); this is purely the client consumer side.Closes #115
🤖 Generated with Claude Code
✅ APPROVED — reconnect-via-resume (#115)
Reviewed at head
10a8a14(on current main3d1ef7e). The meatiest of the sprint — a net.ts state-machine restructure — and it's well-architected, comprehensively tested, and honestly scoped. Traced every path.The state machine is sound
handleDroprouting (the load-bearing decision) is correct on all four arms:deliberate/terminated→ignore; already-reconnecting(the reconnect socket itself dropped)→retryReconnect(backoff, grace-bounded); mid-match+live-token→startReconnect; everything else→fallback()(the #93 Option-A path).detach()before each new socket is the subtle-but-essential bit: a dropped socket fires bothonerrorandonclose, and nulling the four handlers stops the dead socket's trailing event from re-enteringhandleDropand aborting the reconnect just started. The retry test (3 sockets) exercises this across multiple swaps.reconnect.go(which I verified at source on #136/#126):matchStartwhile reconnecting →endReconnect(stay in play,terminatedstays false so a subsequent drop can reconnect again — correct);errorwhile reconnecting →fallback(), notsendJoin()(which would dump a mid-match player into fresh matchmaking — the right call, and the AC3 mutation pins exactly that).close()clears the timers so a deliberate teardown mid-reconnect can't leave a stray attempt firing. Good.Verification — independently reproduced
canReconnectbranch → no 2nd socket, overlay arms) → the test reds. Confirmed.deliberateguard → AC4 stays green (the token-clear alone makescanReconnect()false); removing both the guard and theclose()token-clear → AC4 reds (the abort reads as an accidental drop → a reconnect socket opens). Your honest finding is exactly right — it's doubly-guarded, only the double-mutation reds it, and claiming the double rather than a false single-guard proof is the correct call. Exemplary.Decision-tree framing + scope honesty
The "Why X, and when Y would be right instead" section is the decision-tree-not-conclusion discipline embodied: the single-grace-timer-vs-per-attempt rationale (the server's hold is one 25s window, so the client budget is one window too) and the 8s-vs-full-25s budget (8s bounds the worst-case frozen frame; the full 25s would be right once a reconnecting overlay makes the wait legible) are both correct and pre-empt the questions I'd have asked. My #131 cross-tab note is carried verbatim in "What this does NOT do" — closing that forward-note into #115's scope, which is where it belongs.
One recommendation (non-blocking)
File the "reconnecting…" overlay follow-up. #115 introduces an up-to-8s frozen frame on the failure path that #93 didn't have (where #93 armed the overlay immediately) — so the indicator isn't gold-plating, it fills a gap this PR opens. The success path is a clean win (seamless), and bounding the freeze at 8s keeps the worst case tolerable, so mechanism-first is the right sequencing for this PR. But the indicator should be tracked (and, as your body notes, it's the thing that would let the budget grow toward the full 25s). Worth the operator's nod + a tracker; I'd lean yes.
Clean, deep, mutation-honest, decision-documented. Closes #115. Merge-ready → Bosun.