From 9c111fd7aa8f5bd270b5e66313cbf3747d71e8e7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:20:07 -0700 Subject: [PATCH] mobile: per-host connection log screen with copy-diagnostics (#7984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mobile/app/_layout.tsx | 1 + mobile/app/connection-log.tsx | 221 ++++++++++++++++++ mobile/app/troubleshoot.tsx | 12 + .../connection-diagnostics-report.test.ts | 55 +++++ .../connection-diagnostics-report.ts | 58 +++++ mobile/src/transport/client-context.tsx | 8 +- .../transport/connection-log-buffer.test.ts | 56 +++++ mobile/src/transport/connection-log-buffer.ts | 84 +++++++ 8 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 mobile/app/connection-log.tsx create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.test.ts create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.ts create mode 100644 mobile/src/transport/connection-log-buffer.test.ts create mode 100644 mobile/src/transport/connection-log-buffer.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f47ec5910..d1c5ebff1 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -177,6 +177,7 @@ export default function RootLayout() { + diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx new file mode 100644 index 000000000..cc19497e0 --- /dev/null +++ b/mobile/app/connection-log.tsx @@ -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([]) + const [selectedId, setSelectedId] = useState(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 ( + + + router.back()}> + + + Connection log + + + {hosts.length > 1 && ( + + {hosts.map((host) => ( + setSelectedId(host.id)} + > + + {host.name} + + + ))} + + )} + + {selected ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy diagnostics'} + + + {entries.length > 0 ? ( + + ) : ( + + No connection events yet this session. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} + +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 + } +}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index e9d7a34ee..5ae53e008 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -16,6 +16,7 @@ import { ChevronUp, Activity, CheckCircle2, + ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' @@ -213,6 +214,17 @@ export default function TroubleshootScreen() { + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => router.push('/connection-log')} + > + + View connection log + + {checks.length > 0 && ( {checks.map((check, i) => ( diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts new file mode 100644 index 000000000..d2326ffa0 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -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.') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts new file mode 100644 index 000000000..c762c6979 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -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` +} diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 2b8461c58..41f1b5092 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -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 diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts new file mode 100644 index 000000000..e7ce66253 --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -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) + }) +}) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts new file mode 100644 index 000000000..1b223ad8e --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.ts @@ -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() + const listenersByHost = new Map 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() + 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()