fix(mobile): harden crash recovery diagnostics

This commit is contained in:
Brennan Benson
2026-08-26 16:00:46 -07:00
parent 4e09c24697
commit cf104df90c
11 changed files with 226 additions and 54 deletions
+17 -1
View File
@@ -22,6 +22,7 @@ import {
AlertTriangle
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { PreviousCrashSessionBanner } from '../src/components/PreviousCrashSessionBanner'
import { loadHosts } from '../src/transport/host-store'
import {
startDiagnosticFetchTimeout,
@@ -33,7 +34,10 @@ import {
unreachableHostDetail
} from '../src/diagnostics/host-reachability'
import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues'
import { buildMobileCrashDiagnosticsReport } from '../src/diagnostics/mobile-crash-diagnostics'
import {
buildMobileCrashDiagnosticsReport,
getPreviousMobileCrashSession
} from '../src/diagnostics/mobile-crash-diagnostics'
type DiagnosticStatus = 'idle' | 'running' | 'done'
@@ -64,9 +68,19 @@ export default function TroubleshootScreen() {
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(null)
const [crashDiagnosticsCopied, setCrashDiagnosticsCopied] = useState(false)
const [hasPreviousCrashSession, setHasPreviousCrashSession] = useState(false)
const crashSessionLoadedRef = useRef(false)
const setTroubleshootRootRef = useCallback((node: View | null): void => {
if (node !== null) {
if (!crashSessionLoadedRef.current) {
crashSessionLoadedRef.current = true
void getPreviousMobileCrashSession().then((session) => {
if (!abortRef.current) {
setHasPreviousCrashSession(session !== null)
}
})
}
return
}
// Why: diagnostics can outlive the screen; cancel the active run when the
@@ -199,6 +213,8 @@ export default function TroubleshootScreen() {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{hasPreviousCrashSession && <PreviousCrashSessionBanner />}
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
@@ -38,10 +38,8 @@ export class MobileRootErrorBoundary extends Component<Props, State> {
}
handleReturnHome = (): void => {
this.setState(
({ resetKey }) => ({ error: null, resetKey: resetKey + 1 }),
this.props.onReturnHome
)
this.props.onReturnHome()
this.setState(({ resetKey }) => ({ error: null, resetKey: resetKey + 1 }))
}
handleReport = (): void => {
@@ -101,7 +99,7 @@ function MobileRootErrorFallback({
style={styles.secondaryButton}
onPress={onReturnHome}
>
<House size={16} color={colors.textSecondary} />
<House size={16} color={colors.textPrimary} />
<Text style={styles.secondaryButtonText}>Return home</Text>
</Pressable>
<Pressable
@@ -110,7 +108,7 @@ function MobileRootErrorFallback({
style={styles.secondaryButton}
onPress={onReport}
>
<Bug size={16} color={colors.textSecondary} />
<Bug size={16} color={colors.textPrimary} />
<Text style={styles.secondaryButtonText}>Report error</Text>
</Pressable>
</View>
@@ -181,10 +179,11 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.xl,
borderRadius: radii.button
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
secondaryButtonText: {
color: colors.textSecondary,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '500'
}
@@ -0,0 +1,45 @@
import { AlertTriangle } from 'lucide-react-native'
import { StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export function PreviousCrashSessionBanner() {
return (
<View style={styles.banner} testID="previous-crash-session-banner">
<AlertTriangle size={16} color={colors.statusAmber} />
<View style={styles.copy}>
<Text style={styles.title}>Previous session ended abnormally</Text>
<Text style={styles.description}>
Crash diagnostics are available to copy and share with support.
</Text>
</View>
</View>
)
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
marginBottom: spacing.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel
},
copy: {
flex: 1,
gap: spacing.xs
},
title: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
description: {
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
}
})
@@ -84,6 +84,7 @@ vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({
}))
import RootLayout from '../../app/_layout'
import { colors } from '../theme/mobile-theme'
describe('mobile root error boundary', () => {
let renderer!: ReactTestRenderer
@@ -121,6 +122,10 @@ describe('mobile root error boundary', () => {
expect.objectContaining({ message: 'route render exploded' }),
expect.any(String)
)
expect(buttons[1]?.props.style).toEqual(
expect.objectContaining({ backgroundColor: colors.bgRaised })
)
expect(buttons[1]?.findByType('House').props.color).toBe(colors.textPrimary)
act(() => buttons[2]?.props.onPress())
expect(mocks.shareDiagnostics).toHaveBeenCalledOnce()
@@ -148,19 +153,19 @@ describe('mobile root error boundary', () => {
expect(mocks.recordRoute).toHaveBeenCalledWith(['h', '[hostId]', 'session', '[worktreeId]'])
})
it('returns home after remounting the failed route', () => {
it('navigates home before remounting the failed route', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
act(() => {
renderer = create(createElement(RootLayout))
})
mocks.routeShouldThrow = false
mocks.routerReplace.mockImplementationOnce(() => {
expect(renderer.root.findAllByProps({ testID: 'mobile-root-error-boundary' })).toHaveLength(0)
mocks.routeShouldThrow = false
})
const returnHome = renderer.root.findByProps({ accessibilityLabel: 'Return home' })
act(() => returnHome.props.onPress())
expect(mocks.routerReplace).toHaveBeenCalledWith('/')
expect(renderer.root.findAllByProps({ testID: 'mobile-root-error-boundary' })).toHaveLength(0)
})
})
@@ -0,0 +1,64 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getPreviousCrashSession: vi.fn().mockResolvedValue({
openedAt: '2026-08-24T18:00:00.000Z',
breadcrumbs: []
})
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: 'ios', Version: '26.5' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0 }) }))
vi.mock('expo-router', () => ({ useRouter: () => ({ back: vi.fn(), push: vi.fn() }) }))
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
Activity: 'Activity',
AlertTriangle: 'AlertTriangle',
CheckCircle2: 'CheckCircle2',
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronUp: 'ChevronUp',
ScrollText: 'ScrollText',
XCircle: 'XCircle'
}))
vi.mock('../transport/host-store', () => ({ loadHosts: vi.fn().mockResolvedValue([]) }))
vi.mock('../diagnostics/diagnostic-fetch-timeout', () => ({
startDiagnosticFetchTimeout: () => ({ signal: undefined, dispose: vi.fn() })
}))
vi.mock('../diagnostics/host-reachability', () => ({
formatEndpoint: vi.fn(),
testHostReachability: vi.fn(),
unreachableHostDetail: vi.fn()
}))
vi.mock('../diagnostics/troubleshoot-common-issues', () => ({
troubleshootCommonIssues: []
}))
vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({
buildMobileCrashDiagnosticsReport: vi.fn().mockResolvedValue('report'),
getPreviousMobileCrashSession: mocks.getPreviousCrashSession
}))
import TroubleshootScreen from '../../app/troubleshoot'
describe('TroubleshootScreen crash diagnostics', () => {
it('shows when the previous session ended abnormally', async () => {
let renderer!: ReactTestRenderer
await act(async () => {
renderer = create(createElement(TroubleshootScreen), { createNodeMock: () => ({}) })
await Promise.resolve()
})
expect(mocks.getPreviousCrashSession).toHaveBeenCalledOnce()
expect(renderer.root.findByProps({ testID: 'previous-crash-session-banner' })).toBeDefined()
})
})
@@ -24,6 +24,10 @@ export function recordMobileRenderError(
return journal.recordRenderError(error, componentStack)
}
export function getPreviousMobileCrashSession(): Promise<MobileCrashSessionSnapshot | null> {
return journal.getLatestAbnormalSession()
}
export function buildMobileCrashDiagnosticsReport(): Promise<string> {
return journal.buildReport({
version: Constants.expoConfig?.version ?? 'unknown',
+7 -36
View File
@@ -4,6 +4,7 @@ import {
type CrashReportBreadcrumb,
type CrashReportBreadcrumbData
} from '../../../src/shared/crash-reporting'
import { describeCrashError } from '../../../src/shared/crash-error-description'
import {
MAX_MOBILE_CRASH_BREADCRUMBS,
MOBILE_CRASH_SESSION_STORAGE_KEY,
@@ -28,8 +29,6 @@ type JournalOptions = {
createSessionId?: () => string
}
const MAX_STACK_CHARS = 2_000
const SAFE_ROUTE_SEGMENTS = new Set([
'about',
'accounts',
@@ -137,11 +136,16 @@ export class MobileCrashSessionJournal {
async recordRenderError(error: unknown, componentStack?: string | null): Promise<void> {
await this.start()
await this.enqueue(async () => {
this.appendBreadcrumb('render_error_contained', describeError(error, componentStack))
this.appendBreadcrumb('render_error_contained', describeCrashError(error, componentStack))
await this.persistJournal()
})
}
async getLatestAbnormalSession(): Promise<MobileCrashSessionSnapshot | null> {
await this.start()
return this.enqueue(async () => this.journal?.latestAbnormalSession ?? null)
}
async buildReport(app: { version: string; platform: string }): Promise<string> {
await this.start()
return this.enqueue(async () => {
@@ -238,39 +242,6 @@ function normalizeAppState(state: string): string {
: 'unknown'
}
function describeError(error: unknown, componentStack?: string | null): CrashReportBreadcrumbData {
const candidate = error instanceof Error ? error : null
const message = candidate?.message ?? String(error)
const stack = extractStackFrames(candidate?.stack, message)
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 extractStackFrames(stack: string | undefined, message: string): string | undefined {
const messageLineCount = message.split(/\r?\n/).length
const lines = (stack?.split(/\r?\n/) ?? []).slice(messageLineCount)
const firstFrameIndex = lines.findIndex((line) => /^\s+at\b/.test(line))
if (firstFrameIndex === -1) {
return undefined
}
return lines.slice(firstFrameIndex).join('\n').trim() || undefined
}
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):`)
@@ -5,7 +5,7 @@ import {
type CrashReportBreadcrumb
} from '../../shared/crash-reporting'
const MAX_BREADCRUMBS = 30
const MAX_DESKTOP_CRASH_BREADCRUMBS = 30
// Why: retain two thresholds for each renderer surface without growing the ring.
const MAX_RETAINED_BREADCRUMBS = 4
// Why: coalesceKey embeds an open-string agentType (length-trimmed only, never
@@ -78,7 +78,7 @@ export function recordCrashBreadcrumb(
return breadcrumb
}
breadcrumbs.push(breadcrumb)
if (breadcrumbs.length > MAX_BREADCRUMBS) {
if (breadcrumbs.length > MAX_DESKTOP_CRASH_BREADCRUMBS) {
breadcrumbs.shift()
}
return breadcrumb
@@ -178,7 +178,7 @@ function isCoalescedCrumbStillInEvidence(
isVisibleToReporter(breadcrumb, reporterOrigin)
)
return visibleRecent
.slice(-(MAX_BREADCRUMBS - retained.length))
.slice(-(MAX_DESKTOP_CRASH_BREADCRUMBS - retained.length))
.some((recentBreadcrumb) => recentBreadcrumb === crumb)
}
@@ -254,7 +254,7 @@ export function getCrashBreadcrumbSnapshot(reporterOrigin?: string): CrashReport
const visibleRecent = breadcrumbs.filter((breadcrumb) =>
isVisibleToReporter(breadcrumb, reporterOrigin)
)
const recent = visibleRecent.slice(-(MAX_BREADCRUMBS - retained.length))
const recent = visibleRecent.slice(-(MAX_DESKTOP_CRASH_BREADCRUMBS - retained.length))
return [...retained, ...recent]
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
.map((breadcrumb) => ({
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { describeCrashError } from './crash-error-description'
describe('describeCrashError', () => {
it('keeps only stack frames and never records multi-line message content', () => {
const error = new Error(
'Failed to render prompt:\nSECRET_TOKEN=do-not-share\n at private message-shaped frame'
)
error.stack = [
'Error: Failed to render prompt:',
'SECRET_TOKEN=do-not-share',
' at private message-shaped frame',
' at PrivateScreen (/Users/example/private-repo/screen.tsx:1:1)'
].join('\n')
const description = describeCrashError(
error,
'\n at PrivateScreen (/Users/example/private-repo/screen.tsx:1:1)'
)
expect(description.errorName).toBe('Error')
expect(description.errorFingerprint).toMatch(/^[0-9a-f]{8}$/)
expect(description.errorStack).toContain('at PrivateScreen ([redacted-path]')
expect(description.errorStack).not.toContain('SECRET_TOKEN')
expect(description.errorStack).not.toContain('private message-shaped frame')
expect(description.componentStack).toContain('at PrivateScreen ([redacted-path]')
})
})
+41
View File
@@ -0,0 +1,41 @@
import { sanitizeCrashReportString, type CrashReportBreadcrumbData } from './crash-reporting'
export type CrashErrorDescription = CrashReportBreadcrumbData
const MAX_STACK_CHARS = 2_000
export function describeCrashError(
error: unknown,
componentStack?: string | null
): CrashErrorDescription {
const candidate = error instanceof Error ? error : null
const message = candidate?.message ?? String(error)
const stack = extractStackFrames(candidate?.stack, message)
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 extractStackFrames(stack: string | undefined, message: string): string | undefined {
const messageLineCount = message.split(/\r?\n/).length
const lines = (stack?.split(/\r?\n/) ?? []).slice(messageLineCount)
const firstFrameIndex = lines.findIndex((line) => /^\s+at\b/.test(line))
if (firstFrameIndex === -1) {
return undefined
}
return lines.slice(firstFrameIndex).join('\n').trim() || undefined
}
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')
}
+2 -2
View File
@@ -127,7 +127,7 @@ export type CrashReportCopyDiagnosticsArgs = {
const MAX_STRING_DETAIL_LENGTH = 240
const MAX_STACK_DETAIL_LENGTH = 4_000
const MAX_BREADCRUMB_NAME_LENGTH = 80
const MAX_BREADCRUMBS = 30
const MAX_CRASH_REPORT_BREADCRUMBS = 30
const MAX_FORMATTED_REPORT_LENGTH = 64_000
const FORMATTED_REPORT_TRUNCATION_SUFFIX =
'\n\n[Crash report truncated to fit feedback endpoint limits.]'
@@ -214,7 +214,7 @@ export function sanitizeCrashReportBreadcrumbs(
return undefined
}
const sanitized = breadcrumbs
.slice(-MAX_BREADCRUMBS)
.slice(-MAX_CRASH_REPORT_BREADCRUMBS)
.map((breadcrumb): CrashReportBreadcrumb | null => {
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
return null