mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
6b933d7faa
commit
b08ead7faf
14 changed files with 832 additions and 9 deletions
|
|
@ -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')
|
||||
|
|
|
|||
176
src/main/ipc/renderer-crash-log.ts
Normal file
176
src/main/ipc/renderer-crash-log.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
24
src/preload/api-types.d.ts
vendored
24
src/preload/api-types.d.ts
vendored
|
|
@ -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[]>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
171
src/renderer/src/components/AppErrorBoundary.tsx
Normal file
171
src/renderer/src/components/AppErrorBoundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
63
src/renderer/src/components/editor/MermaidErrorBoundary.tsx
Normal file
63
src/renderer/src/components/editor/MermaidErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
src/renderer/src/components/editor/extractCodeText.ts
Normal file
23
src/renderer/src/components/editor/extractCodeText.ts
Normal 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 ''
|
||||
}
|
||||
174
src/renderer/src/lib/crash-log.ts
Normal file
174
src/renderer/src/lib/crash-log.ts
Normal 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 }
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue