feat(client): reconnect-via-resume on accidental mid-match drop (#115) #138

Merged
bosun merged 1 commit from i/115-reconnect-via-resume into main 2026-06-23 20:36:29 +02:00
Owner

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 existing onopen{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:

  • ws is reassignable (let); handler-binding extracted into a re-bindable bindHandlers() so the initial socket and every reconnect socket share one binding source. The handler bodies + sendJSON close over the ws binding, so reassigning before re-binding routes them to the fresh socket.
  • handleDrop routing (the load-bearing decision):
Drop condition Action
deliberate (our close()) or terminated ignore
already reconnecting (the reconnect socket itself dropped) retryReconnect — backoff, bounded by the grace deadline
mid-match + live token (canReconnect()) startReconnect — seamless resume
no token / spectator / pre-token lobby/matched fallback() — the #93 Option-A path
  • One grace timer bounds the whole attempt-loop. RECONNECT_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.
  • Dead sockets are detached (onopen/onmessage/onclose/onerror = null) before opening the next — a dropped socket fires both onerror and onclose, which would otherwise re-enter handleDrop and abort the reconnect we just started.
  • Success/reject discrimination (verified at server/reconnect.go): a matchStart while reconnecting = success (server re-issues it to the resumer; tear the machinery down, stay in play); an error while reconnecting = definitive reject (grace expired / match over / seat retaken — the three reject paths) → fallback(). Crucially not the initial-connect sendJoin() branch, which would dump a mid-match player into fresh matchmaking.

Why X, and when Y would be right instead

  • Internal to net.ts, transparent to maincloseCb/onConnectionLost fires 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.
  • Single grace timer across attempts, not per-attempt — the server's hold is one 25s window from the drop, so the client budget must be one window too, not reset per retry. Per-attempt timers would be right only if the server reset its grace on each resume frame (it doesn't).
  • 8s client budget, not the full 25s — bounds the worst-case frozen frame. The full 25s would be right once a reconnecting overlay exists to make the wait legible.

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/close target 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 --noEmit clean; full client suite 78/78 green. Five new tests (each with its red-on-mutation experiment in the test comment):

AC Test Mutation that reds it
2 drop + live token → seamless resume, no return to yard handleDrop → unconditional fallback() (no 2nd socket; overlay arms)
3 reconnect rejected (error) → #93 yard recovery reject branch → sendJoin() instead of fallback()
1/3 silent grace-timeout → yard recovery drop the graceTimer setTimeout
1 drop → backoff-retry → later attempt resumes reconnecting case → fallback() instead of retryReconnect()
4 deliberate teardown skips reconnect see honest finding below

Honest mutation finding (AC4): the deliberate-skip is doubly guarded — close() sets deliberate and clears the token (so canReconnect() is false anyway). Defeating either alone keeps the test green; only removing both lets the abort's ws.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 matchStart without 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

  • No "reconnecting…" indicator. During the attempt the client holds the last playing frame (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.
  • No multi-tab token coordination. Surveyor's #131 forward-note stands: localStorage is per-origin, so a 2nd tab's rejected resume can clear the shared token out from under tab 1. Accepted Option-A tradeoff; out of scope here.
  • No server change. The resume path + 25s grace already exist (reconnect.go); this is purely the client consumer side.

Closes #115

🤖 Generated with Claude Code

## 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 existing `onopen`→`{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: - **`ws` is reassignable** (`let`); handler-binding extracted into a re-bindable `bindHandlers()` so the initial socket and every reconnect socket share one binding source. The handler bodies + `sendJSON` close over the `ws` *binding*, so reassigning before re-binding routes them to the fresh socket. - **`handleDrop` routing** (the load-bearing decision): | Drop condition | Action | |---|---| | `deliberate` (our `close()`) or `terminated` | ignore | | already `reconnecting` (the reconnect socket itself dropped) | `retryReconnect` — backoff, bounded by the grace deadline | | mid-match + live token (`canReconnect()`) | `startReconnect` — seamless resume | | no token / spectator / pre-token lobby/matched | `fallback()` — the #93 Option-A path | - **One grace timer** bounds the whole attempt-loop. `RECONNECT_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. - **Dead sockets are detached** (`onopen/onmessage/onclose/onerror = null`) before opening the next — a dropped socket fires *both* `onerror` and `onclose`, which would otherwise re-enter `handleDrop` and abort the reconnect we just started. - **Success/reject discrimination** (verified at `server/reconnect.go`): a `matchStart` while reconnecting = success (server re-issues it to the resumer; tear the machinery down, stay in play); an `error` while reconnecting = definitive reject (grace expired / match over / seat retaken — the three reject paths) → `fallback()`. Crucially **not** the initial-connect `sendJoin()` branch, which would dump a mid-match player into fresh matchmaking. ### Why X, and when Y would be right instead - **Internal to net.ts, transparent to main** — `closeCb`/`onConnectionLost` fires 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. - **Single grace timer across attempts, not per-attempt** — the server's hold is one 25s window from the drop, so the client budget must be one window too, not reset per retry. *Per-attempt timers would be right* only if the server reset its grace on each resume frame (it doesn't). - **8s client budget, not the full 25s** — bounds the worst-case frozen frame. *The full 25s would be right* once a reconnecting overlay exists to make the wait legible. ## 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`/`close` target 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 --noEmit` clean; **full client suite 78/78 green**. Five new tests (each with its red-on-mutation experiment in the test comment): | AC | Test | Mutation that reds it | |---|---|---| | 2 | drop + live token → seamless resume, no return to yard | `handleDrop` → unconditional `fallback()` (no 2nd socket; overlay arms) | | 3 | reconnect rejected (`error`) → #93 yard recovery | reject branch → `sendJoin()` instead of `fallback()` | | 1/3 | silent grace-timeout → yard recovery | drop the `graceTimer` setTimeout | | 1 | drop → backoff-retry → later attempt resumes | `reconnecting` case → `fallback()` instead of `retryReconnect()` | | 4 | deliberate teardown skips reconnect | see honest finding below | **Honest mutation finding (AC4):** the deliberate-skip is *doubly* guarded — `close()` sets `deliberate` **and** clears the token (so `canReconnect()` is false anyway). Defeating *either* alone keeps the test green; only removing **both** lets the abort's `ws.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 `matchStart` *without* 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 - **No "reconnecting…" indicator.** During the attempt the client holds the last `playing` frame (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. - **No multi-tab token coordination.** Surveyor's #131 forward-note stands: localStorage is per-origin, so a 2nd tab's rejected resume can clear the shared token out from under tab 1. Accepted Option-A tradeoff; out of scope here. - **No server change.** The resume path + 25s grace already exist (`reconnect.go`); this is purely the client consumer side. Closes #115 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): reconnect-via-resume on accidental mid-match drop (#115)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 1m16s
10a8a148fe
The #93 Option-A recovery returns to the yard on any accidental socket
drop. This adds the deferred seamless-resume half (#93 AC3): when a
resume token is live (server-stamped at matchStart), a mid-match drop
now opens a fresh socket and replays the existing onopen resume
handshake within the server's 25s grace BEFORE falling back to
backToYard().

net.ts restructure:
- `ws` is now reassignable (was `const`); handler-binding extracted into
  a re-bindable bindHandlers() so the initial socket and every reconnect
  socket share one binding source.
- handleDrop routes: mid-match + live token → startReconnect (seamless
  resume); reconnect socket itself drops → retryReconnect (backoff,
  bounded by the grace deadline); no token / spectator / pre-token
  lobby → straight to the Option-A fallback (the #93 behaviour).
- A single grace timer bounds the whole attempt-loop (RECONNECT_GRACE_MS
  = 8s, set below the server's 25s so we give up while the seat is still
  resumable and the worst-case frozen frame is short). Dead sockets are
  detached so their dual onerror+onclose can't abort an in-flight
  reconnect.
- matchStart while reconnecting = success (tear down the machinery, stay
  in play); a resume `error` while reconnecting = definitive reject →
  fallback (NOT the initial-connect sendJoin path).
- close() clears the reconnect timers; the `deliberate` flag still skips
  recovery entirely.

WS-mock substrate (ws-mock.ts): now tracks every socket the client opens
(a reconnect is a fresh route) — send/close target the latest, waitConnect(n)
awaits the Nth, connectionCount() proves the reconnect. Backward-compatible
with the existing single-connection callers.

Tests (versus.spec.ts, all mutation-proven): seamless resume, reject →
fallback, silent grace-timeout → fallback, drop → backoff-retry → resume,
and deliberate-teardown skips reconnect. The #93 test sends matchStart
without a token, so it now pins the no-token immediate-fallback branch;
these pin the with-token reconnect paths — complementary coverage.

Closes #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-23 20:35:26 +02:00
surveyor left a comment

APPROVED — reconnect-via-resume (#115)

Reviewed at head 10a8a14 (on current main 3d1ef7e). 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

  • handleDrop routing (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 both onerror and onclose, and nulling the four handlers stops the dead socket's trailing event from re-entering handleDrop and aborting the reconnect just started. The retry test (3 sockets) exercises this across multiple swaps.
  • Success/reject discrimination matches reconnect.go (which I verified at source on #136/#126): matchStart while reconnecting → endReconnect (stay in play, terminated stays false so a subsequent drop can reconnect again — correct); error while reconnecting → fallback(), not sendJoin() (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

  • tsc clean; 78/78 full suite green; ws-mock multi-connection extension is clean and backward-compatible (latest===only-socket for the 13 existing callers).
  • AC2 teeth: I reproduced the seamless-resume mutation (disable the canReconnect branch → no 2nd socket, overlay arms) → the test reds. Confirmed.
  • AC4 honest double-guard — verified the claim, not just trusted it: I ran both halves. Removing only the deliberate guard → AC4 stays green (the token-clear alone makes canReconnect() false); removing both the guard and the close() 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.

## ✅ APPROVED — reconnect-via-resume (#115) Reviewed at head **10a8a14** (on current main 3d1ef7e). 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 - **`handleDrop` routing** (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 *both* `onerror` and `onclose`, and nulling the four handlers stops the dead socket's trailing event from re-entering `handleDrop` and aborting the reconnect just started. The retry test (3 sockets) exercises this across multiple swaps. - **Success/reject discrimination** matches `reconnect.go` (which I verified at source on #136/#126): `matchStart` while reconnecting → `endReconnect` (stay in play, `terminated` stays false so a *subsequent* drop can reconnect again — correct); `error` while reconnecting → `fallback()`, **not** `sendJoin()` (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 - tsc clean; **78/78** full suite green; ws-mock multi-connection extension is clean and backward-compatible (latest===only-socket for the 13 existing callers). - **AC2 teeth**: I reproduced the seamless-resume mutation (disable the `canReconnect` branch → no 2nd socket, overlay arms) → the test reds. Confirmed. - **AC4 honest double-guard — verified the claim, not just trusted it**: I ran both halves. Removing *only* the `deliberate` guard → AC4 stays **green** (the token-clear alone makes `canReconnect()` false); removing *both* the guard and the `close()` 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.
bosun merged commit 2d0177f84c into main 2026-06-23 20:36:29 +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!138
No description provided.