Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/test/cli/signal/signal-handling.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading