Files
oh-my-opencode/src/features/background-agent/process-cleanup.ts
T
YeonGyu-Kim 4d417a33b6 fix(process-cleanup): stop force-exiting opencode on transient unhandled errors
Root cause of the user-visible `/init-deep ulw` hang (session
`ses_1cb9c3013ffesUOy5H3QOIya4K`): the plugin's `unhandledRejection` and
`uncaughtException` listeners were calling
`scheduleForcedExit(handler(error), 1, true)`, which both ran the entire
`cleanupAll()` chain (BackgroundManager shutdown, tmux pane closure,
team-mode teardown) and then `process.exit(1)`'d the host. Under heavy
slash commands like `/init-deep ultrafucking deep`, a single mid-stream
error (e.g. opencode's own `session.processor` Aborted-process condition,
or a transient socket reset) would:

  1. trigger the listener,
  2. abort the in-flight background tasks (`session.error
     MessageAbortedError` for both child sessions in the log),
  3. close the tmux panes the user was watching,
  4. immediately kill the host via `process.exit(1)`.

From the user's seat that looked like a frozen TUI, which is what they
reported as "ulw 여전히 멈추는데". The error blob also logged as `{}`
because `JSON.stringify(new Error(...))` strips non-enumerable Error
fields, so the previous log line carried no diagnostic value.

This change makes the global `uncaughtException` / `unhandledRejection`
listeners log-only:

* New `describeProcessCleanupError()` extracts `{name, message, stack}`
  from Error instances, falls back to a structured `{raw: ...}` payload
  for plain objects / primitives, so the log now actually says what
  failed.
* `registerErrorEvent()` no longer runs cleanup and no longer calls
  `scheduleForcedExit`. It detaches itself, logs a single explanatory
  line, and returns. Bun's default crash behaviour is already suppressed
  for these events when a listener is present, so the host now genuinely
  survives transient streaming errors instead of being killed by our
  own helper.
* Signal handlers (`SIGINT` / `SIGTERM` / `SIGBREAK` / `beforeExit` /
  `exit`) keep their existing behaviour and still run `cleanupAll()`
  before the host terminates — that is now the only path that tears
  down background tasks and tmux panes.

Tests are updated to lock in the new contract:

* New regression `#given scheduleForcedExit enabled AND unhandledRejection
  fires #when the listener runs #then process.exit is NOT called AND
  process.exitCode stays 0 AND no cleanup runs` (and the
  uncaughtException twin) re-enables `scheduleForcedExit`, spies on
  `process.exit` plus `globalThis.setTimeout`, and asserts none of them
  are touched. Without the fix this test failed exactly like the
  observed hang (exit called once, exitCode set to 1).
* The existing "manager shuts down before process exits" tests are
  rewritten to assert the opposite: cleanup is NOT invoked from the
  error path.
* A complementary `'exit'` listener test pins the real shutdown
  contract (`exit` event still triggers `cleanupAll`).
* A new `#given describeProcessCleanupError` block covers the four
  shapes (Error, plain object with own fields, empty object, primitive).
* The unregister assertion is tightened to check the listener count
  drops back to baseline (previously it relied on a side effect of the
  old cleanup-on-error path).

Manual QA: `bun /tmp/process-cleanup-smoke-test.ts` (out-of-tree smoke
driver) emits five back-to-back unhandledRejection/uncaughtException
events with various payload shapes and prints
`SMOKE_TEST_OK survived 5 emissions; exitCode=0; shutdownInvocations=0`,
confirming the host survives and no spurious cleanup runs.

`bun test` runs green for the affected modules:
  - src/features/background-agent (528 tests)
  - src/create-managers + src/plugin (218 tests)
  - src/hooks/{unstable-agent-babysitter,ralph-loop,keyword-detector}
    (225 tests)
2026-05-17 15:11:44 +09:00

228 lines
7.7 KiB
TypeScript

import { log } from "../../shared"
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
/**
* When set to a truthy value (1/true/yes/on), skips registering the global
* uncaughtException / unhandledRejection log listeners entirely.
*
* The listeners are log-only by default and no longer force-exit the host
* (originally a fix for issue #3856 that previously turned every transient
* streaming rejection into a `process.exit(1)`; reverified during the ulw
* `/init-deep` hang investigation that motivated the log-only rewrite).
* Setting this env var still makes the plugin silent on those events; leave
* it unset whenever you want the diagnostic line and the `name/message/stack`
* payload from `describeProcessCleanupError`.
*
* Signal handlers (SIGINT/SIGTERM/SIGBREAK/beforeExit/exit) remain registered
* because they are the real shutdown path and run `cleanupAll()` before the
* host actually terminates.
*/
const PROCESS_CLEANUP_DISABLE_ENV = "OMO_DISABLE_PROCESS_CLEANUP"
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"])
function isProcessCleanupErrorHandlersDisabled(): boolean {
const raw = process.env[PROCESS_CLEANUP_DISABLE_ENV]
if (!raw) return false
return TRUTHY_ENV_VALUES.has(raw.trim().toLowerCase())
}
/** @internal test-only seam: prevents process.exitCode from contaminating bun test runner */
let _scheduleForcedExitEnabled = true
/** @internal test-only */
export function __disableScheduledForcedExitForTesting(): void {
_scheduleForcedExitEnabled = false
}
/** @internal test-only */
export function __enableScheduledForcedExitForTesting(): void {
_scheduleForcedExitEnabled = true
}
function scheduleForcedExit(
cleanupResult: void | Promise<void>,
exitCode: number,
exitAfterCleanup = false,
): void {
if (!_scheduleForcedExitEnabled) return
process.exitCode = exitCode
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
if (exitAfterCleanup) {
process.exit(exitCode)
}
})
}
function registerProcessSignal(
signal: ProcessCleanupSignal,
handler: () => void | Promise<void>,
exitAfter: boolean
): () => void {
const listener = () => {
const cleanupResult = handler()
if (exitAfter) {
scheduleForcedExit(cleanupResult, 0)
}
}
process.on(signal, listener)
return listener
}
/** @internal test-only seam: exposes the error normalizer used by registerErrorEvent. */
export function describeProcessCleanupError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
}
}
if (typeof error === "object" && error !== null) {
try {
const json = JSON.stringify(error)
if (json !== "{}") return { raw: json }
} catch {
}
return { raw: String(error) }
}
return { raw: String(error) }
}
function registerErrorEvent(
signal: ProcessCleanupErrorEvent,
): (error: unknown) => void {
// Log-only listener. We deliberately DO NOT run cleanup or force-exit on
// transient errors.
//
// History: earlier this listener invoked `scheduleForcedExit(handler(error),
// 1, true)` so every unhandled promise rejection ran the registered cleanup
// (BackgroundManager shutdown, tmux pane closure, team-mode teardown) and
// then `process.exit(1)`'d the host. With OpenCode bundled under Bun, our
// listener already suppresses the default crash behavior, so the host was
// surviving the error itself but we were tearing it down ourselves. During
// heavy slash commands like `/init-deep` running in ulw mode that turned a
// single transient streaming error (e.g. a mid-stream socket reset or
// `session.processor` Aborted-process condition) into a frozen TUI for the
// user.
//
// The signal handlers (SIGINT / SIGTERM / SIGBREAK / beforeExit / exit)
// still cover real shutdown paths and run `cleanupAll()` before process
// termination. `exit` in particular fires for every controlled exit
// regardless of cause, so cleanup is not skipped when the host genuinely
// dies.
//
// We still detach the listener before logging so a re-emit from inside
// `log()` (e.g. EPIPE while writing to a broken pipe during shutdown)
// cannot recurse and produce the 100+ GB log explosion that #3856-era
// regressions caused.
const listener = (error: unknown) => {
process.off(signal, listener)
log(
`[background-agent] ${signal} observed; keeping host alive and skipping cleanup (signal handlers run on real shutdown)`,
describeProcessCleanupError(error),
)
}
process.on(signal, listener)
return listener
}
interface CleanupTarget {
shutdown(): void | Promise<void>
}
const cleanupManagers = new Set<CleanupTarget>()
let cleanupRegistered = false
const cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
export function registerManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.add(manager)
if (cleanupRegistered) return
cleanupRegistered = true
let cleanupPromise: Promise<void> | undefined
const cleanupAll = (): Promise<void> => {
if (cleanupPromise) return cleanupPromise
const promises: Promise<void>[] = []
for (const m of cleanupManagers) {
try {
promises.push(
Promise.resolve(m.shutdown()).catch((error) => {
log("[background-agent] Error during async shutdown cleanup:", error)
})
)
} catch (error) {
log("[background-agent] Error during shutdown cleanup:", error)
}
}
cleanupPromise = Promise.allSettled(promises).then(() => {})
cleanupPromise.then(() => {
log("[background-agent] All shutdown cleanup completed")
})
return cleanupPromise
}
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
cleanupSignalHandlers.set(signal, listener)
}
registerSignal("SIGINT", true)
registerSignal("SIGTERM", true)
if (process.platform === "win32") {
registerSignal("SIGBREAK", true)
}
registerSignal("beforeExit", false)
registerSignal("exit", false)
if (isProcessCleanupErrorHandlersDisabled()) {
log(
`[background-agent] ${PROCESS_CLEANUP_DISABLE_ENV} is set; skipping global uncaughtException/unhandledRejection handler registration. `
+ "Signal handlers (SIGINT/SIGTERM/beforeExit/exit) remain active.",
)
return
}
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException"))
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection"))
}
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.delete(manager)
if (cleanupManagers.size > 0) return
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}
/** @internal - test-only reset for module-level singleton state */
export function _resetForTesting(): void {
for (const manager of [...cleanupManagers]) {
cleanupManagers.delete(manager)
}
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}