mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
|
|
import { removeBootstrapFatalExitGuard } from './bootstrap-fatal-exit-guard'
|
|
|
|
type FatalMainProcessErrorKind = 'main_uncaught_exception' | 'main_unhandled_rejection'
|
|
|
|
type FatalMainProcessErrorDetails = {
|
|
errorName: string
|
|
errorMessage: string
|
|
errorStack: string
|
|
errorCode: string
|
|
}
|
|
|
|
function readErrorProperty(error: unknown, property: string): unknown {
|
|
try {
|
|
return error !== null && (typeof error === 'object' || typeof error === 'function')
|
|
? (error as Record<string, unknown>)[property]
|
|
: undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function boundedString(value: unknown, maxLength: number, fallback = ''): string {
|
|
try {
|
|
return String(value).slice(0, maxLength)
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
function fatalMainProcessErrorDetails(error: unknown): FatalMainProcessErrorDetails {
|
|
let isError = false
|
|
try {
|
|
isError = error instanceof Error
|
|
} catch {
|
|
// Why: a proxy can throw from instanceof; fatal diagnostics still need a safe fallback.
|
|
}
|
|
|
|
return {
|
|
errorName: isError
|
|
? boundedString(readErrorProperty(error, 'name') ?? 'Error', 100, 'Error')
|
|
: typeof error,
|
|
errorMessage: isError
|
|
? boundedString(readErrorProperty(error, 'message') ?? '', 500)
|
|
: boundedString(error, 500, '[unprintable value]'),
|
|
errorStack: isError
|
|
? boundedString(readErrorProperty(error, 'stack') ?? '', 4_000)
|
|
.split('\n')
|
|
.slice(0, 12)
|
|
.join('\n')
|
|
: '',
|
|
errorCode: boundedString(readErrorProperty(error, 'code') ?? '', 100)
|
|
}
|
|
}
|
|
|
|
// Why: one broken resource can reject hundreds of concurrent restore chains; each record does a
|
|
// synchronous trace flush, so an uncapped storm stalls main and churns the trace-file rotation.
|
|
const RECORD_WINDOW_MS = 60_000
|
|
const RECORD_WINDOW_MAX = 20
|
|
let recordWindowStartedAt = 0
|
|
let recordWindowCount = 0
|
|
let recordsSuppressed = 0
|
|
|
|
/** Durably record a main-process fatal/near-fatal error before default handling runs. Exported for tests. */
|
|
export function recordFatalMainProcessError(kind: FatalMainProcessErrorKind, error: unknown): void {
|
|
// Why: only rejections can storm; the one uncaught-exception record before the fatal re-throw
|
|
// must never be lost to a window a storm already exhausted.
|
|
if (kind === 'main_unhandled_rejection') {
|
|
const now = Date.now()
|
|
// Why: a backward clock jump (sleep/resume, NTP) would otherwise trap an exhausted window and suppress every breadcrumb until wall time catches up.
|
|
if (now < recordWindowStartedAt || now - recordWindowStartedAt >= RECORD_WINDOW_MS) {
|
|
recordWindowStartedAt = now
|
|
recordWindowCount = 0
|
|
}
|
|
if (recordWindowCount >= RECORD_WINDOW_MAX) {
|
|
recordsSuppressed += 1
|
|
return
|
|
}
|
|
recordWindowCount += 1
|
|
}
|
|
const suppressedSinceLast = recordsSuppressed
|
|
recordsSuppressed = 0
|
|
const details = fatalMainProcessErrorDetails(error)
|
|
try {
|
|
recordDurableCrashBreadcrumb(
|
|
kind,
|
|
suppressedSinceLast > 0 ? { ...details, suppressedSinceLast } : details,
|
|
kind
|
|
)
|
|
} catch {
|
|
// Why: diagnostics must never turn a fatal-error report into a second fault.
|
|
}
|
|
try {
|
|
console.error(
|
|
`[${kind}] ${details.errorStack || `${details.errorName}: ${details.errorMessage}`}`
|
|
)
|
|
} catch {
|
|
// Why: custom console sinks must not defeat the process-level safety guard.
|
|
}
|
|
}
|
|
|
|
export function installUncaughtPipeErrorGuard(): void {
|
|
const onUncaughtException = (error: unknown): void => {
|
|
const errorCode = readErrorProperty(error, 'code')
|
|
if (errorCode === 'EIO' || errorCode === 'EPIPE') {
|
|
return
|
|
}
|
|
|
|
// Why (issue #9441): the re-throw below exits with a clean code and no macOS crash report; record durably first or the death is undiagnosable in the field.
|
|
recordFatalMainProcessError('main_uncaught_exception', error)
|
|
process.off('uncaughtException', onUncaughtException)
|
|
// Why: throwing inside an uncaughtException handler exits with status 7 and hides the fault; re-throw next tick for the real stack.
|
|
setImmediate(() => {
|
|
throw error
|
|
})
|
|
}
|
|
|
|
process.on('uncaughtException', onUncaughtException)
|
|
removeBootstrapFatalExitGuard()
|
|
}
|
|
|
|
/** Keep one failed background promise from silently killing the whole app.
|
|
*
|
|
* Node's default kills the process on an unhandled rejection. Large-profile startup restore runs
|
|
* hundreds of concurrent async chains (worktree scans, terminal reconnects) in main; a single
|
|
* rejection in any of them exited the app with no crash report (issue #9441). Log it durably and
|
|
* stay alive — dying cannot be less disruptive than continuing with one failed background task.
|
|
*/
|
|
export function installUnhandledRejectionLogging(): void {
|
|
process.on('unhandledRejection', (reason) => {
|
|
recordFatalMainProcessError('main_unhandled_rejection', reason)
|
|
})
|
|
}
|