feat(mobile): persist local crash diagnostics

This commit is contained in:
Brennan Benson
2026-08-24 19:21:03 -07:00
parent a87727670c
commit ff10a10a85
11 changed files with 773 additions and 15 deletions
+24 -2
View File
@@ -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<Set<string>>(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.
+4 -1
View File
@@ -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)
+21
View File
@@ -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<DiagnosticFetchTimeout | null>(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
ref={setTroubleshootRootRef}
@@ -225,6 +233,19 @@ export default function TroubleshootScreen() {
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed
]}
onPress={() => void copyCrashDiagnostics()}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>
{crashDiagnosticsCopied ? 'Crash diagnostics copied' : 'Copy crash diagnostics'}
</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (
@@ -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<Props, State> {
error,
errorInfo
)
void recordMobileRenderError(error, errorInfo.componentStack)
}
handleRetry = (): void => {
@@ -37,9 +42,21 @@ export class MobileRootErrorBoundary extends Component<Props, State> {
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 <MobileRootErrorFallback onRetry={this.handleRetry} onGoBack={this.handleGoBack} />
return (
<MobileRootErrorFallback
onRetry={this.handleRetry}
onGoBack={this.handleGoBack}
onReport={this.handleReport}
/>
)
}
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>
@@ -48,10 +65,12 @@ export class MobileRootErrorBoundary extends Component<Props, State> {
function MobileRootErrorFallback({
onRetry,
onGoBack
onGoBack,
onReport
}: {
onRetry: () => void
onGoBack: () => void
onReport: () => void
}): ReactNode {
return (
<View style={styles.container} accessibilityRole="alert" testID="mobile-root-error-boundary">
@@ -61,7 +80,7 @@ function MobileRootErrorFallback({
<View style={styles.copy}>
<Text style={styles.title}>This part of Orca hit an error.</Text>
<Text style={styles.description}>
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.
</Text>
</View>
<View style={styles.actions}>
@@ -83,6 +102,15 @@ function MobileRootErrorFallback({
<ArrowLeft size={16} color={colors.textSecondary} />
<Text style={styles.secondaryButtonText}>Go back</Text>
</Pressable>
<Pressable
accessibilityLabel="Report error"
accessibilityRole="button"
style={styles.secondaryButton}
onPress={onReport}
>
<Bug size={16} color={colors.textSecondary} />
<Text style={styles.secondaryButtonText}>Report error</Text>
</Pressable>
</View>
</View>
)
@@ -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: <T,>(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('/')
})
})
@@ -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')
})
})
@@ -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')
}
@@ -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<MobileCrashSessionSnapshot | null> {
return journal.start()
}
export function recordMobileRouteBreadcrumb(segments: readonly string[]): Promise<void> {
return journal.recordRoute(segments)
}
export function recordMobileAppState(state: string): Promise<void> {
return journal.recordAppState(state)
}
export function recordMobileRenderError(
error: unknown,
componentStack?: string | null
): Promise<void> {
return journal.recordRenderError(error, componentStack)
}
export function buildMobileCrashDiagnosticsReport(): Promise<string> {
return journal.buildReport({
version: Constants.expoConfig?.version ?? 'unknown',
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim()
})
}
export async function shareMobileCrashDiagnostics(): Promise<void> {
await Share.share({
title: 'Orca Mobile crash diagnostics',
message: await buildMobileCrashDiagnosticsReport()
})
}
@@ -0,0 +1,132 @@
import {
sanitizeCrashReportBreadcrumbs,
sanitizeCrashReportString,
type CrashReportBreadcrumb
} from '../../../src/shared/crash-reporting'
export type MobileCrashStorage = {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
}
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<string, unknown>
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<string, unknown>
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<string, unknown>
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)
}
}
@@ -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<string, string>()
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null
}
async setItem(key: string, value: string): Promise<void> {
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)
})
})
@@ -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<void> = Promise.resolve()
private startPromise: Promise<MobileCrashSessionSnapshot | null> | 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<MobileCrashSessionSnapshot | null> {
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<void> {
await this.start()
await this.enqueue(async () => {
this.appendBreadcrumb('route_changed', { route: formatRouteTemplate(segments) })
await this.persistJournal()
})
}
async recordAppState(state: string): Promise<void> {
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<void> {
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<string> {
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<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation, operation)
this.queue = result.then(
() => undefined,
() => undefined
)
return result
}
private async readJournal(): Promise<PersistedMobileCrashJournal | null> {
try {
const raw = await this.storage.getItem(MOBILE_CRASH_SESSION_STORAGE_KEY)
return raw ? parseMobileCrashJournal(raw) : null
} catch {
return null
}
}
private async persistJournal(): Promise<void> {
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}`)
}
}