Files
orca/src/shared/crash-reporting.ts
90d363afc9 fix(renderer): contain Monaco initialization failures (#21555)
* fix(renderer): add defensive error handling for Monaco editor crashes

Analyzed 34 crash reports for v1.4.205 released 2026-09-17. Identified and
added defensive fixes for React error boundary crashes in Monaco editor setup.

- Error: ReferenceError: thũs is not defined
- Location: Monaco editor initialization (editor.api2 bundle)
- Platforms: Linux, Windows, macOS
- Root cause: Undefined variable in Monaco setup or language registration
- Status: Added try-catch to prevent cascade crash

- Pattern: Cascading process deaths (network service + GPU service)
- Platforms: Primarily Windows
- Root cause: Infrastructure/concurrent process failure (not code defect)
- Status: Documented, requires Electron/Chrome infrastructure review

- Pattern: Renderer memory grows to 851MB on low-RAM Windows systems
- Root cause: Memory exhaustion on systems with <2GB free RAM
- Status: Existing memory monitoring detected; needs leak investigation

- Status: Requires minidump analysis with source maps

1. Added try-catch to Monaco editor mount callback (use-monaco-editor-mount.ts)
   - Catches errors during editor initialization
   - Logs file path and error for better diagnostics
   - Prevents crash cascade to React error boundary

2. Added try-catch to Monaco language registration (monaco-setup.ts)
   - Catches errors during Vue/Svelte/Astro/Nim language registration
   - Logs failures without crashing Monaco setup
   - Allows app to continue even if optional features fail

- Analyzed 34 crash reports across 3 categories
- Examined crash dumps, diagnostics, and memory profiles
- Reviewed Monaco setup and editor component code
- Checked git history for recent changes

- Crash breadcrumbs (memory, process state, user actions)
- Process metrics (heap, private memory, system memory)
- Component stacks (React error boundaries)
- Exit codes and system signals

- Error silently continues instead of crashing: Users get degraded experience
  instead of app crash, can still use editor in most cases
- May hide underlying issues: Errors are logged for crash reports, but won't
  be surfaced as prominently

- Type checking: pnpm tc:renderer (passed)
- Changes preserve existing error reporting through crash breadcrumbs
- Defensive coding only adds try-catch, no behavior change for success path

* fix(renderer): keep Monaco mount failures inside error boundary

* fix(renderer): isolate Monaco setup failures

* fix(renderer): contain Monaco mount failures at the editor surface

The try/catch around the onMount body did the opposite of containment: React
already routed that throw to the page boundary, so swallowing it left a
half-wired editor and hid the crash from the reporting pipeline. It also never
saw the reported failure, which is raised inside @monaco-editor/react's own
create effect before onMount runs.

Revert the hook to main and wrap the editor element in
RecoverableRenderErrorBoundary instead, so either throw degrades the file pane
only, still files a crash report, and retries by remounting on the existing
pane+path key.

* refactor(renderer): drive Monaco setup steps from one guarded table

Ten near-identical guarded calls, each repeating its own function name as a
label, become one [label, step] table run by a single loop. Same behaviour: an
optional registration that throws is logged and the rest still run.

loader.config and the editor model registry stay unguarded — they are
load-bearing, so catching there would only move the failure later.

* fix(renderer): breadcrumb swallowed Monaco setup-step failures

A guarded registration that throws was console-only, so a lost language or
behaviour guard never reached crash reports. Record a breadcrumb so the
containment stays visible in the field.

Claude-Session: ab8ff806-4870-4ea8-bbf5-bbd123b1166e

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-09-22 02:57:18 -04:00

278 lines
8.5 KiB
TypeScript

import {
appendDiagnosticBundleLines,
type CrashReportDiagnosticBundle
} from './crash-reporting-diagnostic-bundle'
import { appendMinidumpSignatureLines } from './crash-report-signature-lines'
import { formatCrashReportExitCode } from './crash-report-exit-code'
import { appendBoundaryAttributionLines } from './crash-report-attribution-lines'
import type { CrashReportAttribution } from './react-update-depth-attribution'
import { sanitizeCrashReportString } from './crash-report-redaction'
export {
sanitizeCrashReportBreadcrumbs,
sanitizeCrashReportDetails,
sanitizeCrashReportString
} from './crash-report-redaction'
export type { CrashReportDiagnosticBundle } from './crash-reporting-diagnostic-bundle'
export type CrashReportStatus = 'pending' | 'sent' | 'dismissed'
export type CrashReportSource = 'renderer' | 'child'
export type CrashReportDetailValue = string | number | boolean | null
export type CrashReportBreadcrumbData = Record<string, CrashReportDetailValue>
export type CrashReportBreadcrumb = {
createdAt: string
name: string
data?: CrashReportBreadcrumbData
origin?: string
}
export type CrashReportBreadcrumbInput = Omit<CrashReportBreadcrumb, 'data'> & {
data?: Record<string, unknown>
}
export type CrashReportRecord = {
id: string
createdAt: string
status: CrashReportStatus
source: CrashReportSource
processType: string
reason: string
exitCode: number | null
appVersion: string
platform: NodeJS.Platform
osRelease: string
arch: string
electronVersion: string
chromeVersion: string
details: Record<string, CrashReportDetailValue>
breadcrumbs?: CrashReportBreadcrumb[]
}
export type UncapturedCrashReportContext = {
createdAt: string
appVersion: string
platform: NodeJS.Platform
osRelease: string
arch: string
electronVersion: string
chromeVersion: string
}
export type CrashReportCreateInput = Omit<
CrashReportRecord,
'id' | 'createdAt' | 'status' | 'details' | 'breadcrumbs'
> & {
details: Record<string, unknown>
breadcrumbs?: CrashReportBreadcrumbInput[]
}
export type ReactErrorBoundarySurface =
| 'app-root'
| 'web-root'
| 'workspace-shell'
| 'sidebar'
| 'terminal-workbench'
| 'right-sidebar'
| 'page'
| 'modal'
| 'overlay'
| 'rich-markdown-editor'
| 'code-editor'
| 'dashboard-popout'
export type ReactErrorBoundaryReportArgs = {
boundaryId: string
surface: ReactErrorBoundarySurface
errorName: string
errorMessage: string
errorStack?: string
componentStack?: string
activeView?: string
activeModal?: string | null
activeTabType?: string | null
activeRightSidebarTab?: string | null
hasActiveWorktree?: boolean
// Absent means "attribution is as trustworthy as it ever was"; older hosts ignore it.
attribution?: CrashReportAttribution
}
export type ReactErrorBoundaryReportResult =
| { ok: true; report: CrashReportRecord | null; deduped: boolean }
| { ok: false; error: string }
export type CrashReportSubmitArgs = {
reportId?: string
notes?: string
includeDiagnosticLogs?: boolean
submitAnonymously?: boolean
githubLogin: string | null
githubEmail: string | null
}
export type CrashReportSubmitResult =
| { ok: true; report: CrashReportRecord | null; diagnosticBundle?: CrashReportDiagnosticBundle }
| {
ok: false
status: number | null
error: string
report?: CrashReportRecord | null
diagnosticBundle?: CrashReportDiagnosticBundle
}
export type CrashReportCopySubmissionFailure = {
error: string
diagnosticContext?:
| { status: 'uploaded'; ticketId: string }
| { status: 'not_uploaded'; reason: string }
}
export type CrashReportCopyDiagnosticsArgs = {
reportId?: string
notes?: string
submissionFailure?: CrashReportCopySubmissionFailure
}
// User notes need a prose budget, separate from 240-character telemetry values.
export const MAX_USER_NOTES_LENGTH = 8_000
// Bound redaction work while allowing redacted input to contract into the output budget.
const MAX_USER_NOTES_SANITIZE_LENGTH = MAX_USER_NOTES_LENGTH * 2
const USER_NOTES_TRUNCATION_SUFFIX = '...'
const MAX_FORMATTED_REPORT_LENGTH = 64_000
const FORMATTED_REPORT_TRUNCATION_SUFFIX =
'\n\n[Crash report truncated to fit feedback endpoint limits.]'
export function isCrashReportReason(reason: string): boolean {
return [
'abnormal-exit',
'crashed',
'integrity-failure',
'killed',
'launch-failed',
'memory-eviction',
'oom'
].includes(reason)
}
export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean {
return (
report.source === 'renderer' &&
report.processType === 'react-render' &&
report.reason === 'react-error-boundary'
)
}
// Notes lead so endpoint truncation removes reproducible machine data first.
const USER_NOTES_BEGIN = '--- begin user notes ---'
const USER_NOTES_END = '--- end user notes ---'
function appendUserNotesLines(lines: string[], notes: string | undefined): void {
if (!notes) {
return
}
const inputWasClamped = notes.length > MAX_USER_NOTES_SANITIZE_LENGTH
const boundedNotes = notes.slice(0, MAX_USER_NOTES_SANITIZE_LENGTH).trim()
if (!boundedNotes) {
return
}
const sanitized = sanitizeCrashReportString(boundedNotes, MAX_USER_NOTES_SANITIZE_LENGTH)
const wasTruncated = inputWasClamped || sanitized.length > MAX_USER_NOTES_LENGTH
const formattedNotes = wasTruncated
? `${sanitized
.slice(0, MAX_USER_NOTES_LENGTH - USER_NOTES_TRUNCATION_SUFFIX.length)
.trimEnd()}${USER_NOTES_TRUNCATION_SUFFIX}`
: sanitized
// Indentation prevents user text from impersonating line-oriented machine sections.
lines.push(
'',
'User notes:',
USER_NOTES_BEGIN,
...formattedNotes.split('\n').map((line) => ` ${line}`),
USER_NOTES_END
)
}
export function formatCrashReportText(
report: CrashReportRecord,
notes?: string,
diagnosticBundle?: CrashReportDiagnosticBundle
): string {
const lines = [
'[Crash Report]',
'',
`Report ID: ${report.id}`,
`Created: ${report.createdAt}`,
`Status: ${report.status}`,
`Source: ${report.source}`,
`Process: ${report.processType}`,
`Reason: ${report.reason}`,
`Exit code: ${formatCrashReportExitCode(report)}`,
`App version: ${report.appVersion}`,
`Platform: ${report.platform} ${report.osRelease} ${report.arch}`,
`Electron: ${report.electronVersion}`,
`Chrome: ${report.chromeVersion}`
]
appendUserNotesLines(lines, notes)
appendMinidumpSignatureLines(lines, report.details)
appendBoundaryAttributionLines(lines, report.details)
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
const details = Object.entries(report.details)
if (details.length > 0) {
lines.push('', 'Details:')
for (const [key, value] of details) {
lines.push(`- ${key}: ${String(value)}`)
}
}
if (report.breadcrumbs && report.breadcrumbs.length > 0) {
lines.push('', 'Recent activity:')
for (const breadcrumb of report.breadcrumbs) {
const data = breadcrumb.data ? Object.entries(breadcrumb.data) : []
const suffix =
data.length > 0
? ` (${data.map(([key, value]) => `${key}=${String(value)}`).join(', ')})`
: ''
lines.push(`- ${breadcrumb.createdAt}: ${breadcrumb.name}${suffix}`)
}
}
return truncateFormattedCrashReport(lines.join('\n'))
}
export function formatUncapturedCrashReportText(
context: UncapturedCrashReportContext,
notes?: string,
diagnosticBundle?: CrashReportDiagnosticBundle
): string {
const lines = [
'[Crash Report]',
'',
'Report ID: not captured',
`Created: ${context.createdAt}`,
'Status: uncaptured',
'Source: user-reported',
'Process: unknown',
'Reason: no captured crash report',
'Exit code: unknown',
`App version: ${context.appVersion}`,
`Platform: ${context.platform} ${context.osRelease} ${context.arch}`,
`Electron: ${context.electronVersion}`,
`Chrome: ${context.chromeVersion}`
]
appendUserNotesLines(lines, notes)
lines.push('', 'Details:', '- captured_crash_report: false', '- report_source: help_menu')
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
return truncateFormattedCrashReport(lines.join('\n'))
}
function truncateFormattedCrashReport(text: string): string {
if (text.length <= MAX_FORMATTED_REPORT_LENGTH) {
return text
}
// The report cap leaves Slack-specific attachment handling to the feedback service.
const budget = MAX_FORMATTED_REPORT_LENGTH - FORMATTED_REPORT_TRUNCATION_SUFFIX.length
return `${text.slice(0, Math.max(0, budget)).trimEnd()}${FORMATTED_REPORT_TRUNCATION_SUFFIX}`
}