diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index a1ae162061d..9b774fe8087 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' -import { View, StyleSheet } from 'react-native' -import { Stack, useRouter } from 'expo-router' +import { AppState, View, StyleSheet } from 'react-native' +import { Stack, useRouter, useSegments } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' @@ -14,6 +14,11 @@ import { useOpenNotificationRoute } from '../src/notifications/use-open-notifica import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' +import { + recordMobileAppState, + recordMobileRouteBreadcrumb, + startMobileCrashSession +} from '../src/diagnostics/mobile-crash-diagnostics' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -34,11 +39,28 @@ Notifications.setNotificationHandler({ }) }) +// Why: the open marker must land before the first route can fail during render. +void startMobileCrashSession() + function RootLayoutContents() { const router = useRouter() + const segments = useSegments() + const routeKey = segments.join('\u0000') const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + // Why: background is mobile's last reliable clean handoff before the OS may terminate us. + void recordMobileAppState(state) + }) + return () => subscription.remove() + }, []) + + useEffect(() => { + void recordMobileRouteBreadcrumb(routeKey ? routeKey.split('\u0000') : []) + }, [routeKey]) + useEffect(() => { // Why: pairing publication is journaled across process death; startup must // reconcile the server result before another scan can replace that journal. diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index e2a9b73ff7f..56b6bcaaeb6 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -16,6 +16,7 @@ import { } from '../src/transport/client-context-connection-metrics' import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' +import { buildMobileCrashDiagnosticsReport } from '../src/diagnostics/mobile-crash-diagnostics' // Why: getSnapshot must be referentially stable when there's no data — // a fresh [] per call would make useSyncExternalStore re-render forever. @@ -65,6 +66,7 @@ export default function ConnectionLogScreen() { if (!selected) { return } + const crashDiagnostics = await buildMobileCrashDiagnosticsReport() const report = buildConnectionDiagnosticsReport({ hostName: selected.name, endpoint: selected.endpoint, @@ -73,7 +75,8 @@ export default function ConnectionLogScreen() { lastConnectedAt, platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), appVersion: Constants.expoConfig?.version ?? 'unknown', - entries + entries, + crashDiagnostics }) await Clipboard.setStringAsync(report) setCopied(true) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 5ae53e0084c..9d468d697a1 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -10,6 +10,7 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' +import * as Clipboard from 'expo-clipboard' import { ChevronLeft, ChevronDown, @@ -32,6 +33,7 @@ import { unreachableHostDetail } from '../src/diagnostics/host-reachability' import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' +import { buildMobileCrashDiagnosticsReport } from '../src/diagnostics/mobile-crash-diagnostics' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -61,6 +63,7 @@ export default function TroubleshootScreen() { const abortRef = useRef(false) const diagnosticRunRef = useRef(0) const activeInternetCheckRef = useRef(null) + const [crashDiagnosticsCopied, setCrashDiagnosticsCopied] = useState(false) const setTroubleshootRootRef = useCallback((node: View | null): void => { if (node !== null) { @@ -174,6 +177,11 @@ export default function TroubleshootScreen() { setDiagnosticStatus('done') }, []) + const copyCrashDiagnostics = useCallback(async () => { + await Clipboard.setStringAsync(await buildMobileCrashDiagnosticsReport()) + setCrashDiagnosticsCopied(true) + }, []) + return ( View connection log + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => void copyCrashDiagnostics()} + > + + + {crashDiagnosticsCopied ? 'Crash diagnostics copied' : 'Copy crash diagnostics'} + + + {checks.length > 0 && ( {checks.map((check, i) => ( diff --git a/mobile/src/components/MobileRootErrorBoundary.tsx b/mobile/src/components/MobileRootErrorBoundary.tsx index 7985147f26a..4774f78f2a1 100644 --- a/mobile/src/components/MobileRootErrorBoundary.tsx +++ b/mobile/src/components/MobileRootErrorBoundary.tsx @@ -1,7 +1,11 @@ import { Component, Fragment, type ErrorInfo, type ReactNode } from 'react' -import { AlertTriangle, ArrowLeft, RefreshCw } from 'lucide-react-native' +import { AlertTriangle, ArrowLeft, Bug, RefreshCw } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + recordMobileRenderError, + shareMobileCrashDiagnostics +} from '../diagnostics/mobile-crash-diagnostics' type Props = { children: ReactNode @@ -26,6 +30,7 @@ export class MobileRootErrorBoundary extends Component { error, errorInfo ) + void recordMobileRenderError(error, errorInfo.componentStack) } handleRetry = (): void => { @@ -37,9 +42,21 @@ export class MobileRootErrorBoundary extends Component { this.handleRetry() } + handleReport = (): void => { + void shareMobileCrashDiagnostics().catch((error: unknown) => { + console.warn('[mobile-root-error-boundary] diagnostics share failed', error) + }) + } + render(): ReactNode { if (this.state.error) { - return + return ( + + ) } return {this.props.children} @@ -48,10 +65,12 @@ export class MobileRootErrorBoundary extends Component { function MobileRootErrorFallback({ onRetry, - onGoBack + onGoBack, + onReport }: { onRetry: () => void onGoBack: () => void + onReport: () => void }): ReactNode { return ( @@ -61,7 +80,7 @@ function MobileRootErrorFallback({ This part of Orca hit an error. - The app is still running. Retry this screen or go back and continue elsewhere. + The app is still running. Retry this screen, go back, or share diagnostic details. @@ -83,6 +102,15 @@ function MobileRootErrorFallback({ Go back + + + Report error + ) diff --git a/mobile/src/components/mobile-root-error-boundary.test.tsx b/mobile/src/components/mobile-root-error-boundary.test.tsx index 22ebbb5734f..5339fbd7bab 100644 --- a/mobile/src/components/mobile-root-error-boundary.test.tsx +++ b/mobile/src/components/mobile-root-error-boundary.test.tsx @@ -1,13 +1,23 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ hideSplash: vi.fn().mockResolvedValue(undefined), - preventAutoHide: vi.fn().mockResolvedValue(undefined) + preventAutoHide: vi.fn().mockResolvedValue(undefined), + recordAppState: vi.fn().mockResolvedValue(undefined), + recordRenderError: vi.fn().mockResolvedValue(undefined), + recordRoute: vi.fn().mockResolvedValue(undefined), + routerBack: vi.fn(), + routerCanGoBack: true, + routerReplace: vi.fn(), + routeShouldThrow: true, + shareDiagnostics: vi.fn().mockResolvedValue(undefined), + startSession: vi.fn().mockResolvedValue(null) })) vi.mock('react-native', () => ({ + AppState: { addEventListener: () => ({ remove: vi.fn() }) }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, @@ -17,16 +27,25 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ AlertTriangle: 'AlertTriangle', ArrowLeft: 'ArrowLeft', + Bug: 'Bug', RefreshCw: 'RefreshCw' })) vi.mock('expo-router', () => { - function Stack(): never { - throw new Error('route render exploded') + function Stack(): null { + if (mocks.routeShouldThrow) { + throw new Error('route render exploded') + } + return null } Stack.Screen = 'StackScreen' return { Stack, - useRouter: () => ({ back: vi.fn(), canGoBack: () => true, replace: vi.fn() }) + useRouter: () => ({ + back: mocks.routerBack, + canGoBack: () => mocks.routerCanGoBack, + replace: mocks.routerReplace + }), + useSegments: () => ['h', '[hostId]', 'session', '[worktreeId]'] } }) vi.mock('expo-status-bar', () => ({ StatusBar: 'StatusBar' })) @@ -60,12 +79,25 @@ vi.mock('../transport/pairing', () => ({ extractPairingCodeFromUrl: () => null } vi.mock('../transport/mobile-relay-pairing-recovery', () => ({ recoverMobileRelayPairing: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({ + recordMobileAppState: mocks.recordAppState, + recordMobileRenderError: mocks.recordRenderError, + recordMobileRouteBreadcrumb: mocks.recordRoute, + shareMobileCrashDiagnostics: mocks.shareDiagnostics, + startMobileCrashSession: mocks.startSession +})) import RootLayout from '../../app/_layout' describe('mobile root error boundary', () => { let renderer: ReactTestRenderer | null = null + beforeEach(() => { + mocks.routeShouldThrow = true + mocks.routerCanGoBack = true + vi.clearAllMocks() + }) + afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -80,13 +112,74 @@ describe('mobile root error boundary', () => { }) const fallback = renderer.root.findByProps({ testID: 'mobile-root-error-boundary' }) - expect( - fallback.findAllByType('Pressable').map((button) => button.props.accessibilityLabel) - ).toEqual(['Retry', 'Go back']) + const buttons = fallback.findAllByType('Pressable') + expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([ + 'Retry', + 'Go back', + 'Report error' + ]) expect(console.error).toHaveBeenCalledWith( '[mobile-root-error-boundary] render crash contained by boundary', expect.any(Error), expect.objectContaining({ componentStack: expect.any(String) }) ) + expect(mocks.recordRenderError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'route render exploded' }), + expect.any(String) + ) + + act(() => buttons[2]?.props.onPress()) + expect(mocks.shareDiagnostics).toHaveBeenCalledOnce() + }) + + it('retries by remounting the failed route', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + act(() => { + renderer = create(createElement(RootLayout)) + }) + + mocks.routeShouldThrow = false + const retry = renderer.root.findByProps({ accessibilityLabel: 'Retry' }) + act(() => retry.props.onPress()) + + expect(renderer.root.findAllByProps({ testID: 'mobile-root-error-boundary' })).toHaveLength(0) + }) + + it('records the route template after the navigator mounts', () => { + mocks.routeShouldThrow = false + act(() => { + renderer = create(createElement(RootLayout)) + }) + + expect(mocks.recordRoute).toHaveBeenCalledWith(['h', '[hostId]', 'session', '[worktreeId]']) + }) + + it('navigates away from the failed route without clearing user data', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + act(() => { + renderer = create(createElement(RootLayout)) + }) + + mocks.routeShouldThrow = false + const goBack = renderer.root.findByProps({ accessibilityLabel: 'Go back' }) + act(() => goBack.props.onPress()) + + expect(mocks.routerBack).toHaveBeenCalledOnce() + expect(mocks.routerReplace).not.toHaveBeenCalled() + }) + + it('returns home when the failed route has no back entry', () => { + mocks.routerCanGoBack = false + vi.spyOn(console, 'error').mockImplementation(() => undefined) + act(() => { + renderer = create(createElement(RootLayout)) + }) + + mocks.routeShouldThrow = false + const goBack = renderer.root.findByProps({ accessibilityLabel: 'Go back' }) + act(() => goBack.props.onPress()) + + expect(mocks.routerBack).not.toHaveBeenCalled() + expect(mocks.routerReplace).toHaveBeenCalledWith('/') }) }) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts index d2326ffa022..0570c8ba49a 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.test.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -52,4 +52,21 @@ describe('buildConnectionDiagnosticsReport', () => { expect(report).toContain('Last connected: never this session') expect(report).toContain('No connection events recorded this session.') }) + + it('includes the locally captured crash diagnostics when available', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 2', + endpoint: 'ws://192.168.1.50:6768', + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: NOW, + platform: 'android 15', + appVersion: '0.0.29', + entries: [], + crashDiagnostics: 'Previous session ended abnormally\nroute_changed: settings', + nowMs: NOW + }) + + expect(report).toContain('Previous session ended abnormally\nroute_changed: settings') + }) }) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts index c762c69799e..f4220d968bd 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -14,6 +14,7 @@ export function buildConnectionDiagnosticsReport(args: { platform: string appVersion: string entries: readonly ConnectionLogEntry[] + crashDiagnostics?: string nowMs?: number }): string { const now = args.nowMs ?? Date.now() @@ -41,6 +42,9 @@ export function buildConnectionDiagnosticsReport(args: { lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) } } + if (args.crashDiagnostics) { + lines.push('', args.crashDiagnostics) + } return lines.join('\n') } diff --git a/mobile/src/diagnostics/mobile-crash-diagnostics.ts b/mobile/src/diagnostics/mobile-crash-diagnostics.ts new file mode 100644 index 00000000000..0f145ec25ce --- /dev/null +++ b/mobile/src/diagnostics/mobile-crash-diagnostics.ts @@ -0,0 +1,39 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import Constants from 'expo-constants' +import { Platform, Share } from 'react-native' +import { MobileCrashSessionJournal, type MobileCrashSessionSnapshot } from './mobile-crash-session' + +const journal = new MobileCrashSessionJournal(AsyncStorage) + +export function startMobileCrashSession(): Promise { + return journal.start() +} + +export function recordMobileRouteBreadcrumb(segments: readonly string[]): Promise { + return journal.recordRoute(segments) +} + +export function recordMobileAppState(state: string): Promise { + return journal.recordAppState(state) +} + +export function recordMobileRenderError( + error: unknown, + componentStack?: string | null +): Promise { + return journal.recordRenderError(error, componentStack) +} + +export function buildMobileCrashDiagnosticsReport(): Promise { + return journal.buildReport({ + version: Constants.expoConfig?.version ?? 'unknown', + platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim() + }) +} + +export async function shareMobileCrashDiagnostics(): Promise { + await Share.share({ + title: 'Orca Mobile crash diagnostics', + message: await buildMobileCrashDiagnosticsReport() + }) +} diff --git a/mobile/src/diagnostics/mobile-crash-session-storage.ts b/mobile/src/diagnostics/mobile-crash-session-storage.ts new file mode 100644 index 00000000000..3db2f8e41e1 --- /dev/null +++ b/mobile/src/diagnostics/mobile-crash-session-storage.ts @@ -0,0 +1,132 @@ +import { + sanitizeCrashReportBreadcrumbs, + sanitizeCrashReportString, + type CrashReportBreadcrumb +} from '../../../src/shared/crash-reporting' + +export type MobileCrashStorage = { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise +} + +export type MobileCrashSessionSnapshot = { + openedAt: string + breadcrumbs: CrashReportBreadcrumb[] +} + +export type PersistedMobileCrashSession = MobileCrashSessionSnapshot & { + id: string + marker: 'open' | 'closed' +} + +export type PersistedMobileCrashJournal = { + version: 1 + activeSession: PersistedMobileCrashSession + latestAbnormalSession?: MobileCrashSessionSnapshot +} + +export const MOBILE_CRASH_SESSION_STORAGE_KEY = 'orca.mobile-crash-session.v1' +export const MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS = 24_000 +export const MAX_MOBILE_CRASH_BREADCRUMBS = 30 + +export function snapshotMobileCrashSession( + session: PersistedMobileCrashSession +): MobileCrashSessionSnapshot { + return { + openedAt: session.openedAt, + breadcrumbs: session.breadcrumbs.map((breadcrumb) => ({ + ...breadcrumb, + ...(breadcrumb.data ? { data: { ...breadcrumb.data } } : {}) + })) + } +} + +export function parseMobileCrashJournal(raw: string): PersistedMobileCrashJournal | null { + try { + const parsed = JSON.parse(raw) as Record + if (parsed.version !== 1) { + return null + } + const activeSession = parseSession(parsed.activeSession) + if (!activeSession) { + return null + } + const latestAbnormalSession = parseSnapshot(parsed.latestAbnormalSession) + return { + version: 1, + activeSession, + ...(latestAbnormalSession ? { latestAbnormalSession } : {}) + } + } catch { + return null + } +} + +export function serializeMobileCrashJournal(journal: PersistedMobileCrashJournal): string { + const bounded: PersistedMobileCrashJournal = { + version: 1, + activeSession: { + ...journal.activeSession, + breadcrumbs: [...journal.activeSession.breadcrumbs] + }, + ...(journal.latestAbnormalSession + ? { + latestAbnormalSession: { + ...journal.latestAbnormalSession, + breadcrumbs: [...journal.latestAbnormalSession.breadcrumbs] + } + } + : {}) + } + let serialized = JSON.stringify(bounded) + while (serialized.length > MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS) { + const previous = bounded.latestAbnormalSession?.breadcrumbs + const active = bounded.activeSession.breadcrumbs + if (previous && previous.length > 1) { + previous.shift() + } else if (active.length > 1) { + active.shift() + } else { + break + } + serialized = JSON.stringify(bounded) + } + return serialized +} + +function parseSession(value: unknown): PersistedMobileCrashSession | null { + if (!value || typeof value !== 'object') { + return null + } + const candidate = value as Record + const snapshot = parseSnapshot(candidate) + if ( + !snapshot || + typeof candidate.id !== 'string' || + (candidate.marker !== 'open' && candidate.marker !== 'closed') + ) { + return null + } + return { + ...snapshot, + id: sanitizeCrashReportString(candidate.id, 80), + marker: candidate.marker + } +} + +function parseSnapshot(value: unknown): MobileCrashSessionSnapshot | null { + if (!value || typeof value !== 'object') { + return null + } + const candidate = value as Record + if (typeof candidate.openedAt !== 'string' || !Array.isArray(candidate.breadcrumbs)) { + return null + } + const breadcrumbs = sanitizeCrashReportBreadcrumbs( + candidate.breadcrumbs as CrashReportBreadcrumb[] + ) + return { + openedAt: sanitizeCrashReportString(candidate.openedAt, 80), + breadcrumbs: (breadcrumbs ?? []).slice(-MAX_MOBILE_CRASH_BREADCRUMBS) + } +} diff --git a/mobile/src/diagnostics/mobile-crash-session.test.ts b/mobile/src/diagnostics/mobile-crash-session.test.ts new file mode 100644 index 00000000000..e13821959cb --- /dev/null +++ b/mobile/src/diagnostics/mobile-crash-session.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS, + MOBILE_CRASH_SESSION_STORAGE_KEY, + MobileCrashSessionJournal, + type MobileCrashStorage +} from './mobile-crash-session' + +class MemoryStorage implements MobileCrashStorage { + values = new Map() + + async getItem(key: string): Promise { + return this.values.get(key) ?? null + } + + async setItem(key: string, value: string): Promise { + this.values.set(key, value) + } +} + +const FIRST_SESSION_AT = Date.UTC(2026, 7, 24, 18, 0, 0) +const SECOND_SESSION_AT = FIRST_SESSION_AT + 30_000 + +describe('mobile crash session journal', () => { + it('surfaces durable breadcrumbs after a simulated abnormal termination', async () => { + const storage = new MemoryStorage() + const first = new MobileCrashSessionJournal(storage, { + now: () => FIRST_SESSION_AT, + createSessionId: () => 'session-one' + }) + + await first.start() + await first.recordRoute(['h', 'private-host-id', 'session', 'private-worktree-id']) + await first.recordRenderError( + new Error('prompt contents from /Users/example/private-repo secret=do-not-store'), + '\n at PrivateScreen (/Users/example/private-repo/screen.tsx:1:1)' + ) + // No background transition: the first process disappears with its marker open. + + const second = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT, + createSessionId: () => 'session-two' + }) + const previous = await second.start() + const report = await second.buildReport({ version: '0.0.29', platform: 'ios 26.5' }) + const persisted = storage.values.get(MOBILE_CRASH_SESSION_STORAGE_KEY) ?? '' + + expect(previous?.breadcrumbs.map((breadcrumb) => breadcrumb.name)).toEqual([ + 'session_started', + 'route_changed', + 'render_error_contained' + ]) + expect(report).toContain('Previous session ended abnormally') + expect(report).toContain('h > [dynamic] > session > [dynamic]') + expect(report).toContain('errorFingerprint') + expect(report).not.toContain('private-host-id') + expect(report).not.toContain('private-worktree-id') + expect(report).not.toContain('prompt contents') + expect(report).not.toContain('do-not-store') + expect(report).not.toContain('/Users/example/private-repo') + expect(persisted).not.toContain('private-host-id') + expect(persisted).not.toContain('private-worktree-id') + expect(persisted).not.toContain('prompt contents') + expect(persisted).not.toContain('do-not-store') + expect(persisted).not.toContain('/Users/example/private-repo') + expect(persisted.length).toBeLessThanOrEqual(MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS) + }) + + it('does not classify a session backgrounded cleanly as a crash', async () => { + const storage = new MemoryStorage() + const first = new MobileCrashSessionJournal(storage, { + now: () => FIRST_SESSION_AT, + createSessionId: () => 'session-one' + }) + await first.start() + await first.recordRoute(['settings']) + await first.recordAppState('background') + + const second = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT, + createSessionId: () => 'session-two' + }) + + await expect(second.start()).resolves.toBeNull() + await expect( + second.buildReport({ version: '0.0.29', platform: 'android 15' }) + ).resolves.toContain('No previous abnormal session recorded.') + }) + + it('reopens the marker when a backgrounded process becomes active again', async () => { + const storage = new MemoryStorage() + const first = new MobileCrashSessionJournal(storage, { + now: () => FIRST_SESSION_AT, + createSessionId: () => 'session-one' + }) + await first.start() + await first.recordAppState('inactive') + await first.recordAppState('background') + await first.recordAppState('active') + + const second = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT, + createSessionId: () => 'session-two' + }) + + await expect(second.start()).resolves.toMatchObject({ + breadcrumbs: [ + expect.objectContaining({ name: 'session_started' }), + expect.objectContaining({ name: 'app_state_changed', data: { state: 'inactive' } }), + expect.objectContaining({ name: 'app_state_changed', data: { state: 'background' } }), + expect.objectContaining({ name: 'app_state_changed', data: { state: 'active' } }) + ] + }) + }) + + it('caps the persisted breadcrumb ring and payload', async () => { + const storage = new MemoryStorage() + const journal = new MobileCrashSessionJournal(storage, { + now: () => FIRST_SESSION_AT, + createSessionId: () => 'session-one' + }) + await journal.start() + + for (let index = 0; index < 80; index += 1) { + await journal.recordRoute(['h', `host-${index}`, 'session', `worktree-${index}`]) + } + + const raw = storage.values.get(MOBILE_CRASH_SESSION_STORAGE_KEY) + expect(raw?.length).toBeLessThanOrEqual(MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS) + expect(JSON.parse(raw ?? '{}').activeSession.breadcrumbs).toHaveLength(30) + }) +}) diff --git a/mobile/src/diagnostics/mobile-crash-session.ts b/mobile/src/diagnostics/mobile-crash-session.ts new file mode 100644 index 00000000000..703d3522c66 --- /dev/null +++ b/mobile/src/diagnostics/mobile-crash-session.ts @@ -0,0 +1,267 @@ +import { + sanitizeCrashReportBreadcrumbs, + sanitizeCrashReportString, + type CrashReportBreadcrumb, + type CrashReportBreadcrumbData +} from '../../../src/shared/crash-reporting' +import { + MAX_MOBILE_CRASH_BREADCRUMBS, + MOBILE_CRASH_SESSION_STORAGE_KEY, + parseMobileCrashJournal, + serializeMobileCrashJournal, + snapshotMobileCrashSession, + type MobileCrashSessionSnapshot, + type MobileCrashStorage, + type PersistedMobileCrashJournal, + type PersistedMobileCrashSession +} from './mobile-crash-session-storage' + +export { + MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS, + MOBILE_CRASH_SESSION_STORAGE_KEY, + type MobileCrashSessionSnapshot, + type MobileCrashStorage +} from './mobile-crash-session-storage' + +type JournalOptions = { + now?: () => number + createSessionId?: () => string +} + +const MAX_STACK_CHARS = 2_000 + +const SAFE_ROUTE_SEGMENTS = new Set([ + 'about', + 'accounts', + 'agent-history', + 'browser-settings', + 'connection-log', + 'edit', + 'files', + 'h', + 'history', + 'index', + 'mobile-onboarding', + 'native-chat-settings', + 'notification-opt-in', + 'notifications', + 'pair', + 'pair-confirm', + 'pair-scan', + 'pr', + 'preview', + 'review', + 'session', + 'settings', + 'source-control', + 'tasks', + 'terminal-settings', + 'troubleshoot', + 'voice-settings', + '[hostId]', + '[worktreeId]' +]) + +export class MobileCrashSessionJournal { + private readonly storage: MobileCrashStorage + private readonly now: () => number + private readonly createSessionId: () => string + private queue: Promise = Promise.resolve() + private startPromise: Promise | null = null + private journal: PersistedMobileCrashJournal | null = null + + constructor(storage: MobileCrashStorage, options: JournalOptions = {}) { + this.storage = storage + this.now = options.now ?? Date.now + this.createSessionId = + options.createSessionId ?? (() => `${this.now()}-${Math.random().toString(36).slice(2, 10)}`) + } + + start(): Promise { + if (this.startPromise) { + return this.startPromise + } + this.startPromise = this.enqueue(async () => { + const stored = await this.readJournal() + const abnormal = + stored?.activeSession.marker === 'open' + ? snapshotMobileCrashSession(stored.activeSession) + : null + const openedAt = new Date(this.now()).toISOString() + const activeSession: PersistedMobileCrashSession = { + id: this.createSessionId(), + openedAt, + marker: 'open', + breadcrumbs: [createBreadcrumb(openedAt, 'session_started')] + } + this.journal = { + version: 1, + activeSession, + ...(abnormal || stored?.latestAbnormalSession + ? { latestAbnormalSession: abnormal ?? stored?.latestAbnormalSession } + : {}) + } + await this.persistJournal() + return abnormal + }) + return this.startPromise + } + + async recordRoute(segments: readonly string[]): Promise { + await this.start() + await this.enqueue(async () => { + this.appendBreadcrumb('route_changed', { route: formatRouteTemplate(segments) }) + await this.persistJournal() + }) + } + + async recordAppState(state: string): Promise { + await this.start() + await this.enqueue(async () => { + const safeState = normalizeAppState(state) + if (safeState === 'active' && this.journal) { + this.journal.activeSession.marker = 'open' + } + this.appendBreadcrumb('app_state_changed', { state: safeState }) + if (safeState === 'background' && this.journal) { + this.journal.activeSession.marker = 'closed' + } + await this.persistJournal() + }) + } + + async recordRenderError(error: unknown, componentStack?: string | null): Promise { + await this.start() + await this.enqueue(async () => { + this.appendBreadcrumb('render_error_contained', describeError(error, componentStack)) + await this.persistJournal() + }) + } + + async buildReport(app: { version: string; platform: string }): Promise { + await this.start() + return this.enqueue(async () => { + const lines = [ + 'Orca Mobile crash diagnostics', + `Generated: ${new Date(this.now()).toISOString()}`, + `App: Orca Mobile ${sanitizeCrashReportString(app.version)} · ${sanitizeCrashReportString(app.platform)}` + ] + const previous = this.journal?.latestAbnormalSession + if (previous) { + lines.push('', 'Previous session ended abnormally') + appendSessionLines(lines, previous) + } else { + lines.push('', 'No previous abnormal session recorded.') + } + if (this.journal) { + lines.push('', 'Current session') + appendSessionLines(lines, this.journal.activeSession) + } + return lines.join('\n') + }) + } + + private appendBreadcrumb(name: string, data?: CrashReportBreadcrumbData): void { + if (!this.journal) { + return + } + this.journal.activeSession.breadcrumbs.push( + createBreadcrumb(new Date(this.now()).toISOString(), name, data) + ) + this.journal.activeSession.breadcrumbs = this.journal.activeSession.breadcrumbs.slice( + -MAX_MOBILE_CRASH_BREADCRUMBS + ) + } + + private enqueue(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation) + this.queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private async readJournal(): Promise { + try { + const raw = await this.storage.getItem(MOBILE_CRASH_SESSION_STORAGE_KEY) + return raw ? parseMobileCrashJournal(raw) : null + } catch { + return null + } + } + + private async persistJournal(): Promise { + if (!this.journal) { + return + } + try { + await this.storage.setItem( + MOBILE_CRASH_SESSION_STORAGE_KEY, + serializeMobileCrashJournal(this.journal) + ) + } catch { + // Crash evidence is best-effort and must never create a second app failure. + } + } +} + +function createBreadcrumb( + createdAt: string, + name: string, + data?: CrashReportBreadcrumbData +): CrashReportBreadcrumb { + return ( + sanitizeCrashReportBreadcrumbs([{ createdAt, name, data }])?.[0] ?? { + createdAt, + name + } + ) +} + +function formatRouteTemplate(segments: readonly string[]): string { + if (segments.length === 0) { + return 'index' + } + return segments + .map((segment) => (SAFE_ROUTE_SEGMENTS.has(segment) ? segment : '[dynamic]')) + .join(' > ') +} + +function normalizeAppState(state: string): string { + return ['active', 'background', 'inactive', 'unknown', 'extension'].includes(state) + ? state + : 'unknown' +} + +function describeError(error: unknown, componentStack?: string | null): CrashReportBreadcrumbData { + const candidate = error instanceof Error ? error : null + const message = candidate?.message ?? String(error) + const stack = candidate?.stack?.split(/\r?\n/).slice(1).join('\n').trim() + return { + errorName: sanitizeCrashReportString(candidate?.name || 'NonErrorThrown', 80), + errorFingerprint: fingerprint(message), + ...(stack ? { errorStack: sanitizeCrashReportString(stack, MAX_STACK_CHARS) } : {}), + ...(componentStack?.trim() + ? { componentStack: sanitizeCrashReportString(componentStack.trim(), MAX_STACK_CHARS) } + : {}) + } +} + +function fingerprint(value: string): string { + let hash = 0x811c9dc5 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +function appendSessionLines(lines: string[], session: MobileCrashSessionSnapshot): void { + lines.push(`Opened: ${session.openedAt}`) + lines.push(`Breadcrumbs (${session.breadcrumbs.length}, oldest first):`) + for (const breadcrumb of session.breadcrumbs) { + const data = breadcrumb.data ? ` ${JSON.stringify(breadcrumb.data)}` : '' + lines.push(`${breadcrumb.createdAt} ${breadcrumb.name}${data}`) + } +}