fix(mobile): recover Android remote sessions without an app restart (#5061)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-09 20:55:20 -04:00 committed by GitHub
parent 6d2646df1e
commit 93e59ab086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 798 additions and 169 deletions

View File

@ -11,7 +11,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import type { RpcSuccess } from '../../../../src/transport/types'
import { triggerError, triggerSelection } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
@ -104,17 +105,6 @@ function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
}
const pathPart = worktreeId.includes('::')
? worktreeId.slice(worktreeId.indexOf('::') + 2)
: worktreeId
const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
}
export default function MobileFileExplorerScreen() {
const { hostId, worktreeId, name } = useLocalSearchParams<{
hostId: string
@ -123,6 +113,7 @@ export default function MobileFileExplorerScreen() {
}>()
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [files, setFiles] = useState<MobileFileEntry[]>([])
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
@ -287,7 +278,15 @@ export default function MobileFileExplorerScreen() {
) : error ? (
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={() => void loadFiles()}>
{/* Why: while disconnected, re-sending the request is useless revive
the parked transport instead (issue #5049); loadFiles re-runs via
its effect once the new client connects. */}
<Pressable
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>

View File

@ -46,7 +46,13 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { useHostClient } from '../../../../src/transport/client-context'
import {
useHostClient,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
} from '../../../../src/transport/client-context'
import { classifyConnection } from '../../../../src/transport/connection-health'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
@ -924,6 +930,9 @@ export default function SessionScreen() {
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : ''
const [terminals, setTerminals] = useState<Terminal[]>([])
const terminalsRef = useRef<Terminal[]>([])
@ -3640,6 +3649,17 @@ export default function SessionScreen() {
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
// Why: the reconnect loop parks at its give-up cap; without an in-session
// affordance the only recovery is leaving the screen or restarting the
// app (issue #5049). Surface tap-to-retry once the verdict escalates.
const connectionVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
const terminalSummary =
connState === 'connected'
? showLoadingState
@ -3647,7 +3667,9 @@ export default function SessionScreen() {
: visibleTabs.length === 1
? '1 tab'
: `${visibleTabs.length} tabs`
: STATUS_LABELS[connState]
: showConnectionRetry
? `${connectionVerdict.label} — tap to retry`
: STATUS_LABELS[connState]
// Why: keep safe-area padding in layout at all times, then visually translate
// the controls over the terminal when the keyboard appears. iOS keyboard
@ -3790,12 +3812,22 @@ export default function SessionScreen() {
<Text style={styles.sessionTitle} numberOfLines={1}>
{worktreeName || 'Terminal'}
</Text>
<View style={styles.sessionMetaRow}>
<Pressable
style={styles.sessionMetaRow}
disabled={!showConnectionRetry}
onPress={() => {
if (hostId) {
void forceReconnectHost(hostId)
}
}}
accessibilityRole={showConnectionRetry ? 'button' : undefined}
accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
>
<StatusDot state={connState} />
<Text style={styles.sessionMetaText} numberOfLines={1}>
{terminalSummary}
</Text>
</View>
</Pressable>
</View>
<Pressable
style={({ pressed }) => [styles.filesButton, pressed && styles.filesButtonPressed]}

View File

@ -30,7 +30,8 @@ import {
Trash2,
X
} from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../../src/transport/types'
import {
@ -200,17 +201,6 @@ async function resolveMobileBranchCompareBaseRef(
return result.defaultBaseRef?.trim() || null
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
}
const pathPart = worktreeId.includes('::')
? worktreeId.slice(worktreeId.indexOf('::') + 2)
: worktreeId
const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
}
function formatBranchLabel(branch: string | undefined, head: string | undefined): string {
if (branch?.startsWith('refs/heads/')) {
return branch.slice('refs/heads/'.length)
@ -249,6 +239,7 @@ export default function MobileSourceControlScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [screenState, setScreenState] = useState<ScreenState>({ kind: 'loading' })
const [branchCompareState, setBranchCompareState] = useState<MobileBranchCompareState>({
kind: 'idle'
@ -1431,7 +1422,20 @@ export default function MobileSourceControlScreen() {
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable style={styles.retryButton} onPress={() => void loadStatus()}>
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}

View File

@ -0,0 +1,92 @@
# Issue #5049: Android Remote Session Unresponsiveness — Findings
Date: 2026-06-09
Issue: https://github.com/stablyai/orca/issues/5049
## Reported symptoms
Android + Tailscale remote session intermittently becomes unresponsive: tab/worktree
taps do nothing, pasted text doesn't execute, the connection "appears stuck instead
of clearly disconnected", and closing/reopening the app restores the session.
## Root causes found (mobile-side)
All three independently produce the exact reported symptom — a session that looks
alive but ignores input, recoverable only by an app restart:
1. **Parked reconnect loop with no recovery path (primary).** `rpc-client.ts`
stops retrying permanently after `GIVE_UP_AFTER_ATTEMPTS` (12 attempts ≈ 6.5 min
of backoff). Android backgrounding + Doze + a Tailscale tunnel drop routinely
burns through all 12 attempts while the user is away. Nothing ever restarted the
loop: there was **no AppState listener anywhere in the transport layer**, so
returning to the foreground did not nudge the client. The state stays
`'reconnecting'` forever ("appears stuck instead of clearly disconnected").
Reopening the app creates a fresh client with a fresh attempt budget — which is
exactly why "closing and reopening usually restores the session".
2. **Half-open socket detection waits up to ~28s, and never starts earlier on
resume.** Android can kill the TCP path while backgrounded without delivering
`onclose`; `readyState` still reads OPEN, so every `terminal.send` (e.g. paste)
silently blackholes. The activity probe (20s interval + 8s timeout) eventually
reaps the link, but the first ~28s after resume look like "pasted text does not
run immediately" / "switching is very slow".
3. **Stale client after `forceReconnect` (pre-existing `useHostClient` bug).**
`forceReconnect` swaps in a fresh `RpcClient`, but `useHostClient` only re-read
the client when its ref was still `null`. Any mounted screen kept driving the
old, **closed** client forever: the status header (fed by provider-level state
listeners) shows "Connected" while every RPC instantly fails with "Client
closed" — a session that looks alive but ignores all input.
Additionally, the session screen (where users actually live) had no recovery
affordance at all: just a status label, while the Retry buttons exist only on
the home/host/tasks screens.
## Fixes
- `src/transport/rpc-client.ts` — new `notifyForeground()`:
- state `connected` → restart the probe interval and run one probe immediately
(half-open link reaped in ≤8s instead of ≤28s);
- state `reconnecting` → clear any pending backoff timer, reset the attempt
budget, reconnect immediately (un-parks the give-up cap).
- (Also extracted the duplicated close/error event serialization into
`socket-event-debug.ts` to stay under the file's line cap.)
- `src/transport/client-context.tsx`:
- `RpcClientProvider` now listens to AppState and calls `notifyForeground()` on
every live client when the app becomes active.
- `useHostClient` re-reads the underlying client on every state change, so
screens pick up the fresh client after `forceReconnect` instead of driving a
closed one.
- `app/h/[hostId]/session/[worktreeId].tsx` — the status row in the session header
becomes tappable once `classifyConnection` escalates to warning/unreachable,
showing "<label> — tap to retry" and invoking `forceReconnect`.
## Repro harnesses
- `src/transport/rpc-client.test.ts``foreground recovery` describe block:
deterministic fake-timer repro of the parked loop (proves it never self-recovers)
plus regression coverage for all `notifyForeground()` paths.
- `src/transport/rpc-client-live-recovery.test.ts`: opt-in live harness running the
REAL rpc-client (real sockets, real tweetnacl E2EE, real timers) against an
in-process ws server with a blackhole toggle:
- `ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/rpc-client-live-recovery.test.ts`
— half-open-link scenario (~15s).
- `ORCA_MOBILE_LIVE_REPRO_FULL=1 …` — full parked-loop scenario (~8.5 min): waits
out all 12 backoff attempts, proves the loop stays parked even after the server
returns, then proves `notifyForeground()` recovers it.
## Not addressed (out of scope, noted for future work)
- The diagnostics in `rpc-client.ts` mention a suspected RN/OkHttp process-state
poisoning mode (every open instantly fails with 1006 until force-quit). If that
mode is real, a foreground nudge reconnect attempt would also fail; the existing
`[net]` logs (wsCount / msSinceLast\*) are designed to confirm or rule it out from
device logs.
## Follow-up audit (same PR)
- `connection-revival-triggers.ts` (via `expo-network`) extends the foreground
nudge to network restoration and Wi-Fi → cellular handoffs.
- Files and source-control screens' Retry buttons now revive the transport
(`forceReconnect`) when disconnected instead of pointlessly re-sending the
request into a parked connection.

View File

@ -27,6 +27,7 @@
"expo-haptics": "^55.0.14",
"expo-linking": "^55.0.15",
"expo-modules-core": "~55.0.25",
"expo-network": "~55.0.14",
"expo-notifications": "^55.0.22",
"expo-router": "^55.0.14",
"expo-secure-store": "^55.0.13",

View File

@ -47,6 +47,9 @@ importers:
expo-modules-core:
specifier: ~55.0.25
version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-network:
specifier: ~55.0.14
version: 55.0.14(expo@55.0.23)(react@19.2.6)
expo-notifications:
specifier: ^55.0.22
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
@ -1424,56 +1427,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@ -1546,56 +1541,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
@ -3421,6 +3408,12 @@ packages:
react-native-worklets:
optional: true
expo-network@55.0.14:
resolution: {integrity: sha512-Sy544zTPjVh+tbOLUOU8fBX87oRSrNQqUZY6TLO0w0WF/QTNb7yxlwRh6v6wfKKRg9xpZypTIIEtdG/s6q8ZQA==}
peerDependencies:
expo: '*'
react: '*'
expo-notifications@55.0.22:
resolution: {integrity: sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==}
peerDependencies:
@ -10030,6 +10023,11 @@ snapshots:
optionalDependencies:
react-native-worklets: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-network@55.0.14(expo@55.0.23)(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
expo-notifications@55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.14(typescript@5.9.3)

View File

@ -0,0 +1,12 @@
// Why: worktree ids encode `repo::path`; screens that only receive the id
// (deep links, route params without a name) still need a human label.
export function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
}
const pathPart = worktreeId.includes('::')
? worktreeId.slice(worktreeId.indexOf('::') + 2)
: worktreeId
const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
}

View File

@ -20,6 +20,7 @@ import {
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
@ -320,6 +321,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Why: nudge every live client when the OS signals the link may be back
// (foreground, network restored/switched) so sessions recover without an
// app restart (issue #5049).
useEffect(() => {
return subscribeConnectionRevivalTriggers(() => {
for (const entry of storeRef.current.values()) {
entry.client.notifyForeground()
}
})
}, [])
const value = useMemo<ContextValue>(
() => ({
acquire,
@ -387,16 +399,13 @@ export function useHostClient(hostId: string | undefined): {
return
}
setState(next)
// Why: if the client was null at first acquire (async open), the
// first state change ('connecting'/'handshaking'/'connected') is our
// signal to re-read.
if (clientRef.current == null) {
const all = ctx.getAllClients()
const found = all.find((entry) => entry.hostId === hostId)
if (found) {
clientRef.current = found.client
force((n) => n + 1)
}
// Why: the client materialises after an async open, and forceReconnect
// swaps in a fresh client object. Re-read on every state change so a
// mounted screen never keeps driving a stale (closed) client.
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)

View File

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
type AppStateListener = (next: string) => void
type NetworkSnapshot = { isConnected?: boolean; type?: string }
type NetworkListener = (state: NetworkSnapshot) => void
let appStateListener: AppStateListener | null = null
let networkListener: NetworkListener | null = null
let seededNetworkState: NetworkSnapshot = { isConnected: true, type: 'WIFI' }
const appStateRemove = vi.fn()
const networkRemove = vi.fn()
vi.mock('react-native', () => ({
AppState: {
addEventListener: (_event: string, listener: AppStateListener) => {
appStateListener = listener
return { remove: appStateRemove }
}
}
}))
vi.mock('expo-network', () => ({
getNetworkStateAsync: () => Promise.resolve(seededNetworkState),
addNetworkStateListener: (listener: NetworkListener) => {
networkListener = listener
return { remove: networkRemove }
}
}))
// Why: the baseline seed resolves on a microtask; flush it so listener
// events in the test observe the same ordering as a real subscription.
async function subscribeAndSeed(nudge: () => void): Promise<() => void> {
const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
await Promise.resolve()
return unsubscribe
}
describe('subscribeConnectionRevivalTriggers', () => {
let nudge: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
appStateListener = null
networkListener = null
seededNetworkState = { isConnected: true, type: 'WIFI' }
nudge = vi.fn()
})
it('nudges when the app returns to the foreground, not on background', async () => {
await subscribeAndSeed(nudge)
appStateListener?.('background')
expect(nudge).not.toHaveBeenCalled()
appStateListener?.('active')
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges when the network comes back online', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: false, type: 'NONE' })
expect(nudge).not.toHaveBeenCalled()
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges when the app started offline and the first event is the recovery', async () => {
seededNetworkState = { isConnected: false, type: 'NONE' }
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('nudges on a Wi-Fi to cellular handoff that never reports offline', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'CELLULAR' })
expect(nudge).toHaveBeenCalledTimes(1)
})
it('stays quiet when the network state matches the seeded baseline', async () => {
await subscribeAndSeed(nudge)
networkListener?.({ isConnected: true, type: 'WIFI' })
networkListener?.({ isConnected: true, type: 'WIFI' })
expect(nudge).not.toHaveBeenCalled()
})
it('ignores a stale seed that resolves after unsubscribe', async () => {
seededNetworkState = { isConnected: false, type: 'NONE' }
const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
unsubscribe()
await Promise.resolve()
expect(appStateRemove).toHaveBeenCalledTimes(1)
expect(networkRemove).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,50 @@
import { AppState } from 'react-native'
import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from 'expo-network'
// Why: Android/iOS suspend JS timers and silently kill sockets while the app
// is backgrounded, and network handoffs (Wi-Fi → cellular) kill the TCP path
// without an onclose. Both leave clients waiting out long backoff timers or
// parked at the reconnect give-up cap (issue #5049). Surface every "the link
// probably just came back" OS signal as a single nudge callback.
export function subscribeConnectionRevivalTriggers(nudge: () => void): () => void {
const appStateSub = AppState.addEventListener('change', (next) => {
if (next === 'active') {
nudge()
}
})
let lastNetwork: Pick<NetworkState, 'isConnected' | 'type'> | null = null
let disposed = false
// Why: the listener only fires on *changes*; without a seeded baseline the
// first change after subscribing (app launched offline, network returns)
// would be swallowed by the previous == null guard below.
void getNetworkStateAsync()
.then((state) => {
if (!disposed && lastNetwork == null) {
lastNetwork = { isConnected: state.isConnected, type: state.type }
}
})
.catch(() => {})
const networkSub = addNetworkStateListener((state) => {
const previous = lastNetwork
lastNetwork = { isConnected: state.isConnected, type: state.type }
if (state.isConnected !== true) {
return
}
const cameOnline = previous != null && previous.isConnected !== true
// Why: a type change while staying "connected" is the Wi-Fi → cellular
// handoff case — the old socket is dead even though we never went offline.
const switchedNetworks = previous?.type != null && state.type !== previous.type
if (cameOnline || switchedNetworks) {
console.log('[net] network changed — nudging clients', {
type: state.type,
cameOnline
})
nudge()
}
})
return () => {
disposed = true
appStateSub.remove()
networkSub.remove()
}
}

View File

@ -0,0 +1,208 @@
// Live (real-socket, real-timer) repro harness for issue #5049: Android
// remote sessions that appear connected but stop responding until the app
// is reopened. Unlike rpc-client.test.ts (fake timers, mocked e2ee), this
// runs the REAL rpc-client with real tweetnacl E2EE against an in-process
// ws server, simulating the Tailscale failure modes behind the report.
//
// Opt-in because the quick scenario takes ~15s wall-clock and the full
// parked-loop scenario ~8 minutes:
// ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/rpc-client-live-recovery.test.ts
// ORCA_MOBILE_LIVE_REPRO_FULL=1 ... (adds the 8-minute parked-loop case)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { randomBytes } from 'node:crypto'
import type { AddressInfo } from 'node:net'
import nacl from 'tweetnacl'
import { WebSocketServer, type WebSocket as ServerSocket } from 'ws'
import { connect, type RpcClient } from './rpc-client'
// Why: expo-crypto only exists inside a React Native runtime; Node's CSPRNG
// is equivalent for the harness. Everything else (tweetnacl, the wire
// protocol) is the real production path.
vi.mock('expo-crypto', () => ({
getRandomBytes: (n: number) => new Uint8Array(randomBytes(n))
}))
const RUN_LIVE =
process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL
const RUN_FULL = process.env.ORCA_MOBILE_LIVE_REPRO_FULL === '1'
const AUTH_TOKEN = 'repro-device-token'
const serverKeyPair = nacl.box.keyPair()
const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64')
// When true the server accepts traffic but never replies — simulates a
// half-open link where TCP looks alive but the path is dead.
let blackhole = false
function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string {
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const msg = new TextEncoder().encode(plaintext)
const ciphertext = nacl.box.after(msg, nonce, sharedKey)
const bundle = new Uint8Array(nonce.length + ciphertext.length)
bundle.set(nonce)
bundle.set(ciphertext, nonce.length)
return Buffer.from(bundle).toString('base64')
}
function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
return null
}
const nonce = bundle.slice(0, nacl.box.nonceLength)
const plaintext = nacl.box.open.after(bundle.slice(nacl.box.nonceLength), nonce, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
// Why: port 0 lets the OS assign a free port so the opt-in harness can't
// fail with EADDRINUSE; the full scenario restarts on the captured port
// because the client keeps reconnecting to its original URL.
function startServer(port = 0): Promise<WebSocketServer> {
const wss = new WebSocketServer({ port })
wss.on('connection', (ws: ServerSocket) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
ws.on('message', (data) => {
if (blackhole) {
return
}
const msg = typeof data === 'string' ? data : data.toString('utf-8')
if (!sharedKey) {
const hello = JSON.parse(msg) as { publicKeyB64: string }
const clientKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64'))
sharedKey = nacl.box.before(clientKey, serverKeyPair.secretKey)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
return
}
const plaintext = e2eeDecrypt(msg, sharedKey)
if (!plaintext) {
return
}
const request = JSON.parse(plaintext) as { id?: string; type?: string; deviceToken?: string }
if (!authenticated) {
if (request.type === 'e2ee_auth' && request.deviceToken === AUTH_TOKEN) {
authenticated = true
ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
}
return
}
ws.send(
e2eeEncrypt(JSON.stringify({ id: request.id, ok: true, result: { up: true } }), sharedKey)
)
})
})
return new Promise((resolve) => wss.once('listening', () => resolve(wss)))
}
function serverPort(wss: WebSocketServer): number {
return (wss.address() as AddressInfo).port
}
function stopServer(wss: WebSocketServer): Promise<void> {
return new Promise((resolve) => {
for (const ws of wss.clients) {
ws.terminate()
}
wss.close(() => resolve())
})
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function waitFor(label: string, timeoutMs: number, check: () => boolean): Promise<number> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (check()) {
return Date.now() - start
}
await sleep(200)
}
throw new Error(`timed out after ${timeoutMs / 1000}s waiting for: ${label}`)
}
describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
let client: RpcClient | null = null
let wss: WebSocketServer | null = null
afterEach(async () => {
blackhole = false
client?.close()
client = null
if (wss) {
await stopServer(wss)
wss = null
}
})
it(
'reaps a half-open link via the foreground probe and recovers',
{ timeout: 60_000 },
async () => {
wss = await startServer()
client = connect(`ws://127.0.0.1:${serverPort(wss)}`, AUTH_TOKEN, serverPublicKeyB64)
const c = client
await waitFor('initial connect', 10_000, () => c.getState() === 'connected')
expect((await c.sendRequest('status.get')).ok).toBe(true)
// Half-open: server keeps TCP up but stops answering, then the app
// comes back to the foreground.
blackhole = true
c.notifyForeground()
// Foreground probe budget is 8s; the interval probe alone would take
// up to 28s. Allow scheduling slack but stay well under 28s.
const detectMs = await waitFor(
'half-open detected',
15_000,
() => c.getState() !== 'connected'
)
expect(detectMs).toBeLessThan(12_000)
blackhole = false
await waitFor('recovered after link healed', 15_000, () => c.getState() === 'connected')
expect((await c.sendRequest('status.get')).ok).toBe(true)
}
)
it.runIf(RUN_FULL)(
'repro: parked retry loop stays stuck until the foreground nudge',
{ timeout: 600_000 },
async () => {
wss = await startServer()
const port = serverPort(wss)
client = connect(`ws://127.0.0.1:${port}`, AUTH_TOKEN, serverPublicKeyB64)
const c = client
await waitFor('initial connect', 10_000, () => c.getState() === 'connected')
await stopServer(wss)
wss = null
await waitFor('retry cap scheduled (~5 min)', 480_000, () => c.getReconnectAttempt() >= 12)
// The attempt counter hits 12 when the final attempt is *scheduled*;
// its 60s backoff timer is still pending. Let it fire and fail while
// the server is still down so the loop truly parks.
await sleep(65_000)
expect(c.getState()).toBe('reconnecting')
wss = await startServer(port)
// Pre-fix behavior: even with the server back, a parked loop never
// recovers — the user had to restart the app.
await sleep(70_000)
expect(c.getState()).not.toBe('connected')
c.notifyForeground()
await waitFor('foreground nudge recovered the session', 15_000, () => {
return c.getState() === 'connected'
})
expect((await c.sendRequest('status.get')).ok).toBe(true)
}
)
})
// Why: vitest fails a file with zero tests; keep a sentinel for default runs.
describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => {
it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => {
expect(true).toBe(true)
})
})

View File

@ -581,6 +581,120 @@ describe('mobile rpc-client connection timeout', () => {
}
})
// Repro for issue #5049: Android sessions that appear connected (or stuck
// "Reconnecting…") after the app returns to the foreground, recoverable
// only by restarting the app. notifyForeground is the recovery hook the
// provider invokes on AppState 'active'.
describe('foreground recovery', () => {
function openAndAuthenticate(socket: MockWebSocket) {
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
}
it('repro: a parked reconnect loop never retries on its own', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
openAndAuthenticate(mockSockets[0]!)
mockSockets[0]!.close()
await vi.runAllTimersAsync()
expect(client.getState()).toBe('reconnecting')
expect(client.getReconnectAttempt()).toBe(12)
// Stuck: arbitrary additional time produces no further attempts.
const socketsBefore = mockSockets.length
await vi.advanceTimersByTimeAsync(600_000)
expect(mockSockets.length).toBe(socketsBefore)
client.close()
})
it('restarts a parked reconnect loop on foreground', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
openAndAuthenticate(mockSockets[0]!)
mockSockets[0]!.close()
await vi.runAllTimersAsync()
expect(client.getReconnectAttempt()).toBe(12)
const socketsBefore = mockSockets.length
client.notifyForeground()
expect(mockSockets.length).toBe(socketsBefore + 1)
expect(client.getReconnectAttempt()).toBe(0)
openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
expect(client.getState()).toBe('connected')
client.close()
})
it('fast-forwards a pending backoff timer on foreground', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
openAndAuthenticate(mockSockets[0]!)
mockSockets[0]!.close()
expect(client.getState()).toBe('reconnecting')
const socketsBefore = mockSockets.length
client.notifyForeground()
expect(mockSockets.length).toBe(socketsBefore + 1)
openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
expect(client.getState()).toBe('connected')
// The cleared backoff timer must not fire a duplicate attempt.
await vi.advanceTimersByTimeAsync(1_000)
expect(mockSockets.length).toBe(socketsBefore + 1)
client.close()
})
it('reaps a half-open socket within 8s of foreground', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
openAndAuthenticate(socket)
// Half-open: readyState stays OPEN but the server never answers.
client.notifyForeground()
expect(sentRequests(socket, 'status.get')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).toHaveBeenCalled()
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
expect(client.getState()).toBe('connected')
client.close()
})
it('keeps a healthy connection when the foreground probe is answered', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
openAndAuthenticate(socket)
client.notifyForeground()
const probe = sentRequest(socket, 'status.get')
socket.receive(`encrypted:${JSON.stringify({ id: probe.id, ok: true, result: {} })}`)
await vi.advanceTimersByTimeAsync(10_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
client.close()
})
it('is a no-op after the client is closed', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
openAndAuthenticate(mockSockets[0]!)
client.close()
const socketsBefore = mockSockets.length
client.notifyForeground()
expect(mockSockets.length).toBe(socketsBefore)
expect(client.getState()).toBe('disconnected')
})
})
it('rejects requests waiting for reconnect after the retry cap', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!

View File

@ -28,6 +28,7 @@ import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { describeSocketEvent } from './socket-event-debug'
type PendingRequest = {
resolve: (response: RpcResponse) => void
@ -91,6 +92,10 @@ export type RpcClient = {
// to distinguish "host moved/never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
// Why: app-resume hook. Android/iOS can kill the TCP path or park the
// reconnect loop while the app is backgrounded; callers invoke this on
// AppState 'active' so the session recovers without an app restart.
notifyForeground: () => void
close: () => void
}
@ -645,38 +650,9 @@ export function connect(
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
// Why: inline the diagnostic dump. Earlier hot-reload tripped
// `Property 'enumKeys' doesn't exist` because a stale closure
// captured a half-loaded module. Inlining keeps the handler's
// behavior fully decided at construction time.
let closeEventKeys: string[] = []
let closeEventStr = ''
try {
closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
closeEventKeys = []
}
try {
const seen = new WeakSet<object>()
closeEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) {
return '[circular]'
}
seen.add(v as object)
}
if (typeof v === 'function') {
return '[fn]'
}
return v
},
0
).slice(0, 500)
} catch {
closeEventStr = '[unstringifiable]'
}
// Why: statically imported (not closure-built) — an earlier hot-reload
// bug came from a stale closure capturing a half-loaded module.
const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: e?.code,
reason: e?.reason,
@ -688,8 +664,8 @@ export function connect(
constructToCloseMs,
aliveMs,
inboundIdleMs,
eventKeys: closeEventKeys,
eventStr: closeEventStr
eventKeys: closeEvent.keys,
eventStr: closeEvent.json
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
@ -704,41 +680,13 @@ export function connect(
// onclose fires right after, but logging the error message gives us
// the original cause that the close code alone can hide.
const e = event as { message?: string } | undefined
// Why: inlined defensively — see ws.onclose comment.
let errEventKeys: string[] = []
let errEventStr = ''
try {
errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
errEventKeys = []
}
try {
const seen = new WeakSet<object>()
errEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) {
return '[circular]'
}
seen.add(v as object)
}
if (typeof v === 'function') {
return '[fn]'
}
return v
},
0
).slice(0, 500)
} catch {
errEventStr = '[unstringifiable]'
}
const errEvent = describeSocketEvent(event)
console.log('[net] ws.onerror', {
message: e?.message,
state,
attempt: reconnectAttempt,
eventKeys: errEventKeys,
eventStr: errEventStr
eventKeys: errEvent.keys,
eventStr: errEvent.json
})
}
}
@ -818,56 +766,58 @@ export function connect(
// at the top of the file. Fires while the channel is in 'connected'
// state, sends a tiny status.get, and force-closes the WS if the probe
// fails (which the existing onclose path then turns into a reconnect).
function runActivityProbe() {
// Why: only probe while the channel is actually in 'connected'. The
// sendRequest path itself waits for connected, but a probe scheduled
// during a reconnect would just stack up timeouts and confuse logs.
if (state !== 'connected' || !ws) {
return
}
const probeWs = ws
// Why: short timeout (8s) — server's heartbeat is 15s, so if we
// don't see *anything* back within 8s the link is almost certainly
// half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
// user wait nearly a minute before reconnect kicks in.
const id = nextId()
const probeStart = Date.now()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
pending.delete(id)
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
waitedMs: Date.now() - probeStart,
state
})
// Why: only force-close if this is still the same socket the
// probe was sent on; a normal close that already swapped `ws`
// shouldn't trigger a redundant terminate.
if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
probeWs.close()
}
}, 8_000)
pending.set(id, {
resolve: () => {
if (timedOut) {
return
}
clearTimeout(timeout)
},
reject: () => {
if (timedOut) {
return
}
clearTimeout(timeout)
}
})
if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
clearTimeout(timeout)
pending.delete(id)
}
}
function startActivityProbe() {
stopActivityProbe()
activityProbeTimer = setInterval(() => {
// Why: only probe while the channel is actually in 'connected'. The
// sendRequest path itself waits for connected, but a probe scheduled
// during a reconnect would just stack up timeouts and confuse logs.
if (state !== 'connected' || !ws) {
return
}
const probeWs = ws
// Why: short timeout (8s) — server's heartbeat is 15s, so if we
// don't see *anything* back within 8s the link is almost certainly
// half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
// user wait nearly a minute before reconnect kicks in.
const id = nextId()
const probeStart = Date.now()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
pending.delete(id)
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
waitedMs: Date.now() - probeStart,
state
})
// Why: only force-close if this is still the same socket the
// probe was sent on; a normal close that already swapped `ws`
// shouldn't trigger a redundant terminate.
if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
probeWs.close()
}
}, 8_000)
pending.set(id, {
resolve: () => {
if (timedOut) {
return
}
clearTimeout(timeout)
},
reject: () => {
if (timedOut) {
return
}
clearTimeout(timeout)
}
})
if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
clearTimeout(timeout)
pending.delete(id)
}
}, ACTIVITY_PROBE_INTERVAL_MS)
activityProbeTimer = setInterval(runActivityProbe, ACTIVITY_PROBE_INTERVAL_MS)
}
function stopActivityProbe() {
@ -1216,6 +1166,38 @@ export function connect(
return () => stateListeners.delete(listener)
},
notifyForeground(): void {
if (intentionallyClosed) {
return
}
if (state === 'connected') {
// Why: the OS can kill the TCP path while the app is backgrounded
// without delivering onclose, leaving a half-open socket that
// blackholes input. Probe now so death is detected in ≤8s instead
// of waiting out the 20s interval (issue #5049).
console.log('[net] foreground — probing live connection')
startActivityProbe()
runActivityProbe()
return
}
if (state === 'reconnecting') {
// Why: while backgrounded the retry loop may have parked at the
// give-up cap or be sitting on a 60s backoff timer. Returning to
// the foreground is a strong user signal — restart with a fresh
// attempt budget immediately instead of requiring an app restart.
console.log('[net] foreground — restarting reconnect loop', {
attempt: reconnectAttempt,
hadTimer: !!reconnectTimer
})
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
openConnection()
}
},
close() {
intentionallyClosed = true
if (reconnectTimer) {

View File

@ -0,0 +1,34 @@
// Why: RN's WebSocket close/error events are loosely typed and vary per
// platform. Serialize them defensively (circular-safe, function-safe,
// truncated) so the [net] diagnostics can never crash mid-handler.
export function describeSocketEvent(event: unknown): { keys: string[]; json: string } {
let keys: string[] = []
try {
keys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
keys = []
}
let json = ''
try {
const seen = new WeakSet<object>()
json = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) {
return '[circular]'
}
seen.add(v as object)
}
if (typeof v === 'function') {
return '[fn]'
}
return v
},
0
).slice(0, 500)
} catch {
json = '[unstringifiable]'
}
return { keys, json }
}