fix(m13): Phase 1 — co-op survives real life (disconnects, ghosts, leaks) - #27
Merged
Conversation
A phone that sleeps on cellular never FINs; without a heartbeat its socket stays OPEN, room.phone stays claimed, and the documented manual rejoin gets 'room already has a phone' until kernel TCP times out. Standard isAlive/pong tracking + a periodic terminate() sweep; the existing 'close' cleanup then frees the phone slot or deletes the room (also bounding the rooms map against ghost games, F-26-adjacent). RELAY_HEARTBEAT_MS env override lets the smoke test fast-forward the sweep instead of waiting out the 30s default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h net clients (F-05, F-06) Lobby and Planet subscribed to the shared net client every create() and never unsubscribed: Planet's stale handlers re-cast every phone solve N+1 times (stacked banners + corrupted solveTimings telemetry), and Lobby's stale handler threw on destroyed Text objects — which, because the fan-out shared one try/catch and Lobby registered first, silently dropped that message for every later handler (the '● phone disconnected' indicator could never fire). Scenes now keep the unsubscribe and release it on SHUTDOWN; both clients isolate each handler in its own try/catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… role fields (F-18, F-35, F-36)
Peer departure was overloaded onto {type:'error'} and sniffed by
msg.message.includes('phone') on the game side, while the phone treated
a transient 'game disconnected' like any fatal relay error. The server
now sends {type:'peer-disconnected', peer} from its close handler —
server-originated, so no relayForward rule (the relay still never reads
state). Planet flips its link indicator on the typed message; the phone
shows a purpose-written notice and routes to fresh code entry (rooms
die with their game, so the old code is dead by definition).
Also per the audit: game-ready/availablePowers had no producer or
consumer anywhere (F-35), and the create/join role fields were never
read by the server — roles are implied by the handshake (F-36, the
'drop' arm of validate-or-drop). Both removed; protocol + server + both
clients + fixtures in this one commit per the boundary rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-tap rejoin (F-07) The phone never learned its socket died: no 'close' listener existed (the 'error' listener is removed once open) and send() silently no-oped when the socket wasn't OPEN — so a player returning to a backgrounded tab could solve a puzzle, see the big 'Cast!', and mint stardust while the laptop received nothing, indefinitely. PhoneNetClient now surfaces 'close' via onClose (guarded to fire only after a successful open and never for a deliberate close/reconnect) and send() returns whether the frame reached an OPEN socket. The App flips to a 'disconnected' phase that remembers the room code — the relay preserves the room, so rejoining is one tap — and onSolved routes a dead-socket solve there instead of faking cast-feedback or minting stardust. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… link indicator (F-16, F-17) GameNetClient logged socket death to the console and nothing else: relay down at boot left the Lobby on 'Connecting to relay…' forever, and a mid-planet drop kept the indicator green while send() no-oped. New single-consumer onClose surface (the active scene owns the link UI, cleared on SHUTDOWN like the message unsubscribe): Lobby flips its status line, Planet flips the indicator to '● connection lost'. A rejoining phone now also turns the indicator back green (F-16) — the red '● phone disconnected' used to stick even though the theme was re-announced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ Vitest (F-21) Room lifecycle (create/join/leave, membership, code minting) moves out of the socket layer into server/roomRegistry.ts — generic over the member handle, no ws import, no game state — mirroring how relayForward already isolates the forwarding policy. server.ts keeps only parse → registry/allowlist → send. 13 colocated cases cover create/join/ occupied/not-found/close cleanup and the post-sweep contract (phone slot freed → same-code rejoin; game gone → room deleted, count bounded) — the exact handler where both P0 crashes lived untested. Behavioral fix that fell out: a phone orphaned by its game leaving used to stay flagged 'already in a room' on that socket forever; the registry releases it with the room, so it can join a fresh code. Error strings and log lines are byte-identical to before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ouch scenario Three new live-socket steps (the Phase 1 gate's named scenario): a graceful phone close reaches the game as the dedicated peer-disconnected message; a ghost phone (autoPong:false — never answers pings, like a locked phone on cellular) is terminated by the heartbeat sweep with abnormal close 1006, the game is notified, and the slot frees; a fresh phone then rejoins the SAME room code and a cast round-trips. RELAY_HEARTBEAT_MS=300 fast-forwards the sweep so the whole scenario adds ~a second instead of a 30s wait. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of the 2026-07-09 audit fix plan — the disconnect/ghost lifecycle arc. Closes F-05, F-06, F-07, F-08, F-16, F-17, F-18, F-21, F-35, F-36 (+ bounds the rooms map, F-26-adjacent). Follows Phase 0 (#26).
What changed
server/server.ts, F-08/F-26): isAlive/pong + periodicterminate(). A ghost phone (locked on cellular, never FINs) is swept, its slot frees, and the documented same-code manual rejoin actually works; ghost games can no longer growroomsunboundedly.RELAY_HEARTBEAT_MSenv override (default 30s) lets the smoke fast-forward.onMessageunsubscribe and release it onSHUTDOWN; both net clients isolate each handler in its own try/catch. Kills the N+1 re-cast / corruptedsolveTimingstelemetry on replay and the Lobby stale-handler exception that starved Planet of every later message.PhoneNetClient.onClose(fires only post-open, never for a deliberate reconnect) +send()returns a boolean. Newdisconnectedphase: "Connection lost" panel with one-tap Rejoin+ "Enter a different code". A solve on a dead link routes there instead of faking "Cast!" and minting stardust.GameNetClient.onClose(also fires on boot-time failure) → Lobby status line "Relay unreachable — reload to retry"; Planet indicator "● connection lost".phone-joinedturns the indicator back green.{type:'peer-disconnected', peer}replaces the{type:'error'}+includes('phone')substring hack; deadgame-ready/availablePowersdeleted (zero producers/consumers); decorativerolefields dropped (the audit's "validate-or-drop" — roles are implied by the handshake). NorelayForwardrule — server-originated, never peer-forwarded.RoomRegistry(F-21): room lifecycle extracted from the connection handler (mirroringrelayForward), generic over the member handle, 13 colocated Vitest cases (create/join/occupied/not-found/close cleanup/post-sweep rejoin/room-count bound). Sockets stay inserver.ts; error strings byte-identical. Bonus fix that fell out: a phone orphaned by its game leaving is released, so the same socket can join a fresh room (previously stuck 'already in a room' forever).Gate (all green)
npm run typecheckclean ·npm run test175/175 (162 → +13 registry) ·npm run buildcleansmoke:relay: existing 8 steps + graceful-close →peer-disconnected; ghost (autoPong:false) swept by heartbeat (close 1006, game notified, slot freed); same-code rejoin → cast round-trips — the couch-playtest failure mode, over live socketsnpm run dev, real Chromium × 2 tabs): joinKDSJRR→ spellbook → QuickMath solve → freeze cast crossed the wire (lastCastPower, freeze cue) → phone tab closed → game shows red "● phone disconnected" (typed path) → same-code rejoin → "● phone linked" restored (F-16) → Trivia solve → illuminate cast landed (darkZonePresent:false) → relay killed under both clients → phone renders "Connection lost / Rejoin CUMSAF" (F-07) and Planet shows "● connection lost"; a fresh Lobby boot against the dead relay shows "Relay unreachable" (F-17 boot path)Notes for review
WebSocket, and the repo convention (CLAUDE.md) keeps browser-API/framework classes out of Vitest. Same substitution style Phase 0 used.peer-disconnectedhandling was written once, not twice. All six items are in.server/*,net/client.ts×2,Lobby.ts,Planet.tscreate-block,protocol.ts,App.tsx) barely overlap the m12 generator branches (Hub.ts/testBridge.ts/planets/); whichever lands second should rebase — expected conflicts: none to trivial.server/) are Phase 4.🤖 Generated with Claude Code