Skip to content

Commit 9da397e

Browse files
committed
fix(opencode): robust process exit detection for child processes
1 parent 7806936 commit 9da397e

6 files changed

Lines changed: 671 additions & 20 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
4545
import { iife } from "@/util/iife"
4646
import { Shell } from "@/shell/shell"
4747
import { Truncate } from "@/tool/truncation"
48+
import { stale, reap } from "@/tool/bash"
4849

4950
// @ts-ignore
5051
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -284,6 +285,13 @@ export namespace SessionPrompt {
284285

285286
using _ = defer(() => cancel(sessionID))
286287

288+
const watchdog = setInterval(() => {
289+
for (const id of stale()) {
290+
reap(id)
291+
}
292+
}, 5000)
293+
using _watchdog = defer(() => clearInterval(watchdog))
294+
287295
// Structured output state
288296
// Note: On session resumption, state is reset but outputFormat is preserved
289297
// on the user message and will be retrieved from lastUser below

packages/opencode/src/shell/shell.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import { spawn, type ChildProcess } from "child_process"
77
const SIGKILL_TIMEOUT_MS = 200
88

99
export namespace Shell {
10+
function alive(pid: number): boolean {
11+
try {
12+
process.kill(pid, 0)
13+
return true
14+
} catch {
15+
return false
16+
}
17+
}
18+
1019
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
1120
const pid = proc.pid
1221
if (!pid || opts?.exited?.()) return
@@ -22,17 +31,24 @@ export namespace Shell {
2231

2332
try {
2433
process.kill(-pid, "SIGTERM")
25-
await Bun.sleep(SIGKILL_TIMEOUT_MS)
26-
if (!opts?.exited?.()) {
27-
process.kill(-pid, "SIGKILL")
28-
}
29-
} catch (_e) {
30-
proc.kill("SIGTERM")
31-
await Bun.sleep(SIGKILL_TIMEOUT_MS)
32-
if (!opts?.exited?.()) {
34+
} catch {
35+
try {
36+
proc.kill("SIGTERM")
37+
} catch {}
38+
}
39+
40+
await Bun.sleep(SIGKILL_TIMEOUT_MS)
41+
42+
if (opts?.exited?.() || !alive(pid)) return
43+
try {
44+
process.kill(-pid, "SIGKILL")
45+
} catch {
46+
try {
3347
proc.kill("SIGKILL")
34-
}
48+
} catch {}
3549
}
50+
51+
await Bun.sleep(SIGKILL_TIMEOUT_MS)
3652
}
3753
const BLACKLIST = new Set(["fish", "nu"])
3854

packages/opencode/src/tool/bash.ts

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,40 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2
2323

2424
export const log = Log.create({ service: "bash-tool" })
2525

26+
// Registry for active bash processes — enables server-level watchdog
27+
const active = new Map<
28+
string,
29+
{
30+
pid: number
31+
timeout: number
32+
started: number
33+
kill: () => void
34+
done: () => void
35+
}
36+
>()
37+
38+
export function stale() {
39+
const result: string[] = []
40+
const now = Date.now()
41+
for (const [id, entry] of active) {
42+
if (now - entry.started > entry.timeout + 5000) result.push(id)
43+
}
44+
return result
45+
}
46+
47+
export function reap(id: string) {
48+
const entry = active.get(id)
49+
if (!entry) return
50+
log.info("reaping stuck process", {
51+
callID: id,
52+
pid: entry.pid,
53+
age: Date.now() - entry.started,
54+
})
55+
entry.kill()
56+
entry.done()
57+
active.delete(id)
58+
}
59+
2660
const resolveWasm = (asset: string) => {
2761
if (asset.startsWith("file://")) return fileURLToPath(asset)
2862
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
@@ -180,6 +214,21 @@ export const BashTool = Tool.define("bash", async () => {
180214
detached: process.platform !== "win32",
181215
})
182216

217+
if (!proc.pid) {
218+
if (proc.exitCode !== null) {
219+
log.info("process exited before pid could be read", { exitCode: proc.exitCode })
220+
} else {
221+
throw new Error(`Failed to spawn process: pid is undefined for command "${params.command}"`)
222+
}
223+
}
224+
225+
log.info("spawned process", {
226+
pid: proc.pid,
227+
command: params.command.slice(0, 100),
228+
cwd,
229+
timeout,
230+
})
231+
183232
let output = ""
184233

185234
// Initialize metadata with empty output
@@ -216,34 +265,143 @@ export const BashTool = Tool.define("bash", async () => {
216265
}
217266

218267
const abortHandler = () => {
268+
log.info("process abort triggered", { pid: proc.pid })
219269
aborted = true
220270
void kill()
221271
}
222272

223273
ctx.abort.addEventListener("abort", abortHandler, { once: true })
224274

225275
const timeoutTimer = setTimeout(() => {
276+
log.info("process timeout triggered", { pid: proc.pid, timeout })
226277
timedOut = true
227278
void kill()
228279
}, timeout + 100)
229280

281+
const started = Date.now()
282+
283+
const callID = ctx.callID
284+
if (callID) {
285+
active.set(callID, {
286+
pid: proc.pid!,
287+
timeout,
288+
started,
289+
kill: () => Shell.killTree(proc, { exited: () => exited }),
290+
done: () => {},
291+
})
292+
}
293+
230294
await new Promise<void>((resolve, reject) => {
295+
let resolved = false
296+
231297
const cleanup = () => {
298+
if (resolved) return
299+
resolved = true
232300
clearTimeout(timeoutTimer)
301+
clearInterval(poll)
233302
ctx.abort.removeEventListener("abort", abortHandler)
303+
proc.stdout?.removeListener("end", check)
304+
proc.stderr?.removeListener("end", check)
234305
}
235306

236-
proc.once("exit", () => {
307+
const done = () => {
308+
if (resolved) return
237309
exited = true
238310
cleanup()
239311
resolve()
240-
})
312+
}
313+
314+
// Update the active entry with the real done callback
315+
if (callID) {
316+
const entry = active.get(callID)
317+
if (entry) {
318+
entry.done = () => {
319+
if (resolved) return
320+
exited = true
321+
cleanup()
322+
resolve()
323+
}
324+
}
325+
}
241326

242-
proc.once("error", (error) => {
327+
const fail = (error: Error) => {
328+
if (resolved) return
243329
exited = true
244330
cleanup()
245331
reject(error)
332+
}
333+
334+
proc.once("exit", () => {
335+
log.info("process exit detected via 'exit' event", { pid: proc.pid, exitCode: proc.exitCode })
336+
done()
337+
})
338+
proc.once("close", () => {
339+
log.info("process exit detected via 'close' event", { pid: proc.pid, exitCode: proc.exitCode })
340+
done()
246341
})
342+
proc.once("error", fail)
343+
344+
// Redundancy: stdio end events fire when pipe file descriptors close
345+
// independent of process exit monitoring — catches missed exit events
346+
let streams = 0
347+
const total = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0)
348+
const check = () => {
349+
streams++
350+
if (streams < total) return
351+
if (proc.exitCode !== null || proc.signalCode !== null) {
352+
log.info("stdio end detected exit (exitCode already set)", {
353+
pid: proc.pid,
354+
exitCode: proc.exitCode,
355+
})
356+
done()
357+
return
358+
}
359+
setTimeout(() => {
360+
log.info("stdio end deferred check", {
361+
pid: proc.pid,
362+
exitCode: proc.exitCode,
363+
})
364+
done()
365+
}, 50)
366+
}
367+
proc.stdout?.once("end", check)
368+
proc.stderr?.once("end", check)
369+
370+
// Polling watchdog: detect process exit when Bun's event loop
371+
// fails to deliver the "exit" event (confirmed Bun bug in containers)
372+
const poll = setInterval(() => {
373+
if (proc.exitCode !== null || proc.signalCode !== null) {
374+
log.info("polling watchdog detected exit via exitCode/signalCode", {
375+
exitCode: proc.exitCode,
376+
signalCode: proc.signalCode,
377+
})
378+
done()
379+
return
380+
}
381+
382+
// Check 2: process.kill(pid, 0) throws ESRCH if process is dead
383+
if (proc.pid && process.platform !== "win32") {
384+
try {
385+
process.kill(proc.pid, 0)
386+
} catch {
387+
log.info("polling watchdog detected exit via kill(0) ESRCH", {
388+
pid: proc.pid,
389+
})
390+
done()
391+
return
392+
}
393+
}
394+
}, 1000)
395+
})
396+
397+
if (callID) active.delete(callID)
398+
399+
log.info("process completed", {
400+
pid: proc.pid,
401+
exitCode: proc.exitCode,
402+
duration: Date.now() - started,
403+
timedOut,
404+
aborted,
247405
})
248406

249407
const resultMetadata: string[] = []

packages/opencode/src/util/process.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,52 @@ export namespace Process {
7474
}
7575

7676
const exited = new Promise<number>((resolve, reject) => {
77-
const done = () => {
77+
let resolved = false
78+
79+
const cleanup = () => {
80+
if (resolved) return
81+
resolved = true
7882
opts.abort?.removeEventListener("abort", abort)
7983
if (timer) clearTimeout(timer)
84+
clearInterval(poll)
85+
}
86+
87+
const finish = (code: number) => {
88+
if (resolved) return
89+
cleanup()
90+
resolve(code)
91+
}
92+
93+
const fail = (error: Error) => {
94+
if (resolved) return
95+
cleanup()
96+
reject(error)
8097
}
8198

8299
proc.once("exit", (code, signal) => {
83-
done()
84-
resolve(code ?? (signal ? 1 : 0))
100+
finish(code ?? (signal ? 1 : 0))
85101
})
86102

87-
proc.once("error", (error) => {
88-
done()
89-
reject(error)
103+
proc.once("close", (code, signal) => {
104+
finish(code ?? (signal ? 1 : 0))
90105
})
106+
107+
proc.once("error", fail)
108+
const poll = setInterval(() => {
109+
if (proc.exitCode !== null || proc.signalCode !== null) {
110+
finish(proc.exitCode ?? (proc.signalCode ? 1 : 0))
111+
return
112+
}
113+
114+
if (proc.pid && process.platform !== "win32") {
115+
try {
116+
process.kill(proc.pid, 0)
117+
} catch {
118+
finish(proc.exitCode ?? 1)
119+
return
120+
}
121+
}
122+
}, 1000)
91123
})
92124

93125
if (opts.abort) {

0 commit comments

Comments
 (0)