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
6 changes: 6 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,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.")
}
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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()
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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
Expand Down
20 changes: 20 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,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)
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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)
})
}

const cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
Expand Down
46 changes: 46 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,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("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)
})
})