fix(client): persist resume token in localStorage so it survives browser restart (#123) #131
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/123-localstorage-resume-token"
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
Operator playtest (post round-22): on mobile, quitting the browser entirely and reopening failed to resume the in-flight versus match. Wave-0 probe traced the root cause to the resume token living in
sessionStorage— which the browser clears when the origin's last tab closes. A page reload keeps sessionStorage, but a full browser quit→reopen does not, so the token was gone before the client could resume (within the server's 25s grace).Operator-ratified fix (A): store the token in
localStorage, which persists across a full browser restart. Swaps the five token call sites innet.ts(getItem/setItem/removeItem). No change to the resume code path — sameLS_TOKENkey, same onopen→resume-or-joinlogic; only the backing store changes.Self-healing edges (unchanged paths, now reachable)
{type:'resume',token}→ server resumes the match.resume→ the existingerror-case (net.ts) clears the token and falls back to a freshjoin. A dead token never wedges the client.Substrate-of-record: the misleading name
The const was already named
LS_TOKEN— the "LS" implies localStorage — while the implementation usedsessionStorage. That naming/impl mismatch is precisely what made the bug easy to miss on a read: the name asserted the persistence the code didn't have. The swap makes the name honest. (Banked instance of misleading-naming-creates-inheritance-hazards-downstream.) A code comment at the const now documents the persistence semantics + the grace/self-healing contract so the next reader doesn't re-introduce the mismatch.Verification
versus.spec.ts): seeds a token inlocalStorage, connects, asserts the client's first frame is{type:'resume',token:'tok-123'}not{type:'join'}. This runs the realnet.tsonopen path in a real browser page.sessionStorage.getItemmakes the seeded localStorage token invisible → client sendsjoin→sawType('resume')stays false → test reds (verified locally, then restored by re-edit).npx tsc --noEmitclean.Scope boundary — what this PR does NOT do
Closes #123
🤖 Generated with Claude Code
The resume token was stored in sessionStorage, which the browser clears when the origin's last tab closes. That is exactly the mobile "quit browser → reopen" case from the operator playtest: the token was gone on reopen, so the client could not resume the in-flight match (within the server's 25s grace). Swap the five token call sites to localStorage, which persists across a full browser restart. Within grace the reopened client now resumes; beyond grace (or a stale/cross-tab token) the server rejects the resume and the existing 'error' path clears the token and falls back to a fresh join, so a dead token never wedges the client. The const was already named LS_TOKEN ("LS" = localStorage) while the impl used sessionStorage — a misleading-name inheritance hazard. The name is now honest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG✅ APPROVED — sessionStorage→localStorage resume-token swap (#123, Option A)
Reviewed at head
0d3c3ab(on current main0889c5a, mergeable). Small, bounded, and the safety reasoning checks out at source.Swap is complete + consistent
All 5 token sites now use localStorage —
grepconfirms zerosessionStorageleft in code (only the explanatory comment references the history): onopen read (117), matchStart write (166), matchEnd clear (192), error/resume-fail clear (226), close clear (267). No partial-swap. TheLS_TOKENname is now honest (it was the misleading-name-is-inheritance-hazard instance — a future reader would have assumed localStorage; the swap resolves the latent trap rather than perpetuating it).The safety claims are substrate-accurate (verified at source)
The net.ts comment claims "a dead/stale/cross-tab token never wedges the client — the server rejects, the error path clears it, falls back to join." Confirmed in
reconnect.go:"resume: unknown or expired token"error (191-193)"resume: match already over"error (201)"resume: seat is still active"error (207)So the client's
error-handler (pendingResume → clear LS_TOKEN +sendJoin()) self-heals every rejection. Notably the "seat is still active" path means a second tab resuming an active seat is rejected — it does not boot the live tab. Good.Verification
tsc --noEmitclean; 69/69 suite green (the global swap regresses nothing).{type:resume,token}notjoin), with an honest scope note (cross-restart persistence is a browser-storage guarantee; the test pins reading from the store that has the token). Mutation reproduced: revert onopenlocalStorage.getItem→sessionStorage.getItem→ the seeded token is invisible → client sendsjoin→ the test reds. Clean discrimination.Forward-note (non-blocking, inherent to Option A)
localStorage is shared across tabs where sessionStorage was per-tab. Consequence: if a 2nd tab is opened during an active match, its rejected resume clears the shared token, so the 1st tab silently loses its resume-on-drop. It's uncommon (mobile-primary = single tab), non-catastrophic (no boot — the server protects the active seat), and an accepted tradeoff of the localStorage choice — just worth being on record if multi-tab resume-ability ever matters. No change needed here.
Clean, complete swap with source-verified safety. Closes #123. Merge-ready → Bosun.