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
8 changes: 8 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { stale, reap } from "@/tool/bash"

// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -284,6 +285,13 @@ export namespace SessionPrompt {

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

const watchdog = setInterval(() => {
for (const id of stale()) {
reap(id)
}
}, 5000)
using _watchdog = defer(() => clearInterval(watchdog))

// Structured output state
// Note: On session resumption, state is reset but outputFormat is preserved
// on the user message and will be retrieved from lastUser below
Expand Down
34 changes: 25 additions & 9 deletions packages/opencode/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { spawn, type ChildProcess } from "child_process"
const SIGKILL_TIMEOUT_MS = 200

export namespace Shell {
function alive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}

export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
Expand All @@ -22,17 +31,24 @@ export namespace Shell {

try {
process.kill(-pid, "SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
} catch {
try {
proc.kill("SIGTERM")
} catch {}
}

await Bun.sleep(SIGKILL_TIMEOUT_MS)

if (opts?.exited?.() || !alive(pid)) return
try {
process.kill(-pid, "SIGKILL")
} catch {
try {
proc.kill("SIGKILL")
}
} catch {}
}

await Bun.sleep(SIGKILL_TIMEOUT_MS)
}
const BLACKLIST = new Set(["fish", "nu"])

Expand Down
164 changes: 161 additions & 3 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,40 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2

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

// Registry for active bash processes — enables server-level watchdog
const active = new Map<
string,
{
pid: number
timeout: number
started: number
kill: () => void
done: () => void
}
>()

export function stale() {
const result: string[] = []
const now = Date.now()
for (const [id, entry] of active) {
if (now - entry.started > entry.timeout + 5000) result.push(id)
}
return result
}

export function reap(id: string) {
const entry = active.get(id)
if (!entry) return
log.info("reaping stuck process", {
callID: id,
pid: entry.pid,
age: Date.now() - entry.started,
})
entry.kill()
entry.done()
active.delete(id)
}

const resolveWasm = (asset: string) => {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
Expand Down Expand Up @@ -180,6 +214,21 @@ export const BashTool = Tool.define("bash", async () => {
detached: process.platform !== "win32",
})

if (!proc.pid) {
if (proc.exitCode !== null) {
log.info("process exited before pid could be read", { exitCode: proc.exitCode })
} else {
throw new Error(`Failed to spawn process: pid is undefined for command "${params.command}"`)
}
}

log.info("spawned process", {
pid: proc.pid,
command: params.command.slice(0, 100),
cwd,
timeout,
})

let output = ""

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

const abortHandler = () => {
log.info("process abort triggered", { pid: proc.pid })
aborted = true
void kill()
}

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

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

const started = Date.now()

const callID = ctx.callID
if (callID) {
active.set(callID, {
pid: proc.pid!,
timeout,
started,
kill: () => Shell.killTree(proc, { exited: () => exited }),
done: () => {},
})
}

await new Promise<void>((resolve, reject) => {
let resolved = false

const cleanup = () => {
if (resolved) return
resolved = true
clearTimeout(timeoutTimer)
clearInterval(poll)
ctx.abort.removeEventListener("abort", abortHandler)
proc.stdout?.removeListener("end", check)
proc.stderr?.removeListener("end", check)
}

proc.once("exit", () => {
const done = () => {
if (resolved) return
exited = true
cleanup()
resolve()
})
}

// Update the active entry with the real done callback
if (callID) {
const entry = active.get(callID)
if (entry) {
entry.done = () => {
if (resolved) return
exited = true
cleanup()
resolve()
}
}
}

proc.once("error", (error) => {
const fail = (error: Error) => {
if (resolved) return
exited = true
cleanup()
reject(error)
}

proc.once("exit", () => {
log.info("process exit detected via 'exit' event", { pid: proc.pid, exitCode: proc.exitCode })
done()
})
proc.once("close", () => {
log.info("process exit detected via 'close' event", { pid: proc.pid, exitCode: proc.exitCode })
done()
})
proc.once("error", fail)

// Redundancy: stdio end events fire when pipe file descriptors close
// independent of process exit monitoring — catches missed exit events
let streams = 0
const total = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0)
const check = () => {
streams++
if (streams < total) return
if (proc.exitCode !== null || proc.signalCode !== null) {
log.info("stdio end detected exit (exitCode already set)", {
pid: proc.pid,
exitCode: proc.exitCode,
})
done()
return
}
setTimeout(() => {
log.info("stdio end deferred check", {
pid: proc.pid,
exitCode: proc.exitCode,
})
done()
}, 50)
}
proc.stdout?.once("end", check)
proc.stderr?.once("end", check)

// Polling watchdog: detect process exit when Bun's event loop
// fails to deliver the "exit" event (confirmed Bun bug in containers)
const poll = setInterval(() => {
if (proc.exitCode !== null || proc.signalCode !== null) {
log.info("polling watchdog detected exit via exitCode/signalCode", {
exitCode: proc.exitCode,
signalCode: proc.signalCode,
})
done()
return
}

// Check 2: process.kill(pid, 0) throws ESRCH if process is dead
if (proc.pid && process.platform !== "win32") {
try {
process.kill(proc.pid, 0)
} catch {
log.info("polling watchdog detected exit via kill(0) ESRCH", {
pid: proc.pid,
})
done()
return
}
}
}, 1000)
})

if (callID) active.delete(callID)

log.info("process completed", {
pid: proc.pid,
exitCode: proc.exitCode,
duration: Date.now() - started,
timedOut,
aborted,
})

const resultMetadata: string[] = []
Expand Down
44 changes: 38 additions & 6 deletions packages/opencode/src/util/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,52 @@ export namespace Process {
}

const exited = new Promise<number>((resolve, reject) => {
const done = () => {
let resolved = false

const cleanup = () => {
if (resolved) return
resolved = true
opts.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
clearInterval(poll)
}

const finish = (code: number) => {
if (resolved) return
cleanup()
resolve(code)
}

const fail = (error: Error) => {
if (resolved) return
cleanup()
reject(error)
}

proc.once("exit", (code, signal) => {
done()
resolve(code ?? (signal ? 1 : 0))
finish(code ?? (signal ? 1 : 0))
})

proc.once("error", (error) => {
done()
reject(error)
proc.once("close", (code, signal) => {
finish(code ?? (signal ? 1 : 0))
})

proc.once("error", fail)
const poll = setInterval(() => {
if (proc.exitCode !== null || proc.signalCode !== null) {
finish(proc.exitCode ?? (proc.signalCode ? 1 : 0))
return
}

if (proc.pid && process.platform !== "win32") {
try {
process.kill(proc.pid, 0)
} catch {
finish(proc.exitCode ?? 1)
return
}
}
}, 1000)
})

if (opts.abort) {
Expand Down
Loading
Loading