fix: extend session recovery to network handoffs and remaining screens

Follow-ups from the #5049 audit plus CodeRabbit review:

- connection-revival-triggers.ts (new, via expo-network): nudge clients
  on network restoration and Wi-Fi -> cellular handoffs, not just app
  foregrounding; AppState wiring moved here from client-context.
- files/source-control screens: Retry now revives the parked transport
  (forceReconnect) when disconnected instead of re-sending the request.
  getWorktreeLabel deduped into src/session/worktree-label.ts.
- session header (review): accessibility role/label only while the
  retry affordance is active.
- live harness (review): OS-assigned port instead of hardcoded 6799.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-09 17:32:47 -07:00
co-authored by Orca
parent fe8b4d2851
commit 087095828d
11 changed files with 197 additions and 64 deletions
+12 -13
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>
@@ -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}
>
<StatusDot state={connState} />
<Text style={styles.sessionMetaText} numberOfLines={1}>
@@ -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}
@@ -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.
+1
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",
+14 -16
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)
+12
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'
}
+5 -10
View File
@@ -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<ContextValue>(
@@ -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<typeof vi.fn>
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)
})
})
@@ -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<NetworkState, 'isConnected' | 'type'> | 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()
}
}
@@ -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<WebSocketServer> {
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<void> {
@@ -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)