Skip to content

fix(client): always force WS reconnect on iOS foreground return#367

Merged
dimakis merged 1 commit into
mainfrom
fix/ios-first-message-lost
Jun 5, 2026
Merged

fix(client): always force WS reconnect on iOS foreground return#367
dimakis merged 1 commit into
mainfrom
fix/ios-first-message-lost

Conversation

@dimakis
Copy link
Copy Markdown
Owner

@dimakis dimakis commented Jun 5, 2026

Summary

  • iOS kills WebSocket connections during background without updating readyState
  • checkAndReconnect() trusted readyState — if the socket appeared OPEN, it did nothing
  • First message sent on the dead socket was silently lost (regression from prior reconnect work)
  • Fix: always tear down the old socket and force a fresh connection on foreground return
  • Sends during the reconnect window queue in pendingSends and flush after the welcome handshake

This is a regression — previously both messages reached the agent (even if a double-send was needed to wake it up). Now the first message is silently dropped into a dead socket.

Root cause

connection.ts:328-336 — the readyState !== OPEN guard:

// BEFORE (broken)
checkAndReconnect(): void {
    if (!this.ws || this.ws.readyState !== WS_READY_STATE.OPEN) {
        // only reconnects if socket is visibly dead
    }
    // if readyState appears OPEN → does NOTHING
}

iOS kills the TCP connection during background but the JS readyState property stays OPEN until a send/recv actually fails. The heartbeat (5s interval) also checks readyState !== OPEN, so it has the same blind spot. The first user message fires into the void.

Test plan

  • Background Mitzo on iOS, wait 30+ seconds, return to foreground
  • Send a message — verify it reaches the agent on the first try
  • Verify rapid foreground/background cycling doesn't cause double connections (reconnectTimer guard)
  • Verify desktop browser tab switching still works (visibilitychange path)

🤖 Generated with Claude Code

iOS kills WebSocket connections during background without updating
readyState. checkAndReconnect() previously trusted readyState, so if
the socket appeared OPEN the function did nothing — first message sent
on the dead socket was silently lost.

Remove the readyState guard: always tear down the old socket and create
a fresh connection. Sends during the reconnect window queue in
pendingSends and flush after the welcome handshake completes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis
Copy link
Copy Markdown
Owner Author

dimakis commented Jun 5, 2026

Centaur Review

Found 4 issue(s) (2 warning).

packages/client/src/connection.ts

Correct fix for a real iOS bug (stale readyState after background). Main concern is that defuseOldWs doesn't close() the old socket, leaving orphaned connections on desktop tab switches — adding ws.close() before nulling would be a clean improvement. Missing tests for a behavior change in a TDD project.

  • 🟡 regressions (L339): defuseOldWs() detaches handlers and drops the reference but never calls ws.close(). Previously this path only ran for dead sockets; now it runs unconditionally on every foreground return — including desktop tab switches where the socket is still healthy. The orphaned open socket stays alive on both client and server until GC or timeout, briefly creating two server-side connections per client. Consider calling ws.close() before nulling the reference (the detached onclose=null already prevents the handler from interfering). [fixable]
  • 🔵 regressions (L337): On desktop browsers, every visibilitychange→visible (any tab switch back) now tears down a healthy WebSocket and forces a full reconnect + hello/welcome handshake. This causes a brief _close→_open state transition on every tab switch, which may produce UI flicker in downstream listeners. The tradeoff is valid for iOS, but you could gate the unconditional teardown behind a platform check or at least skip it when the socket has recently proven alive (e.g., received a message within the last heartbeat interval). [fixable]
  • 🟡 missing_tests: The @mitzo/client package has zero test files. This PR changes a behavioral contract (conditional reconnect → unconditional teardown) with implications for both iOS and desktop. Given the project's TDD requirement, at minimum the new behavior should have tests: (1) checkAndReconnect tears down an OPEN socket and creates a new one, (2) reconnectTimer guard prevents double-reconnect, (3) sends during reconnect window queue in pendingSends. Pre-existing gap, but this change makes it more consequential. [fixable]
  • 🔵 style (L268): The heartbeat (line 268) still conditionally checks readyState before reconnecting, while checkAndReconnect now unconditionally tears down. The inconsistency is intentional (heartbeat fires every 5s in foreground, so it should only catch dead sockets), but a brief inline comment noting why the heartbeat keeps its guard while checkAndReconnect doesn't would prevent a future reader from "fixing" the heartbeat to match. [fixable]

@dimakis dimakis merged commit dde5dd6 into main Jun 5, 2026
1 check passed
dimakis added a commit that referenced this pull request Jun 5, 2026
dimakis added a commit that referenced this pull request Jun 5, 2026
* fix(client): always force WS reconnect on iOS foreground return (#367)

iOS kills WebSocket connections during background without updating
readyState. checkAndReconnect() previously trusted readyState, so if
the socket appeared OPEN the function did nothing — first message sent
on the dead socket was silently lost.

Remove the readyState guard: always tear down the old socket and create
a fresh connection. Sends during the reconnect window queue in
pendingSends and flush after the welcome handshake completes.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address Centaur review — platform guard, socket leak, tests

- Add force parameter to checkAndReconnect(): iOS (via forceReconnect)
  always forces, desktop browser visibility events use default (false)
  which preserves healthy sockets
- Close old socket in defuseOldWs() to prevent server-side connection
  leak from orphaned live sockets
- Add 6 tests covering: force vs default behavior, reconnectTimer
  guard, pending send flush after forced reconnect, socket close

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant