mobile: per-host connection log screen with copy-diagnostics (#7984)

The rpc-client has always emitted a detailed connection lifecycle log
(dials, timeouts, close codes, handshake steps, retries) via onLog, but
only the pairing screen wired it up — for long-lived host connections
everything went to console.log, invisible to users. Debugging reports
like #7824/#6928 meant asking reporters for facts the app already knew.

- connection-log-buffer: bounded (200/host) module-level ring buffer with
  referentially-stable snapshots for useSyncExternalStore; survives
  client swaps and provider remounts.
- client-context: wire onLog for every shared host client.
- connection-log screen: live per-host log (reuses the pairing
  ConnectionLog component), host picker, and a Copy Diagnostics button
  that bundles app/platform versions, endpoint (flagged if Tailscale),
  state, attempt count, last-connected, and the event log into one
  shareable blob.
- troubleshoot: 'View connection log' entry point.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-09 16:20:07 -07:00 committed by GitHub
parent 69befad13e
commit 9c111fd7aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 494 additions and 1 deletions

View File

@ -177,6 +177,7 @@ export default function RootLayout() {
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>

View File

@ -0,0 +1,221 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../src/transport/client-context'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
// a fresh [] per call would make useSyncExternalStore re-render forever.
const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// Why: reading the log is most needed while a host is failing, so this
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
let stale = false
void loadHosts().then((loaded) => {
if (stale) {
return
}
setHosts(loaded)
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
const selected = hosts.find((h) => h.id === selectedId) ?? null
const { state } = useHostClient(selected?.id)
const reconnectAttempts = useReconnectAttempt(selected?.id)
const lastConnectedAt = useLastConnectedAt(selected?.id)
const subscribe = useCallback(
(listener: () => void) =>
selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {},
[selectedId]
)
const getSnapshot = useCallback(
() => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES),
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state,
reconnectAttempts,
lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
entries
})
await Clipboard.setStringAsync(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Connection log</Text>
</View>
{hosts.length > 1 && (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => setSelectedId(host.id)}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
>
{host.name}
</Text>
</Pressable>
))}
</View>
)}
{selected ? (
<>
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
</Text>
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
{copied ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
</Pressable>
</View>
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} />
) : (
<Text style={styles.emptyText}>
No connection events yet this session. Events appear as the app dials this host.
</Text>
)}
</>
) : (
<Text style={styles.emptyText}>No paired hosts.</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
hostPicker: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md
},
hostChip: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderRadius: 16,
backgroundColor: colors.bgRaised
},
hostChipActive: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
hostChipText: {
fontSize: typography.metaSize,
color: colors.textSecondary,
maxWidth: 160
},
hostChipTextActive: {
color: colors.textPrimary,
fontWeight: '600'
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm
},
statusText: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs + 2,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
copyButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
emptyText: {
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})

View File

@ -16,6 +16,7 @@ import {
ChevronUp,
Activity,
CheckCircle2,
ScrollText,
XCircle,
AlertTriangle
} from 'lucide-react-native'
@ -213,6 +214,17 @@ export default function TroubleshootScreen() {
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed
]}
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
describe('buildConnectionDiagnosticsReport', () => {
it('summarizes a failing Tailscale host with its log', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 1',
endpoint: 'ws://100.65.9.106:6768',
state: 'reconnecting',
reconnectAttempts: 12,
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
entries: [
{
id: 'log-1',
ts: NOW - 60_000,
level: 'error',
message: 'WebSocket connect timeout',
detail: 'No TCP/WS handshake within 12s — endpoint unreachable?'
}
],
nowMs: NOW
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
expect(report).toContain(
'[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?'
)
})
it('marks never-connected sessions and empty logs', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 2',
endpoint: 'ws://192.168.1.50:6768',
state: 'connecting',
reconnectAttempts: 0,
lastConnectedAt: null,
platform: 'android 15',
appVersion: '0.0.29',
entries: [],
nowMs: NOW
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
expect(report).toContain('No connection events recorded this session.')
})
})

View File

@ -0,0 +1,58 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
import { formatEndpoint } from './host-reachability'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
// last-connected, versions, and the reconnect lifecycle log).
export function buildConnectionDiagnosticsReport(args: {
hostName: string
endpoint: string
state: ConnectionState
reconnectAttempts: number
lastConnectedAt: number | null
platform: string
appVersion: string
entries: readonly ConnectionLogEntry[]
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
lines.push(`Host: ${args.hostName}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
if (args.entries.length === 0) {
lines.push('No connection events recorded this session.')
} else {
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
for (const entry of args.entries) {
const detail = entry.detail ? `${entry.detail}` : ''
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
}
}
return lines.join('\n')
}
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}m ${seconds % 60}s`
}
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}

View File

@ -20,6 +20,7 @@ import {
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
@ -150,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64)
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, {
// Why: retain reconnect lifecycle events for the Connection Log
// screen — without this the reasons a host is stuck live only in
// console.log, which users can't see or share.
onLog: (entry) => connectionLogStore.append(hostId, entry)
})
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI

View File

@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
function entry(id: number): ConnectionLogEntry {
return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` }
}
describe('connection log buffer', () => {
it('keeps entries per host without cross-talk', () => {
const store = createConnectionLogStore()
store.append('host-a', entry(1))
store.append('host-b', entry(2))
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1'])
expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2'])
})
it('drops the oldest entries past the cap', () => {
const store = createConnectionLogStore(3)
for (let i = 1; i <= 5; i++) {
store.append('host-a', entry(i))
}
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5'])
})
it('returns a stable snapshot reference until the next append', () => {
const store = createConnectionLogStore()
store.append('host-a', entry(1))
const first = store.get('host-a')
expect(store.get('host-a')).toBe(first)
store.append('host-a', entry(2))
expect(store.get('host-a')).not.toBe(first)
// Empty hosts must also be referentially stable (useSyncExternalStore).
expect(store.get('host-b')).toBe(store.get('host-b'))
})
it('notifies only the host being appended to and stops after unsubscribe', () => {
const store = createConnectionLogStore()
const onA = vi.fn()
const onB = vi.fn()
const unsubA = store.subscribe('host-a', onA)
store.subscribe('host-b', onB)
store.append('host-a', entry(1))
expect(onA).toHaveBeenCalledTimes(1)
expect(onB).not.toHaveBeenCalled()
unsubA()
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,84 @@
import type { ConnectionLogEntry } from './types'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
// users. This buffer retains the recent lifecycle events per host so a
// "Connection log" screen (and copy-diagnostics) can show why a connection
// is stuck without a debug build. Module-level so the log survives client
// swaps (forceReconnect) and provider remounts (hot reload); bounded so an
// all-night reconnect loop can't grow memory unbounded.
const MAX_ENTRIES_PER_HOST = 200
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
subscribe: (hostId: string, listener: () => void) => () => void
}
export function createConnectionLogStore(
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
): ConnectionLogStore {
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
const listenersByHost = new Map<string, Set<() => void>>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
const EMPTY: readonly ConnectionLogEntry[] = []
return {
append(hostId, entry) {
let entries = entriesByHost.get(hostId)
if (!entries) {
entries = []
entriesByHost.set(hostId, entries)
}
entries.push(entry)
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
}
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
},
get(hostId) {
const cached = snapshotByHost.get(hostId)
if (cached) {
return cached
}
const entries = entriesByHost.get(hostId)
if (!entries || entries.length === 0) {
return EMPTY
}
const snapshot = Object.freeze([...entries])
snapshotByHost.set(hostId, snapshot)
return snapshot
},
subscribe(hostId, listener) {
let listeners = listenersByHost.get(hostId)
if (!listeners) {
listeners = new Set()
listenersByHost.set(hostId, listeners)
}
listeners.add(listener)
return () => {
const set = listenersByHost.get(hostId)
if (!set) {
return
}
set.delete(listener)
if (set.size === 0) {
listenersByHost.delete(hostId)
}
}
}
}
}
export const connectionLogStore = createConnectionLogStore()