From 944b000f562a999153e0fef3cd98d951576c9c2d Mon Sep 17 00:00:00 2001 From: Fynn Date: Mon, 16 Feb 2026 21:59:32 +0800 Subject: [PATCH] fix(tui): add signal handlers to prevent orphaned processes on terminal close When a terminal tab/window is closed, the OS sends SIGHUP to the process group. @opentui/core registers handlers for SIGTERM/SIGINT/SIGQUIT/SIGABRT that call destroy() but never call process.exit() or re-raise the signal, silently swallowing them. SIGHUP has no handler at all. Combined with the Worker thread keeping the event loop alive, the bun binary survives as an orphan (PPID=1) with revoked file descriptors, leaking memory indefinitely. On a real machine this accumulated 150+ orphaned processes consuming ~13GB of RAM over 10 days. The fix registers signal handlers in thread.ts that gracefully shut down the worker (disposing instances, stopping servers) before calling process.exit() with the correct signal-based exit code (128 + signum). A 5-second timeout ensures exit even if shutdown hangs. Fixes #12767, relates to #11527, #10563 --- packages/opencode/src/cli/cmd/tui/thread.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6d41fe857a61..9c0a2c7aa5bf 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -128,6 +128,28 @@ export const TuiThreadCommand = cmd({ await client.call("reload", undefined) }) + // Ensure the process exits when receiving termination signals. + // Without this, @opentui/core's exitHandler swallows SIGTERM/SIGINT/etc. + // (it calls destroy() but never process.exit()), and SIGHUP has no handler + // at all. When a terminal tab is closed the bun binary survives as an + // orphan (PPID=1) with revoked file descriptors, leaking memory forever. + let exiting = false + for (const signal of ["SIGHUP", "SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + if (exiting) return + exiting = true + const code = signal === "SIGHUP" ? 129 : signal === "SIGINT" ? 130 : 143 + const timeout = setTimeout(() => process.exit(code), 5000) + client + .call("shutdown", undefined) + .catch(() => {}) + .finally(() => { + clearTimeout(timeout) + process.exit(code) + }) + }) + } + const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped