UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

173 lines (162 loc) 6.76 kB
// install-error-handlers — protects `one`'s CLI long-running commands // (build, dev, serve) from a class of catastrophic process zombies. // // background: 2026-05-05 the soot CI runner accumulated four `Onejs:build` // processes pegged at 100% CPU for 3-9 days each. all of them were stuck // inside V8's stack-trace formatter. the chain: // // 1. build code throws an uncaught exception // 2. one's CLI handler runs `console.error(err?.message || err)`. when // `err.message` is empty, the fallback prints the Error object itself. // 3. console.error invokes node's inspector formatter, which reads // `error.stack`. that getter calls Error.prepareStackTrace. // 4. Error.prepareStackTrace was set by source-map-support (a transitive // dep of esbuild-register, vite, etc.). its formatter has no recursion // guard — if wrapCallSite or any frame-string operation throws (very // possible under disk/memory pressure during fs.readFileSync of source // maps), the resulting error's own stack getter triggers the same // formatter again. V8 spins in c++ trying to format the new error, // never yields to the JS event loop, never processes SIGTERM. // 5. node's CLI was also missing process.exit(1) in the handler, so even // a clean uncaught exception left the process alive holding cpu/memory. // // what we install: // // • installPrepareStackTraceGuard() — wraps Error.prepareStackTrace via a // property descriptor with a getter/setter. the getter always hands back // a re-entry guarded wrapper; the setter captures whatever a library // wants to use as the formatter. if that user-formatter throws or is // called recursively, we bail to a default format. this stops the V8 // spin-loop dead even if a buggy formatter ships with a transitive dep. // • installSafeUncaughtHandler(label) — drop-in replacement for the // existing `process.on('uncaughtException', ...)` calls. always exits // after logging so a fatal error becomes a fast non-zero exit instead // of a permanent 100%-cpu zombie. // • formatErrorSafely(err) — lower-level helper used inside the handler. // temporarily zeroes Error.prepareStackTrace so `err.stack` reads use // V8's default formatter, even on ancient node versions where our // getter/setter ride-along somehow gets clobbered. type StackPrepare = (error: Error, stack: NodeJS.CallSite[]) => string let userPrepare: StackPrepare | undefined let depth = 0 // prior userPrepare values, paired with the wrapper they were displaced by. // keeps the React-style save/zero/restore pattern working: saving the wrapper, // setting Error.prepareStackTrace = undefined, then setting it back to the // saved wrapper restores the right userPrepare. const restoreTrail: Array<StackPrepare | undefined> = [] function defaultFormat(error: Error, stack: NodeJS.CallSite[]): string { const name = error?.name ?? 'Error' const message = error?.message ?? '' const head = `${name}: ${message}` try { const lines = stack.map((frame) => { try { return ` at ${frame}` } catch { return ' at <frame>' } }) return `${head}\n${lines.join('\n')}` } catch { return head } } function guardedPrepare(error: Error, stack: NodeJS.CallSite[]): string { if (depth > 0) return defaultFormat(error, stack) if (typeof userPrepare !== 'function') return defaultFormat(error, stack) depth++ try { return userPrepare(error, stack) } catch { return defaultFormat(error, stack) } finally { depth-- } } let installed = false export function installPrepareStackTraceGuard(): void { if (installed) return installed = true const existing = Error.prepareStackTrace if (typeof existing === 'function' && existing !== guardedPrepare) { userPrepare = existing as StackPrepare } Object.defineProperty(Error, 'prepareStackTrace', { configurable: true, get(): StackPrepare { return guardedPrepare }, set(value: unknown): void { if (value === guardedPrepare) { // restore-after-save round-trip. pop the matching userPrepare so // libraries that read → set undefined → set back to the saved value // end up with their original formatter still installed. if (restoreTrail.length > 0) { userPrepare = restoreTrail.pop() } return } restoreTrail.push(userPrepare) userPrepare = typeof value === 'function' ? (value as StackPrepare) : undefined }, }) } // reads err.stack with Error.prepareStackTrace temporarily zeroed so a // broken user-formatter can't recurse on us — even if installPrepareStackTrace- // Guard isn't active for some reason. used inside our uncaught handlers. export function formatErrorSafely(err: unknown): string { const prevPrepare = Error.prepareStackTrace // setting to undefined makes V8 use its built-in default stack format on // err.stack reads. node's @types declare prepareStackTrace as a function, // so cast through unknown to express the runtime allowance. Error.prepareStackTrace = undefined as unknown as typeof Error.prepareStackTrace try { if (err instanceof Error) { let stack = '' try { stack = err.stack ?? '' } catch { // nothing } if (stack) return stack const name = err.name || 'Error' const message = err.message || '' return `${name}: ${message}` } return String(err) } catch { try { return String(err) } catch { return '<unprintable error>' } } finally { Error.prepareStackTrace = prevPrepare } } // drop-in replacement for `process.on('uncaughtException', console.error)` // patterns. `label` distinguishes which command emitted the failure — useful // when grepping CI logs across build/dev/serve. always exits (1) — a leaked // uncaught is what produced the 9-day Onejs:build zombies. export function installSafeUncaughtHandler(label: string): void { installPrepareStackTraceGuard() process.on('uncaughtException', (err) => { try { const formatted = formatErrorSafely(err) process.stderr.write(`[${label}] uncaught exception\n${formatted}\n`) } catch { // last-resort: never throw out of an uncaught handler } process.exit(1) }) process.on('unhandledRejection', (reason) => { try { const formatted = formatErrorSafely(reason) process.stderr.write(`[${label}] unhandled rejection\n${formatted}\n`) } catch { // nothing } // unhandledRejection alone shouldn't kill long-running dev servers; only // the build/serve callers that need fail-fast pass exitOnRejection. }) }