Skip to content

Reconnect a frozen room socket on resume instead of trusting a stale 'open'#111

Merged
ankitsejwal merged 1 commit into
mainfrom
fix/room-stale-open-on-resume
Jul 9, 2026
Merged

Reconnect a frozen room socket on resume instead of trusting a stale 'open'#111
ankitsejwal merged 1 commit into
mainfrom
fix/room-stale-open-on-resume

Conversation

@ankitsejwal

Copy link
Copy Markdown
Member

Fixes #108.

The bug

An iOS background freeze suspends the JS event loop, so the room socket can die server-side with no onclose ever firingstatus stays a stale 'open'. Two things then went wrong on resume:

  1. The foreground handler only reconnected when the socket wasn't open (useLucidAsk.tsx, getStatus() !== 'open'). After a freeze the status reads 'open', so we skipped the reconnect and kept trusting a dead socket.
  2. A message sent in that window hit ws.send() on the dead socket and was orphaned — the payload lands in sent but never outbox, and only a retryable daemon error resends from sent. A dead-socket send gets no daemon response at all, so nothing resent it: it sat until the first-token timeout → "Lucid didn't respond", with the daemon never receiving it.

The fix

Judge liveness by the inbound-frame clock, not status:

  • resumeForeground() (called on AppState → active) forces a fresh dial when the socket looks dead — no inbound frame for >15s (STALE_INBOUND_MS) despite a server we've proven echoes. A genuinely live socket echoes a keepalive every ~10s, so it can't reach 15s while alive; the check is gated on pongsSeen > 0 and a socket that's been open a while, so a fresh open or a non-echoing server never false-positives.
  • enqueue() now routes a send to the outbox (and reconnects) instead of ws.send() whenever the socket only looks open. The fresh onopen flushes the outbox onto a live socket.

This makes status reflect reality before the user can type, which is the clean fix for the orphaned send — no "resend from sent" (which would risk double-running an ask against the room's frame replay).

Verification

  • tsc --noEmit clean. No behavior change on a healthy socket (the staleness check can't trigger while inbound is flowing).
  • Mobile has no unit-test harness in this repo, so this is typecheck- + review-verified; the logic is defensive (gated on pongsSeen and open-duration to avoid false reconnects).

Sibling issues from the same session: #107 (silent resume memory-wipe, fixed in #110) and #109 (diagnostics blind to the freeze).

An iOS background freeze suspends the JS timers, so the room socket can die
server-side with no `onclose` ever firing — `status` stays a stale 'open'. On
foreground we then skipped the reconnect (the guard was `!== 'open'`), and a
message sent in that window hit `ws.send` on a dead socket: kept in `sent` but
never `outbox`, so nothing resent it and it surfaced as "Lucid didn't respond"
while the daemon never received it.

Judge liveness by the inbound-frame clock, not `status`. `resumeForeground`
forces a fresh dial when the socket looks dead (no inbound for >15s despite an
echoing server), and `enqueue` routes a send to the outbox — reconnecting —
rather than into the void whenever the socket only looks open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A message sent right after unlocking the phone can silently get no reply

1 participant