diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index bee2c8f711f..148c335ed33 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -8,6 +8,10 @@ export const ServeCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { + // Exit on terminal closure or termination + process.on("SIGHUP", () => process.exit(0)) + process.on("SIGTERM", () => process.exit(0)) + 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 e852cb73d4c..ff20e1c577e 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -26,6 +26,10 @@ export const AttachCommand = cmd({ describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }), handler: async (args) => { + // Exit on terminal closure or termination + process.on("SIGHUP", () => process.exit(0)) + process.on("SIGTERM", () => process.exit(0)) + const directory = (() => { if (!args.dir) return undefined try { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 2ea49ff6b2b..6da7e826013 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -118,6 +118,14 @@ export const TuiThreadCommand = cmd({ await client.call("reload", undefined) }) + // Graceful shutdown on terminal closure or termination + const shutdown = async () => { + await client.call("shutdown", undefined).catch(() => {}) + process.exit(0) + } + process.on("SIGHUP", shutdown) + process.on("SIGTERM", shutdown) + 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 e63f10ba80c..cc39895aabb 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -32,6 +32,25 @@ process.on("uncaughtException", (e) => { }) }) +// Graceful shutdown on terminal closure or termination +const workerExit = () => rpc.shutdown().finally(() => process.exit(0)) +process.on("SIGHUP", workerExit) +process.on("SIGTERM", workerExit) + +// Detect parent death as safety net for uncatchable signals (e.g. SIGKILL) +const ppid = process.ppid +if (ppid > 1) { + const monitor = setInterval(() => { + try { + process.kill(ppid, 0) + } catch { + clearInterval(monitor) + workerExit() + } + }, 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 6dc5e99e91e..524a2b280b7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -39,6 +39,12 @@ process.on("uncaughtException", (e) => { }) }) +// Safety net: force exit on terminal closure or termination signals. +// Command-specific handlers (tui, attach, serve) may exit earlier with graceful cleanup. +const forceExit = () => setTimeout(() => process.exit(0), 3000).unref() +process.on("SIGHUP", forceExit) +process.on("SIGTERM", forceExit) + const 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..35d3681f8fc --- /dev/null +++ b/packages/opencode/test/cli/signal/signal-handling.test.ts @@ -0,0 +1,47 @@ +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("serve exits gracefully 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") + const code = await proc.exited + + // Our handler calls process.exit(0). Without it, SIGHUP default gives 129. + expect(code).toBe(0) + }) + + test("serve exits gracefully 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") + const code = await proc.exited + + expect(code).toBe(0) + }) +})