diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 1f3d620d068..8180baafb81 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -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([]) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) @@ -287,7 +278,15 @@ export default function MobileFileExplorerScreen() { ) : error ? ( {error} - 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. */} + + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + } + > Retry diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index e7664cc216f..c5d8fb5527e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3820,7 +3820,8 @@ export default function SessionScreen() { void forceReconnectHost(hostId) } }} - accessibilityLabel="Reconnect to desktop" + accessibilityRole={showConnectionRetry ? 'button' : undefined} + accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined} > diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index dad4d4cfe94..59ea98736b3 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -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({ kind: 'loading' }) const [branchCompareState, setBranchCompareState] = useState({ kind: 'idle' @@ -1431,7 +1422,20 @@ export default function MobileSourceControlScreen() { {screenState.message} {screenState.kind === 'error' ? ( - void loadStatus()}> + { + // 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() + }} + > Retry ) : null} diff --git a/mobile/issue-5049-unresponsive-session-findings.md b/mobile/issue-5049-unresponsive-session-findings.md index c0e840d63cf..6440e1c990c 100644 --- a/mobile/issue-5049-unresponsive-session-findings.md +++ b/mobile/issue-5049-unresponsive-session-findings.md @@ -82,5 +82,11 @@ alive but ignores input, recoverable only by an app restart: 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. -- No NetInfo-based reconnect-on-network-change; AppState foreground nudge covers - the reported cases without a new dependency. + +## 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. diff --git a/mobile/package.json b/mobile/package.json index 91d0aad7783..7472008bf83 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6bfe118caa0..b3dd184658c 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -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) diff --git a/mobile/src/session/worktree-label.ts b/mobile/src/session/worktree-label.ts new file mode 100644 index 00000000000..c5e67ac5aab --- /dev/null +++ b/mobile/src/session/worktree-label.ts @@ -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' +} diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index bde515fa29b..f889cba51ff 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -19,8 +19,8 @@ import { useState, type ReactNode } from 'react' -import { AppState } from 'react-native' import { connect, type RpcClient } from './rpc-client' +import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -321,20 +321,15 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Why: Android/iOS suspend JS timers and can silently drop sockets while - // the app is backgrounded; the reconnect loop may even park at its - // give-up cap before the user returns. Nudge every live client on - // foreground so sessions recover without an app restart (issue #5049). + // 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(() => { - const sub = AppState.addEventListener('change', (next) => { - if (next !== 'active') { - return - } + return subscribeConnectionRevivalTriggers(() => { for (const entry of storeRef.current.values()) { entry.client.notifyForeground() } }) - return () => sub.remove() }, []) const value = useMemo( diff --git a/mobile/src/transport/connection-revival-triggers.test.ts b/mobile/src/transport/connection-revival-triggers.test.ts new file mode 100644 index 00000000000..a349a2e5fa6 --- /dev/null +++ b/mobile/src/transport/connection-revival-triggers.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' + +type AppStateListener = (next: string) => void +type NetworkListener = (state: { isConnected?: boolean; type?: string }) => void + +let appStateListener: AppStateListener | null = null +let networkListener: NetworkListener | null = null +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', () => ({ + addNetworkStateListener: (listener: NetworkListener) => { + networkListener = listener + return { remove: networkRemove } + } +})) + +describe('subscribeConnectionRevivalTriggers', () => { + let nudge: ReturnType + let unsubscribe: () => void + + beforeEach(() => { + vi.clearAllMocks() + appStateListener = null + networkListener = null + nudge = vi.fn() + unsubscribe = subscribeConnectionRevivalTriggers(nudge) + }) + + it('nudges when the app returns to the foreground, not on background', () => { + appStateListener?.('background') + expect(nudge).not.toHaveBeenCalled() + appStateListener?.('active') + expect(nudge).toHaveBeenCalledTimes(1) + }) + + it('nudges when the network comes back online', () => { + networkListener?.({ isConnected: false, type: 'NONE' }) + expect(nudge).not.toHaveBeenCalled() + networkListener?.({ isConnected: true, type: 'WIFI' }) + expect(nudge).toHaveBeenCalledTimes(1) + }) + + it('nudges on a Wi-Fi to cellular handoff that never reports offline', () => { + networkListener?.({ isConnected: true, type: 'WIFI' }) + networkListener?.({ isConnected: true, type: 'CELLULAR' }) + expect(nudge).toHaveBeenCalledTimes(1) + }) + + it('stays quiet on the initial report and on repeated identical states', () => { + networkListener?.({ isConnected: true, type: 'WIFI' }) + networkListener?.({ isConnected: true, type: 'WIFI' }) + expect(nudge).not.toHaveBeenCalled() + }) + + it('removes both OS listeners on unsubscribe', () => { + unsubscribe() + expect(appStateRemove).toHaveBeenCalledTimes(1) + expect(networkRemove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/connection-revival-triggers.ts b/mobile/src/transport/connection-revival-triggers.ts new file mode 100644 index 00000000000..86244920dca --- /dev/null +++ b/mobile/src/transport/connection-revival-triggers.ts @@ -0,0 +1,38 @@ +import { AppState } from 'react-native' +import { addNetworkStateListener, 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 | null = null + 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 () => { + appStateSub.remove() + networkSub.remove() + } +} diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index a99560cb5aa..da73fc0b5fd 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -10,6 +10,7 @@ // 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' @@ -25,7 +26,6 @@ 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 PORT = 6799 const AUTH_TOKEN = 'repro-device-token' const serverKeyPair = nacl.box.keyPair() @@ -55,8 +55,11 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { return plaintext ? new TextDecoder().decode(plaintext) : null } -function startServer(): WebSocketServer { - const wss = new WebSocketServer({ port: PORT }) +// 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 { + const wss = new WebSocketServer({ port }) wss.on('connection', (ws: ServerSocket) => { let sharedKey: Uint8Array | null = null let authenticated = false @@ -89,7 +92,11 @@ function startServer(): WebSocketServer { ) }) }) - return wss + 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 { @@ -134,8 +141,8 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { 'reaps a half-open link via the foreground probe and recovers', { timeout: 60_000 }, async () => { - wss = startServer() - client = connect(`ws://127.0.0.1:${PORT}`, AUTH_TOKEN, serverPublicKeyB64) + 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) @@ -163,8 +170,9 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { 'repro: parked retry loop stays stuck until the foreground nudge', { timeout: 600_000 }, async () => { - wss = startServer() - client = connect(`ws://127.0.0.1:${PORT}`, AUTH_TOKEN, serverPublicKeyB64) + 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') @@ -177,7 +185,7 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { await sleep(65_000) expect(c.getState()).toBe('reconnecting') - wss = startServer() + 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)