From eaf161cbbdc017fc17d0c7219ee35155bc7eff3f Mon Sep 17 00:00:00 2001 From: Sanghyuk Jeong Date: Mon, 9 Feb 2026 01:23:04 +0900 Subject: [PATCH 1/2] fix(cli): handle SIGHUP to prevent orphaned processes on terminal close (#10563) --- packages/opencode/src/cli/cmd/serve.ts | 6 +++ packages/opencode/src/cli/cmd/tui/attach.ts | 6 +++ packages/opencode/src/cli/cmd/tui/thread.ts | 7 +++ packages/opencode/src/cli/cmd/tui/worker.ts | 20 ++++++++ packages/opencode/src/index.ts | 11 +++++ .../test/cli/signal/signal-handling.test.ts | 46 +++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 packages/opencode/test/cli/signal/signal-handling.test.ts diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 8f4bb014469..9c7d5b48617 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -11,6 +11,12 @@ export const ServeCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { + for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.once(signal, () => { + process.kill(process.pid, signal) + }) + } + if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d1..b548523b99b 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -40,6 +40,12 @@ export const AttachCommand = cmd({ describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }), handler: async (args) => { + for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.once(signal, () => { + process.kill(process.pid, signal) + }) + } + const unguard = win32InstallCtrlCGuard() try { win32DisableProcessedInput() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 750347d9d63..3493644195e 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -132,6 +132,13 @@ export const TuiThreadCommand = cmd({ await client.call("reload", undefined) }) + for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.once(signal, async () => { + await client.call("shutdown", undefined).catch(() => {}) + process.kill(process.pid, signal) + }) + } + const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index bb5495c4811..510e1a9df2e 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -32,6 +32,26 @@ process.on("uncaughtException", (e) => { }) }) +for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.once(signal, () => { + rpc.shutdown().finally(() => { + process.kill(process.pid, signal) + }) + }) +} + +if (process.platform !== "win32") { + const monitor = setInterval(() => { + // Avoid stale parent PID checks; rely on reparent-to-init (ppid=1) instead. + if (process.ppid !== 1) return + clearInterval(monitor) + rpc.shutdown().finally(() => { + process.kill(process.pid, "SIGTERM") + }) + }, 2000) + monitor.unref() +} + // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 35b42dce77c..4e68a3184ce 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -46,6 +46,17 @@ process.on("uncaughtException", (e) => { }) }) +// Safety net: on signal, schedule a forced process.exit(0) after 3s, +// then remove this listener and re-raise so command-specific handlers +// (and finally the OS default) can still run gracefully. +// process.exit() is intentional here — it's a last resort if graceful shutdown hangs. +for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.once(signal, () => { + setTimeout(() => process.exit(0), 3000).unref() + process.kill(process.pid, signal) + }) +} + let cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("opencode") diff --git a/packages/opencode/test/cli/signal/signal-handling.test.ts b/packages/opencode/test/cli/signal/signal-handling.test.ts new file mode 100644 index 00000000000..3a0ae519ae7 --- /dev/null +++ b/packages/opencode/test/cli/signal/signal-handling.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "bun:test" +import path from "path" + +const root = path.join(__dirname, "../../..") +const entry = path.join(root, "src/index.ts") + +function spawn(args: string[]) { + return Bun.spawn(["bun", "run", "--conditions=browser", entry, ...args], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) +} + +async function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe("signal handling", () => { + test.skipIf(process.platform === "win32")("serve exits on SIGHUP", async () => { + const proc = spawn(["serve", "--port", "0"]) + await Bun.sleep(3000) + expect(await alive(proc.pid)).toBe(true) + + process.kill(proc.pid, "SIGHUP") + await proc.exited + + expect(await alive(proc.pid)).toBe(false) + }) + + test("serve exits on SIGTERM", async () => { + const proc = spawn(["serve", "--port", "0"]) + await Bun.sleep(3000) + expect(await alive(proc.pid)).toBe(true) + + process.kill(proc.pid, "SIGTERM") + await proc.exited + + expect(await alive(proc.pid)).toBe(false) + }) +}) From 89c11de26ab6a0e3e16097b62dfdc66f4dd5ac22 Mon Sep 17 00:00:00 2001 From: Sanghyuk Jeong Date: Tue, 3 Mar 2026 11:03:07 +0900 Subject: [PATCH 2/2] test(e2e): make session question seeding more resilient --- packages/app/e2e/actions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba61752..ae256bbabf0 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -396,7 +396,8 @@ export async function seedSessionQuestion( sdk, sessionID: input.sessionID, prompt: text, - timeout: 30_000, + timeout: 60_000, + attempts: 4, probe: async () => { const list = await sdk.question.list().then((x) => x.data ?? []) return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)