mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
feat(mobile): persist local crash diagnostics
This commit is contained in:
+24
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user