fix(mobile): recover Android remote sessions without an app restart (#5061)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6d2646df1e
commit
93e59ab086
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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]!
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Reference in New Issue