mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
* 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>
278 lines
8.5 KiB
TypeScript
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}`
|
|
}
|