mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+14
-16
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user