feat(crash): add layered error boundaries and renderer crash log

Adds a 3-layer crash containment system: a dependency-free renderer root
AppErrorBoundary, targeted boundaries around MermaidBlock and rehype-highlight
code blocks, and a main-process render-process-gone handler that surfaces a
recovery dialog and writes to <userData>/logs/renderer-crashes.log.
This commit is contained in:
Jinjing 2026-04-19 12:22:12 -07:00
parent 6b933d7faa
commit b08ead7faf
14 changed files with 832 additions and 9 deletions

View file

@ -14,6 +14,7 @@ import {
import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { registerRendererCrashLogHandler } from './ipc/renderer-crash-log'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
@ -67,6 +68,11 @@ initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
// Why: the renderer-crash log sink must be registered before any BrowserWindow
// is created so a crash during initial load still produces a log entry. Per
// error-boundary-design.md §B1 "Registration order".
registerRendererCrashLogHandler()
function openMainWindow(): BrowserWindow {
if (!store) {
throw new Error('Store must be initialized before opening the main window')

View file

@ -0,0 +1,176 @@
import { app, ipcMain, shell } from 'electron'
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync, existsSync } from 'fs'
import { join } from 'path'
// Why: per error-boundary-design.md §B1, forwarded renderer crashes are written
// to <userData>/logs/renderer-crashes.log so they survive renderer death and are
// reachable via `orca logs`. Keeping size bounded is a hard requirement: a loop
// in the global-handler path could otherwise fill the user's disk.
const MAX_BYTES = 5 * 1024 * 1024
const MAX_ROTATIONS = 3
// Why: a single renderer payload must not dominate the log. Without these
// caps a runaway error loop sending megabyte-sized stacks could force
// rotation on every entry, evicting useful history.
const MAX_FIELD_CHARS = 8 * 1024
const MAX_EXTRA_CHARS = 16 * 1024
let _logFile: string | null = null
function getLogDir(): string {
return join(app.getPath('userData'), 'logs')
}
function getLogFile(): string {
if (_logFile) {
return _logFile
}
_logFile = join(getLogDir(), 'renderer-crashes.log')
return _logFile
}
function ensureLogDir(): void {
try {
mkdirSync(getLogDir(), { recursive: true })
} catch {
// Best-effort: if we can't create the dir we still attempt to write below.
}
}
function rotateIfNeeded(file: string): void {
let size: number
try {
size = statSync(file).size
} catch {
return
}
if (size < MAX_BYTES) {
return
}
// Rotate: .2 -> .3 (dropped), .1 -> .2, .log -> .1
try {
const oldest = `${file}.${MAX_ROTATIONS}`
if (existsSync(oldest)) {
unlinkSync(oldest)
}
} catch {
/* ignore */
}
for (let i = MAX_ROTATIONS - 1; i >= 1; i--) {
const src = `${file}.${i}`
const dst = `${file}.${i + 1}`
try {
if (existsSync(src)) {
renameSync(src, dst)
}
} catch {
/* ignore */
}
}
try {
renameSync(file, `${file}.1`)
} catch {
/* ignore */
}
}
const VALID_KINDS = [
'render',
'unhandled-rejection',
'window-error',
'renderer-gone',
'ipc-rejection',
'ipc-listener'
] as const
type RendererCrashKind = (typeof VALID_KINDS)[number]
export type RendererCrashPayload = {
ts?: number
kind: RendererCrashKind
message?: string
stack?: string
componentStack?: string
boundary?: string
channel?: string
appVersion?: string
extra?: unknown
}
function truncate(value: string | undefined, max: number): string | undefined {
if (value == null) {
return value
}
return value.length > max ? `${value.slice(0, max)}…[truncated]` : value
}
function truncateExtra(extra: unknown): unknown {
if (extra == null) {
return extra
}
try {
const serialized = JSON.stringify(extra)
if (serialized.length <= MAX_EXTRA_CHARS) {
return extra
}
return `${serialized.slice(0, MAX_EXTRA_CHARS)}…[truncated]`
} catch {
return '[unserializable]'
}
}
export function appendRendererCrashEntry(payload: RendererCrashPayload): void {
try {
ensureLogDir()
const file = getLogFile()
rotateIfNeeded(file)
const entry = {
ts: payload.ts ?? Date.now(),
kind: payload.kind,
message: truncate(payload.message, MAX_FIELD_CHARS),
stack: truncate(payload.stack, MAX_FIELD_CHARS),
componentStack: truncate(payload.componentStack, MAX_FIELD_CHARS),
boundary: payload.boundary,
channel: payload.channel,
appVersion: payload.appVersion ?? app.getVersion(),
extra: truncateExtra(payload.extra)
}
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8')
} catch (error) {
// Never throw: the crash-log sink must not itself become a crash source.
console.error('[renderer-crash-log] failed to append entry', error)
}
}
export function getRendererCrashLogPath(): string {
return getLogFile()
}
let registered = false
export function registerRendererCrashLogHandler(): void {
if (registered) {
return
}
registered = true
// Why: registered before window creation so a crash during initial load is
// still captured — see B1 "Registration order" in the design doc.
ipcMain.on('log:renderer-crash', (_event, payload: RendererCrashPayload) => {
if (!payload || typeof payload !== 'object') {
return
}
// Why: reject unknown kinds so a buggy renderer cannot pollute the log
// with values that break downstream kind-based filtering.
if (!VALID_KINDS.includes(payload.kind)) {
return
}
appendRendererCrashEntry(payload)
})
ipcMain.handle('log:revealRendererCrashLog', async () => {
try {
ensureLogDir()
shell.showItemInFolder(getLogFile())
return true
} catch {
return false
}
})
}

View file

@ -1,7 +1,8 @@
/* oxlint-disable max-lines */
import { BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, shell } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { appendRendererCrashEntry, getRendererCrashLogPath } from '../ipc/renderer-crash-log'
import icon from '../../../resources/icon.png?asset'
import devIcon from '../../../resources/icon-dev.png?asset'
import type { Store } from '../persistence'
@ -104,6 +105,66 @@ export function createMainWindow(
}
})
// Why: per error-boundary-design.md §B4, this listener must register before
// loadURL/loadFile so a crash during initial load is still observed. Writes
// the reason to renderer-crashes.log, then (unless a dialog is already
// showing) surfaces a native modal so the user can reload the window.
//
// Window-scoped (not module-scoped): if the window is recreated the flag
// must not leak across instances, and a synchronous throw from
// showMessageBox would otherwise pin the flag to true forever.
let rendererGoneDialogOpen = false
mainWindow.on('closed', () => {
rendererGoneDialogOpen = false
})
mainWindow.webContents.on('render-process-gone', (_event, details) => {
// Why: 'clean-exit' fires on normal shutdown paths (app.quit, window
// close). Treating it as a crash would log false positives and show the
// "Orca stopped responding" dialog on a healthy quit.
if (details.reason === 'clean-exit') {
return
}
appendRendererCrashEntry({
kind: 'renderer-gone',
message: `render process gone: ${details.reason}`,
extra: { reason: details.reason, exitCode: details.exitCode }
})
if (rendererGoneDialogOpen || mainWindow.isDestroyed()) {
return
}
rendererGoneDialogOpen = true
dialog
.showMessageBox(mainWindow, {
type: 'error',
buttons: ['Reload window', 'Quit Orca', 'Reveal log'],
defaultId: 0,
cancelId: 1,
noLink: true,
title: 'Orca stopped responding',
// Why: composer drafts live only in renderer memory; they do not
// survive renderer process death. Called out explicitly per design §B4.
message: 'The Orca window crashed and needs to be reloaded.',
detail: `Reason: ${details.reason}. Unsaved drafts may be lost.`
})
.then((result) => {
rendererGoneDialogOpen = false
if (mainWindow.isDestroyed()) {
return
}
if (result.response === 0) {
mainWindow.webContents.reload()
} else if (result.response === 1) {
// Quit the whole app — a dead renderer has no state to retry into.
app.quit()
} else if (result.response === 2) {
shell.showItemInFolder(getRendererCrashLogPath())
}
})
.catch(() => {
rendererGoneDialogOpen = false
})
})
if (process.platform === 'darwin') {
// Why: persistent parked webviews use separate compositor layers, and on
// recent macOS releases those layers can fail to repaint after occlusion or

View file

@ -239,7 +239,31 @@ export type AppApi = {
relaunch: () => Promise<void>
}
export type RendererCrashKind =
| 'render'
| 'unhandled-rejection'
| 'window-error'
| 'renderer-gone'
| 'ipc-rejection'
| 'ipc-listener'
export type RendererCrashForwardPayload = {
kind: RendererCrashKind
message?: string
stack?: string
componentStack?: string
boundary?: string
channel?: string
extra?: unknown
}
export type CrashLogApi = {
report: (payload: RendererCrashForwardPayload) => void
revealLog: () => Promise<boolean>
}
export type PreloadApi = {
crashLog: CrashLogApi
app: AppApi
repos: {
list: () => Promise<Repo[]>

View file

@ -20,6 +20,7 @@ import {
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
import type { RendererCrashForwardPayload } from './api-types'
type NativeDropResolution =
| { target: 'editor' }
@ -158,8 +159,28 @@ document.addEventListener(
true
)
// Why: the crash-log sink is defined here instead of as a method on `api.app`
// so the renderer's global window.onerror / unhandledrejection handlers can
// forward to it before React mounts. Payload shape is imported from
// api-types.d.ts (single source of truth, mirrored by RendererCrashPayload in
// src/main/ipc/renderer-crash-log.ts).
// Custom APIs for renderer
const api = {
crashLog: {
// Why: one-way send (not invoke). The renderer global handler must not
// await anything — if the renderer is dying, we want the payload on its
// way to disk as fast as possible.
report: (payload: RendererCrashForwardPayload): void => {
try {
ipcRenderer.send('log:renderer-crash', payload)
} catch {
// Sink must never throw back into the caller.
}
},
revealLog: (): Promise<boolean> => ipcRenderer.invoke('log:revealRendererCrashLog')
},
app: {
getRuntimeFlags: (): Promise<{ daemonEnabledAtStartup: boolean }> =>
ipcRenderer.invoke('app:getRuntimeFlags'),

View file

@ -0,0 +1,171 @@
import React from 'react'
import { formatCrashDiagnostics, reportRendererCrash } from '../lib/crash-log'
type Props = { children: React.ReactNode }
type State = { error: Error | null; componentStack: string | null; copied: boolean }
// Why: renderer root last-resort boundary per error-boundary-design.md Layer 1.
// Fallback UI must be as close to static as possible — zero context/store
// access, zero i18n lookups, zero imports from app code — because if the
// fallback itself crashes, the window is fully blank with no recovery. Do not
// add `useTheme`, `useTranslation`, or similar hooks here.
export class AppErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, componentStack: null, copied: false }
private buttonRef = React.createRef<HTMLButtonElement>()
static getDerivedStateFromError(error: Error): Partial<State> {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
this.setState({ componentStack: info.componentStack ?? null })
// Why: forwarding is wrapped so a logging failure cannot itself crash
// the fallback — the fallback is our last defense against white-screens.
try {
reportRendererCrash({
kind: 'render',
boundary: 'AppErrorBoundary',
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined
})
} catch {
/* swallow */
}
}
componentDidUpdate(_prevProps: Props, prevState: State): void {
if (!prevState.error && this.state.error) {
// Why: accessibility — move focus to the primary Reload action when
// the fallback first renders. Wrapped so a focus failure cannot loop
// into componentDidCatch.
try {
this.buttonRef.current?.focus()
} catch {
/* ignore */
}
}
}
handleReload = (): void => {
try {
window.location.reload()
} catch {
/* If even reload fails, there is nothing sensible to do. */
}
}
handleCopy = async (): Promise<void> => {
try {
const text = formatCrashDiagnostics(this.state.error, this.state.componentStack ?? undefined)
await navigator.clipboard.writeText(text)
this.setState({ copied: true })
} catch {
// Clipboard can fail on older Electron / permission denied — fall back
// to a textarea selection so the user can still copy manually.
this.setState({ copied: false })
}
}
render(): React.ReactNode {
if (!this.state.error) {
return this.props.children
}
// Inline styles by design: no Tailwind class lookups, no theme hooks.
// If the app's CSS failed to load, the fallback still renders.
const wrap: React.CSSProperties = {
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
background: '#111',
color: '#f5f5f5',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
zIndex: 2147483647
}
const card: React.CSSProperties = {
maxWidth: '560px',
width: '100%',
padding: '24px',
borderRadius: '8px',
background: '#1c1c1c',
border: '1px solid #333',
boxShadow: '0 10px 40px rgba(0,0,0,0.5)'
}
const title: React.CSSProperties = {
margin: '0 0 8px',
fontSize: '18px',
fontWeight: 600
}
const body: React.CSSProperties = {
margin: '0 0 16px',
fontSize: '13px',
lineHeight: 1.5,
opacity: 0.85
}
const row: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '12px'
}
const btn: React.CSSProperties = {
appearance: 'none',
border: '1px solid #444',
background: '#2a2a2a',
color: '#f5f5f5',
padding: '8px 14px',
borderRadius: '6px',
fontSize: '13px',
cursor: 'pointer'
}
const btnPrimary: React.CSSProperties = {
...btn,
background: '#2563eb',
borderColor: '#2563eb'
}
const link: React.CSSProperties = {
...btn,
display: 'inline-block',
textDecoration: 'none',
textAlign: 'center'
}
return (
<div role="alert" style={wrap}>
<div style={card}>
<h2 style={title}>Orca hit an unexpected error</h2>
<p style={body}>
Something in the window crashed and we reset to this safe screen to keep the app from
getting stuck. Reload the window to get back to work. Copy diagnostics if you want to
share details with us.
</p>
<div style={row}>
<button
ref={this.buttonRef}
type="button"
style={btnPrimary}
onClick={this.handleReload}
>
Reload window
</button>
<button type="button" style={btn} onClick={this.handleCopy}>
{this.state.copied ? 'Copied!' : 'Copy diagnostics'}
</button>
<a
href="https://github.com/stablyai/orca/issues/new"
target="_blank"
rel="noreferrer"
style={link}
>
Open an issue
</a>
</div>
</div>
</div>
)
}
}

View file

@ -0,0 +1,54 @@
import React from 'react'
import { reportRendererCrash } from '../../lib/crash-log'
type Props = {
/** Reset key the highlighted source. Changing the source retries the
* highlighter automatically. See design §Layer 3. */
resetKey: string
/** Fallback source shown un-highlighted so a persistently bad payload is
* still readable "view source" escape hatch per design §Layer 3. */
source: string
className?: string
children: React.ReactNode
}
type State = { error: Error | null }
// Why: rehype-highlight (lowlight) runs untrusted-ish source through a
// language grammar. A malformed payload can throw inside the highlighter and
// — without containment — blow away the surrounding markdown tree. Falling
// back to the plain <code> with source text keeps the content readable.
export class CodeHighlightErrorBoundary extends React.Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
try {
reportRendererCrash({
kind: 'render',
boundary: 'CodeHighlightErrorBoundary',
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined
})
} catch {
/* swallow */
}
}
componentDidUpdate(prevProps: Props): void {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null })
}
}
render(): React.ReactNode {
if (this.state.error) {
return <code className={this.props.className}>{this.props.source}</code>
}
return this.props.children
}
}

View file

@ -1,3 +1,4 @@
/* oxlint-disable max-lines */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@ -17,6 +18,9 @@ import { absolutePathToFileUri, resolveMarkdownLinkTarget } from './markdown-int
import { useLocalImageSrc } from './useLocalImageSrc'
import CodeBlockCopyButton from './CodeBlockCopyButton'
import MermaidBlock from './MermaidBlock'
import { MermaidErrorBoundary } from './MermaidErrorBoundary'
import { CodeHighlightErrorBoundary } from './CodeHighlightErrorBoundary'
import { extractCodeText } from './extractCodeText'
import {
applyMarkdownPreviewSearchHighlights,
clearMarkdownPreviewSearchHighlights,
@ -322,14 +326,42 @@ export default function MarkdownPreview({
// injection, and sanitized foreignObject labels disappear on some platforms.
code: ({ className, children, ...props }) => {
if (/language-mermaid/.test(className || '')) {
// Why: language-mermaid is unknown to rehype-highlight, so children
// reaches here as a raw string — String() is safe. For other fenced
// languages see extractCodeText below.
const mermaidSource = String(children).trimEnd()
return (
<MermaidBlock content={String(children).trimEnd()} isDark={isDark} htmlLabels={false} />
<MermaidErrorBoundary resetKey={mermaidSource} source={mermaidSource}>
<MermaidBlock content={mermaidSource} isDark={isDark} htmlLabels={false} />
</MermaidErrorBoundary>
)
}
// Why: contain rehype-highlight / lowlight crashes so a malformed
// fenced code block falls back to plain source instead of unmounting
// the whole markdown preview tree. Only fenced blocks (language-*) run
// through the highlighter, so inline code skips the boundary to avoid a
// class-component wrapper around every backtick span. Re-keys on source
// so the next edit auto-recovers. See design §Layer 3.
const isFenced = /language-/.test(className || '')
if (!isFenced) {
return (
<code className={className} {...props}>
{children}
</code>
)
}
// Why: by this point rehype-highlight has replaced `children` with an
// array of React span elements (one per token). String(children) would
// return "[object Object],…" — making resetKey constant (breaking
// auto-recovery) and showing literal "[object Object]" in the fallback.
// Walk the tree to recover the original source text.
const sourceText = extractCodeText(children)
return (
<code className={className} {...props}>
{children}
</code>
<CodeHighlightErrorBoundary resetKey={sourceText} source={sourceText} className={className}>
<code className={className} {...props}>
{children}
</code>
</CodeHighlightErrorBoundary>
)
},
// Why: Wrap <pre> blocks with a positioned container so a copy button can
@ -339,7 +371,10 @@ export default function MarkdownPreview({
// <div> inside <pre> produces invalid HTML.
pre: ({ children, ...props }) => {
const child = React.Children.toArray(children)[0]
if (React.isValidElement(child) && child.type === MermaidBlock) {
if (
React.isValidElement(child) &&
(child.type === MermaidBlock || child.type === MermaidErrorBoundary)
) {
return <>{children}</>
}
return <CodeBlockCopyButton {...props}>{children}</CodeBlockCopyButton>

View file

@ -0,0 +1,63 @@
import React from 'react'
import { reportRendererCrash } from '../../lib/crash-log'
type Props = {
/** Reset key the mermaid source string. When it changes, a transient
* render failure auto-recovers on the next content. See design §Layer 3. */
resetKey: string
/** Fallback should expose the raw source so a persistently bad diagram is
* still inspectable ("view source" escape hatch, per design §Layer 3). */
source: string
children: React.ReactNode
}
type State = { error: Error | null }
// Why: mermaid is a third-party renderer consuming user-authored input; a
// malformed diagram string can throw inside its internal render pipeline and
// — without this boundary — unmount the parent markdown preview tree. Re-keying
// on the source string means changing the diagram auto-recovers without the
// user needing to click anything.
export class MermaidErrorBoundary extends React.Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
try {
reportRendererCrash({
kind: 'render',
boundary: 'MermaidErrorBoundary',
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined
})
} catch {
/* swallow */
}
}
componentDidUpdate(prevProps: Props): void {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null })
}
}
render(): React.ReactNode {
if (this.state.error) {
return (
<div className="mermaid-block">
<div className="mermaid-error">
Diagram could not be rendered. Showing source instead.
</div>
<pre>
<code>{this.props.source}</code>
</pre>
</div>
)
}
return this.props.children
}
}

View file

@ -2,6 +2,7 @@ import React, { useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import MermaidBlock from './MermaidBlock'
import { MermaidErrorBoundary } from './MermaidErrorBoundary'
type MermaidViewerProps = {
content: string
@ -97,7 +98,9 @@ export default function MermaidViewer({
{/* Why: DOMPurify's SVG profile strips <foreignObject> elements that
mermaid uses for HTML labels. Force SVG-native <text> labels so
they survive sanitization same fix as the markdown preview path. */}
<MermaidBlock content={content.trim()} isDark={isDark} htmlLabels={false} />
<MermaidErrorBoundary resetKey={content} source={content}>
<MermaidBlock content={content.trim()} isDark={isDark} htmlLabels={false} />
</MermaidErrorBoundary>
</div>
</div>
)

View file

@ -4,6 +4,7 @@ import type { NodeViewProps } from '@tiptap/react'
import { Copy, Check } from 'lucide-react'
import { useAppStore } from '@/store'
import MermaidBlock from './MermaidBlock'
import { MermaidErrorBoundary } from './MermaidErrorBoundary'
/**
* Common languages shown in the selector. The user can also type a language
@ -118,7 +119,9 @@ export function RichMarkdownCodeBlock({
Mermaid HTML labels just like markdown preview to keep labels visible. */}
{isMermaid && node.textContent.trim() && (
<div contentEditable={false} className="mermaid-preview">
<MermaidBlock content={node.textContent.trim()} isDark={isDark} htmlLabels={false} />
<MermaidErrorBoundary resetKey={node.textContent.trim()} source={node.textContent.trim()}>
<MermaidBlock content={node.textContent.trim()} isDark={isDark} htmlLabels={false} />
</MermaidErrorBoundary>
</div>
)}
</NodeViewWrapper>

View file

@ -0,0 +1,23 @@
import React from 'react'
// Why: rehype-highlight replaces a fenced block's children with a nested tree
// of React span elements (one per token). To get the original plain source
// back — used as resetKey and the fallback view — we walk the tree and
// concatenate text nodes in order. The tree is small (bounded by block size)
// and the cost is negligible compared to the highlight work that produced it.
export function extractCodeText(node: React.ReactNode): string {
if (node == null || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(extractCodeText).join('')
}
if (React.isValidElement(node)) {
const childProps = node.props as { children?: React.ReactNode }
return extractCodeText(childProps.children)
}
return ''
}

View file

@ -0,0 +1,174 @@
// Why: renderer-side crash plumbing for error-boundary-design.md Part B. The
// ring buffer in localStorage is the diagnostic fallback when IPC itself is
// the broken thing — the root AppErrorBoundary's "Copy diagnostics" button
// reads from it without any React/store access so the fallback survives even
// when the rest of the renderer is on fire.
import type { RendererCrashForwardPayload, RendererCrashKind } from '../../../preload/api-types'
const RING_KEY = 'orca.crashLog.ring.v1'
const RING_MAX = 50
const DEDUPE_WINDOW_MS = 1000
export type CrashLogEntry = RendererCrashForwardPayload & { ts: number }
// Why: StrictMode mounts effects twice in dev, and some crash paths re-throw
// across both the `error` and `unhandledrejection` events within the same
// microtask. Hashing message|stack over a short window keeps dev logs readable.
// This is a dev-ergonomics tool only — see design §B2: it is not a prod spam
// guard and cannot meaningfully suppress an infinite render loop.
type DedupeEntry = { hash: string; at: number }
const dedupeRecent: DedupeEntry[] = []
function dedupeHit(message: string | undefined, stack: string | undefined): boolean {
const hash = `${message ?? ''}|${stack ?? ''}`
const now = Date.now()
// Drop expired entries.
while (dedupeRecent.length && now - dedupeRecent[0].at > DEDUPE_WINDOW_MS) {
dedupeRecent.shift()
}
if (dedupeRecent.some((e) => e.hash === hash)) {
return true
}
dedupeRecent.push({ hash, at: now })
return false
}
function readRing(): CrashLogEntry[] {
try {
const raw = localStorage.getItem(RING_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as CrashLogEntry[]) : []
} catch {
return []
}
}
function writeRing(entries: CrashLogEntry[]): void {
const trimmed = entries.length > RING_MAX ? entries.slice(entries.length - RING_MAX) : entries
try {
localStorage.setItem(RING_KEY, JSON.stringify(trimmed))
} catch (err) {
// Why: QuotaExceededError — drop oldest half and retry once. If that
// still fails, give up on localStorage and continue; we must not block
// the IPC send or crash the global handler. See §B2 "Regression Risk".
if (err instanceof DOMException) {
try {
const half = Math.max(1, Math.floor(trimmed.length / 2))
localStorage.setItem(RING_KEY, JSON.stringify(trimmed.slice(trimmed.length - half)))
} catch {
/* give up */
}
}
}
}
function appendRing(entry: CrashLogEntry): void {
try {
const entries = readRing()
entries.push(entry)
writeRing(entries)
} catch {
// Isolated from IPC send: even if the ring-buffer path fails, we still
// attempt IPC below. The ring buffer must never abort the IPC send.
}
}
/**
* Public entry point used by global handlers, boundaries, and IPC wrappers.
* Always safe to call: never throws.
*/
export function reportRendererCrash(payload: RendererCrashForwardPayload): void {
const entry: CrashLogEntry = { ...payload, ts: Date.now() }
// Dev-only dedupe: collapses StrictMode double-fires within a 1s window.
// Why: in production we want every crash recorded — a real repeated failure
// is itself a signal, and suppressing it would mask bug-report evidence.
if (import.meta.env.DEV && dedupeHit(entry.message, entry.stack)) {
return
}
// Ring buffer first so diagnostics exist even when IPC is the broken thing.
appendRing(entry)
try {
window.api?.crashLog?.report(payload)
} catch {
// IPC bridge unavailable (tests, or bridge itself faulted) — ring buffer
// still holds the entry for later retrieval.
}
}
export function readCrashRingBuffer(): CrashLogEntry[] {
return readRing()
}
export function formatCrashDiagnostics(error: Error | null, componentStack?: string): string {
const entries = readRing()
const lines: string[] = []
lines.push(`userAgent: ${navigator.userAgent}`)
if (error) {
lines.push(`error: ${error.message}`)
if (error.stack) {
lines.push(error.stack)
}
}
if (componentStack) {
lines.push('componentStack:')
lines.push(componentStack)
}
lines.push('')
lines.push('--- last crash log entries ---')
for (const e of entries) {
lines.push(
`[${new Date(e.ts).toISOString()}] ${e.kind}${e.boundary ? ` <${e.boundary}>` : ''}${e.channel ? ` {${e.channel}}` : ''}: ${e.message ?? ''}`
)
if (e.stack) {
lines.push(e.stack)
}
}
return lines.join('\n')
}
let installed = false
/**
* Install global window.error / unhandledrejection forwarders. Must run
* before createRoot so a crash during initial module evaluation is still
* captured. Safe to call multiple times.
*/
export function installGlobalRendererErrorHandlers(): void {
if (installed) {
return
}
installed = true
window.addEventListener('error', (e) => {
try {
const error = e.error
const message = error instanceof Error ? error.message : String(e.message ?? 'unknown error')
const stack = error instanceof Error ? error.stack : undefined
reportRendererCrash({ kind: 'window-error', message, stack })
} catch {
// Handler is the last line of defense — must never throw back into
// the event loop.
}
})
window.addEventListener('unhandledrejection', (e) => {
try {
const reason = e.reason
const message =
reason instanceof Error ? reason.message : String(reason ?? 'unknown rejection')
const stack = reason instanceof Error ? reason.stack : undefined
reportRendererCrash({ kind: 'unhandled-rejection', message, stack })
} catch {
// See above.
}
})
}
export type { RendererCrashKind }

View file

@ -3,6 +3,8 @@ import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { AppErrorBoundary } from './components/AppErrorBoundary'
import { installGlobalRendererErrorHandlers } from './lib/crash-log'
if (import.meta.env.DEV) {
import('react-grab').then(({ init }) => init())
@ -18,8 +20,15 @@ function applySystemTheme(): void {
applySystemTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applySystemTheme)
// Why: global error handlers must install before React mounts so a crash
// during initial module evaluation or inside createRoot() still reaches the
// crash log. See docs/error-boundary-design.md §B2.
installGlobalRendererErrorHandlers()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</StrictMode>
)