Skip to content

fix(mobile): recover when half-open sockets omit close events - #11368

Merged
Jinwoo-H merged 3 commits into
stablyai:mainfrom
himomohi:fix/mobile-half-open-transport-recovery
Jul 30, 2026
Merged

Jinwoo-H merged 3 commits into
stablyai:mainfrom
himomohi:fix/mobile-half-open-transport-recovery

Conversation

@himomohi

@himomohi himomohi commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Recover the mobile RPC transport when its existing liveness probe detects a half-open WebSocket but React Native does not deliver the socket's onclose callback.

The underlying MagicSock receive-loop failure remains outside Orca. Public Tailscale reports include the exact closed ReceiveDERP issue and the similar open iOS ReceiveIPv4/DERP issue; neither currently has a linked fix PR. A search of stablyai/orca found no exact ReceiveDERP issue or PR.

Orca already sends an application-level status.get probe every 20 seconds and waits 8 seconds for validated inbound activity. Previously, the timeout only called WebSocket.close(). If iOS kept the transport wedged and omitted onclose, Orca stayed visibly connected while RPC traffic was dead.

This change mirrors the existing connect-timeout recovery path: after closing the silent socket, it enters the normal close lifecycle only if that socket is still current. The existing lifecycle provides stream replay, delivery-ambiguity handling, bounded backoff, the retry cap/trickle policy, and stale-event fencing. Synchronous or late close events cannot create a second reconnect.

User impact: Orca can move from a false connected state into its existing bounded reconnect flow without requiring the user to restart the app. Manual Force Reconnect, pairing, relay selection, SSH, and folder workspaces are unchanged.

Screenshots

No visual change.

Testing

  • cd mobile && pnpm lint
  • cd mobile && pnpm typecheck
  • cd mobile && pnpm test — 354 files; 2,599 passed, 2 skipped
  • Focused transport/recovery suite — 6 files; 77 passed
  • Real-socket live recovery harness — 1 passed, 2 longer opt-in scenarios skipped
  • cd mobile && pnpm exec expo export --platform ios — iOS Hermes bundle exported successfully
  • Added a deterministic regression that suppresses onclose, proves the pre-fix client remains falsely connected, and verifies one replacement socket with no reconnect storm
  • git diff --check

The mobile workspace has no build script; the Expo iOS export is the available target bundle build used here. A signed native install was not performed.

AI Review Report

The same read-only reviewer assessed the baseline design and the frozen final diff. It independently reproduced the architectural gap, verified the binary diff SHA-256 and changed blob IDs, and returned PASS with no P0-P3 findings.

The review checked:

  • half-open detection within the existing 8-second probe grace;
  • single-flight recovery under synchronous, missing, and late close callbacks;
  • reuse of the existing bounded retry/backoff and trickle policy;
  • healthy probe replies and unrelated validated traffic;
  • pending RPC delivery ambiguity, stream replay, and timer cleanup;
  • unchanged Force Reconnect, pairing, relay, SSH, and folder-workspace boundaries;
  • cross-platform impact: the patch changes only generic React Native WebSocket lifecycle handling and adds no shortcuts, labels, paths, shell behavior, native modules, or Electron-specific behavior.

Security Audit

No new input surface, command execution, filesystem path, IPC method, dependency, credential, token, encryption, or authorization behavior is introduced. The patch does not log secrets or endpoints beyond existing redaction. It reuses the authenticated RPC client's existing close/reconnect path and its delivery-unknown marking for frames that may already have reached the host.

Notes

  • This mitigates the Orca symptom; it does not repair Tailscale MagicSock or add a relay.
  • The missing-onclose behavior is covered deterministically with a mocked React Native WebSocket.
  • The real-socket integration harness uses an in-process server, not a physical iPhone or a reproduced Tailscale ReceiveDERP outage.
  • Physical-device verification should reproduce a real iOS/Tailscale failure, confirm the app leaves the false connected state, and confirm one recovered session without duplicate terminal or notification streams.

Contributor

@himomohi
himomohi marked this pull request as ready for review July 29, 2026 13:04
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The RPC client now prevents overlapping foreground activity probes and clears the in-flight state across completion, failure, and timeout paths. Probe timeouts explicitly invoke socket-closure recovery after closing the active socket, including half-open sockets without a close callback. New tests verify reconnection to an authenticated socket, reset of the reconnect attempt counter, and coalescing of repeated foreground notifications into one probe.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main fix: recovering mobile transport when half-open sockets miss close events.
Description check ✅ Passed The description includes all required sections and gives detailed testing, review, security, and notes coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
mobile/src/transport/rpc-client.test.ts (1)

722-725: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that reconnect attempts reset after recovery.

The test verifies connected but not client.getReconnectAttempt(). Add the assertion to cover the stated recovery contract.

Suggested assertion
       openAndAuthenticate(mockSockets[1]!)
       expect(client.getState()).toBe('connected')
+      expect(client.getReconnectAttempt()).toBe(0)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3279c173-925c-4353-8b64-042c38501164

📥 Commits

Reviewing files that changed from the base of the PR and between 4543bb6 and bdae8f0fc27fb06b64d3ccd4547007c77e5193ee.

📒 Files selected for processing (2)
  • mobile/src/transport/rpc-client.test.ts
  • mobile/src/transport/rpc-client.ts

@greptile-apps

greptile-apps Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes a false-connected state on iOS where a half-open WebSocket's liveness probe correctly times out and calls close(), but React Native never delivers the onclose callback, leaving the client stuck as connected with dead RPC traffic.

  • Adds an activityProbeInFlight boolean to coalesce concurrent probes (e.g. rapid notifyForeground() calls), preventing multiple in-flight status.get requests from stacking up against the same socket.
  • After probeWs.close(), directly invokes handleSocketClosed when probeWs === ws still holds — mirroring the existing connect-timeout pattern — so the normal bounded-backoff reconnect lifecycle runs even when onclose is suppressed; the existing ws !== closedWs stale-event fence in handleSocketClosed prevents a double-close if a late onclose does eventually arrive.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped to the activity-probe timeout path, reuses the existing close/reconnect lifecycle, and cannot trigger on a healthy connection.

The recovery logic correctly mirrors the pre-existing connect-timeout pattern. The double probeWs === ws guard ensures the direct handleSocketClosed call is skipped whenever the normal onclose already ran and nulled ws, and the stale-socket check inside handleSocketClosed prevents a second run if a late onclose arrives afterward. The activityProbeInFlight flag is reset in every exit path. Two deterministic regression tests cover the missing-onclose scenario and probe-coalescing behaviour. No unrelated paths are touched.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
mobile/src/transport/rpc-client.ts Adds activityProbeInFlight guard to prevent concurrent probes, and directly calls handleSocketClosed after probeWs.close() to handle the iOS missing-onclose case — logic mirrors the existing connectTimer recovery path; stale-event fencing via the ws === probeWs double-check is correctly reasoned.
mobile/src/transport/rpc-client.test.ts Adds two focused regression tests: one that suppresses onclose and verifies a single reconnect with late-event fencing, and one that verifies probe coalescing under repeated notifyForeground calls; MockWebSocket gains emitCloseOnClose flag to simulate iOS behaviour.

Reviews (3): Last reviewed commit: "fix(mobile): coalesce half-open recovery..." | Re-trigger Greptile

@himomohi

Copy link
Copy Markdown
Contributor Author

Addressed the CodeRabbit nit in 17a18b4ad by explicitly asserting that the reconnect attempt counter resets to 0 after the replacement socket authenticates. Re-ran the focused regression, all 2,599 mobile tests, mobile typecheck, and mobile lint; all passed.

@Jinwoo-H
Jinwoo-H force-pushed the fix/mobile-half-open-transport-recovery branch from 17a18b4 to d421ca4 Compare July 30, 2026 06:23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 35883623-7d80-4eb3-8d41-999f1cb8b023

📥 Commits

Reviewing files that changed from the base of the PR and between 17a18b4ad2e69bb52607836070fe7086252a2877 and d421ca4.

📒 Files selected for processing (2)
  • mobile/src/transport/rpc-client.test.ts
  • mobile/src/transport/rpc-client.ts

Comment on lines +787 to +790
// Why: React Native can omit onclose for a wedged iOS transport.
if (probeWs === ws) {
handleSocketClosed(probeWs, { timedOut: true })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Recover when the current socket is no longer OPEN.

The fallback is nested under the OPEN check. If the silent socket is already CLOSING/CLOSED and omits onclose, this leaves state === 'connected' indefinitely. Keep the stale-socket check, close only when OPEN, then invoke handleSocketClosed for any still-current probe socket. Add a regression for a non-OPEN socket with no close callback.

Proposed fix
-      if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
+      if (probeWs !== ws) {
+        return
+      }
+      if (probeWs.readyState === WebSocket.OPEN) {
         probeWs.close()
-        // Why: React Native can omit onclose for a wedged iOS transport.
-        if (probeWs === ws) {
-          handleSocketClosed(probeWs, { timedOut: true })
-        }
+      }
+      // Why: React Native can omit onclose for a wedged iOS transport.
+      if (probeWs === ws) {
+        handleSocketClosed(probeWs, { timedOut: true })
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Why: React Native can omit onclose for a wedged iOS transport.
if (probeWs === ws) {
handleSocketClosed(probeWs, { timedOut: true })
}
if (probeWs !== ws) {
return
}
if (probeWs.readyState === WebSocket.OPEN) {
probeWs.close()
}
// Why: React Native can omit onclose for a wedged iOS transport.
if (probeWs === ws) {
handleSocketClosed(probeWs, { timedOut: true })
}

@Jinwoo-H
Jinwoo-H merged commit 89a9d4f into stablyai:main Jul 30, 2026
40 of 42 checks passed
Jinwoo-H pushed a commit that referenced this pull request Aug 4, 2026
Completes the 0.0.37 release attempted on 2026-08-03 (run 30791649691
failed on the version assertion). Ships the post-0.0.36 transport fixes:
relay session recovery when the LAN endpoint is unreachable (#12344,
#11368, #11465, #11690) and honest worktree-catalog failure states
(#12235) — the released-app defect class verified live tonight.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…ai#11368)

* fix(mobile): recover half-open RPC sockets

* test(mobile): assert reconnect attempt reset

* fix(mobile): coalesce half-open recovery probes

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
Completes the 0.0.37 release attempted on 2026-08-03 (run 30791649691
failed on the version assertion). Ships the post-0.0.36 transport fixes:
relay session recovery when the LAN endpoint is unreachable (stablyai#12344,
stablyai#11368, stablyai#11465, stablyai#11690) and honest worktree-catalog failure states
(stablyai#12235) — the released-app defect class verified live tonight.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.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.

MagicSock function ReceiveDERP not running

3 participants