diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d4e8ce85..8263e930 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -14,6 +14,7 @@ import { Agent } from "../agent/agent" const MAX_OUTPUT_LENGTH = 30_000 const DEFAULT_TIMEOUT = 1 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000 +const SIGKILL_TIMEOUT_MS = 200 const log = Log.create({ service: "bash-tool" }) @@ -145,12 +146,16 @@ export const BashTool = Tool.define("bash", { }) } - const process = spawn(params.command, { + const pause = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + + const proc = spawn(params.command, { shell: true, cwd: Instance.directory, - signal: ctx.abort, stdio: ["ignore", "pipe", "pipe"], - timeout, + detached: process.platform !== "win32", }) let output = "" @@ -163,38 +168,87 @@ export const BashTool = Tool.define("bash", { }, }) - process.stdout?.on("data", (chunk) => { + const append = (chunk: Buffer) => { output += chunk.toString() ctx.metadata({ metadata: { - output: output, + output, description: params.description, }, }) - }) + } - process.stderr?.on("data", (chunk) => { - output += chunk.toString() - ctx.metadata({ - metadata: { - output: output, - description: params.description, - }, - }) - }) + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) - await new Promise((resolve) => { - process.on("close", () => { + let timedOut = false + let aborted = false + let exited = false + + const killTree = async () => { + const pid = proc.pid + if (!pid || exited) { + return + } + + if (process.platform === "win32") { + await new Promise((resolve) => { + const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" }) + killer.once("exit", resolve) + killer.once("error", resolve) + }) + return + } + + try { + process.kill(-pid, "SIGTERM") + await pause(SIGKILL_TIMEOUT_MS) + if (!exited) { + process.kill(-pid, "SIGKILL") + } + } catch (_e) { + proc.kill("SIGTERM") + await pause(SIGKILL_TIMEOUT_MS) + if (!exited) { + proc.kill("SIGKILL") + } + } + } + + if (ctx.abort.aborted) { + aborted = true + await killTree() + } + + const abortHandler = () => { + aborted = true + void killTree() + } + + ctx.abort.addEventListener("abort", abortHandler, { once: true }) + + const timeoutTimer = setTimeout(() => { + timedOut = true + void killTree() + }, timeout) + + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeoutTimer) + ctx.abort.removeEventListener("abort", abortHandler) + } + + proc.once("exit", () => { + exited = true + cleanup() resolve() }) - }) - ctx.metadata({ - metadata: { - output: output, - exit: process.exitCode, - description: params.description, - }, + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) }) if (output.length > MAX_OUTPUT_LENGTH) { @@ -202,15 +256,19 @@ export const BashTool = Tool.define("bash", { output += "\n\n(Output was truncated due to length limit)" } - if (process.signalCode === "SIGTERM" && params.timeout) { + if (timedOut) { output += `\n\n(Command timed out after ${timeout} ms)` } + if (aborted) { + output += "\n\n(Command was aborted)" + } + return { title: params.command, metadata: { output, - exit: process.exitCode, + exit: proc.exitCode, description: params.description, }, output,