Skip to content

Commit e4ff1ea

Browse files
authored
refactor(bash): use Effect ChildProcess for bash tool execution (#20496)
1 parent 26fb6b8 commit e4ff1ea

3 files changed

Lines changed: 196 additions & 66 deletions

File tree

packages/opencode/src/effect/cross-spawn-spawner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,14 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
488488
)
489489

490490
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
491+
492+
import { lazy } from "@/util/lazy"
493+
494+
const rt = lazy(() => {
495+
// Dynamic import to avoid circular dep: cross-spawn-spawner → run-service → Instance → project → cross-spawn-spawner
496+
const { makeRuntime } = require("@/effect/run-service") as typeof import("@/effect/run-service")
497+
return makeRuntime(ChildProcessSpawner, defaultLayer)
498+
})
499+
500+
export const runPromiseExit: ReturnType<typeof rt>["runPromiseExit"] = (...args) => rt().runPromiseExit(...(args as [any]))
501+
export const runPromise: ReturnType<typeof rt>["runPromise"] = (...args) => rt().runPromise(...(args as [any]))

packages/opencode/src/tool/bash.ts

Lines changed: 70 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import z from "zod"
22
import os from "os"
3-
import { spawn } from "child_process"
43
import { Tool } from "./tool"
54
import path from "path"
65
import DESCRIPTION from "./bash.txt"
@@ -18,6 +17,9 @@ import { Shell } from "@/shell/shell"
1817
import { BashArity } from "@/permission/arity"
1918
import { Truncate } from "./truncate"
2019
import { Plugin } from "@/plugin"
20+
import { Cause, Effect, Exit, Stream } from "effect"
21+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
22+
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
2123

2224
const MAX_METADATA_LENGTH = 30_000
2325
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -293,27 +295,26 @@ async function shellEnv(ctx: Tool.Context, cwd: string) {
293295
}
294296
}
295297

296-
function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
298+
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
297299
if (process.platform === "win32" && PS.has(name)) {
298-
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
300+
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
299301
cwd,
300302
env,
301-
stdio: ["ignore", "pipe", "pipe"],
303+
stdin: "ignore",
302304
detached: false,
303-
windowsHide: true,
304305
})
305306
}
306307

307-
return spawn(command, {
308+
return ChildProcess.make(command, [], {
308309
shell,
309310
cwd,
310311
env,
311-
stdio: ["ignore", "pipe", "pipe"],
312+
stdin: "ignore",
312313
detached: process.platform !== "win32",
313-
windowsHide: process.platform === "win32",
314314
})
315315
}
316316

317+
317318
async function run(
318319
input: {
319320
shell: string
@@ -326,8 +327,9 @@ async function run(
326327
},
327328
ctx: Tool.Context,
328329
) {
329-
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
330330
let output = ""
331+
let expired = false
332+
let aborted = false
331333

332334
ctx.metadata({
333335
metadata: {
@@ -336,76 +338,78 @@ async function run(
336338
},
337339
})
338340

339-
const append = (chunk: Buffer) => {
340-
output += chunk.toString()
341-
ctx.metadata({
342-
metadata: {
343-
output: preview(output),
344-
description: input.description,
345-
},
346-
})
347-
}
348-
349-
proc.stdout?.on("data", append)
350-
proc.stderr?.on("data", append)
351-
352-
let expired = false
353-
let aborted = false
354-
let exited = false
355-
356-
const kill = () => Shell.killTree(proc, { exited: () => exited })
357-
358-
if (ctx.abort.aborted) {
359-
aborted = true
360-
await kill()
361-
}
341+
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
342+
Effect.gen(function* () {
343+
const handle = yield* spawner.spawn(
344+
cmd(input.shell, input.name, input.command, input.cwd, input.env),
345+
)
362346

363-
const abort = () => {
364-
aborted = true
365-
void kill()
366-
}
347+
yield* Effect.forkScoped(
348+
Stream.runForEach(
349+
Stream.decodeText(handle.all),
350+
(chunk) =>
351+
Effect.sync(() => {
352+
output += chunk
353+
ctx.metadata({
354+
metadata: {
355+
output: preview(output),
356+
description: input.description,
357+
},
358+
})
359+
}),
360+
),
361+
)
367362

368-
ctx.abort.addEventListener("abort", abort, { once: true })
369-
const timer = setTimeout(() => {
370-
expired = true
371-
void kill()
372-
}, input.timeout + 100)
363+
const abort = Effect.callback<void>((resume) => {
364+
if (ctx.abort.aborted) return resume(Effect.void)
365+
const handler = () => resume(Effect.void)
366+
ctx.abort.addEventListener("abort", handler, { once: true })
367+
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
368+
})
373369

374-
await new Promise<void>((resolve, reject) => {
375-
const cleanup = () => {
376-
clearTimeout(timer)
377-
ctx.abort.removeEventListener("abort", abort)
378-
}
370+
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
379371

380-
proc.once("exit", () => {
381-
exited = true
382-
})
372+
const exit = yield* Effect.raceAll([
373+
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
374+
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
375+
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
376+
])
383377

384-
proc.once("close", () => {
385-
exited = true
386-
cleanup()
387-
resolve()
388-
})
378+
if (exit.kind === "abort") {
379+
aborted = true
380+
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
381+
}
382+
if (exit.kind === "timeout") {
383+
expired = true
384+
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
385+
}
389386

390-
proc.once("error", (error) => {
391-
exited = true
392-
cleanup()
393-
reject(error)
394-
})
395-
})
387+
return exit.kind === "exit" ? exit.code : null
388+
}).pipe(
389+
Effect.scoped,
390+
Effect.orDie,
391+
),
392+
)
393+
394+
let code: number | null = null
395+
if (Exit.isSuccess(exit)) {
396+
code = exit.value
397+
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
398+
throw Cause.squash(exit.cause)
399+
}
396400

397-
const metadata: string[] = []
398-
if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
399-
if (aborted) metadata.push("User aborted the command")
400-
if (metadata.length > 0) {
401-
output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
401+
const meta: string[] = []
402+
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
403+
if (aborted) meta.push("User aborted the command")
404+
if (meta.length > 0) {
405+
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
402406
}
403407

404408
return {
405409
title: input.description,
406410
metadata: {
407411
output: preview(output),
408-
exit: proc.exitCode,
412+
exit: code,
409413
description: input.description,
410414
},
411415
output,

packages/opencode/test/tool/bash.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,121 @@ describe("tool.bash permissions", () => {
896896
})
897897
})
898898

899+
describe("tool.bash abort", () => {
900+
test("preserves output when aborted", async () => {
901+
await Instance.provide({
902+
directory: projectRoot,
903+
fn: async () => {
904+
const bash = await BashTool.init()
905+
const controller = new AbortController()
906+
const collected: string[] = []
907+
const result = bash.execute(
908+
{
909+
command: `echo before && sleep 30`,
910+
description: "Long running command",
911+
},
912+
{
913+
...ctx,
914+
abort: controller.signal,
915+
metadata: (input) => {
916+
const output = (input.metadata as { output?: string })?.output
917+
if (output && output.includes("before") && !controller.signal.aborted) {
918+
collected.push(output)
919+
controller.abort()
920+
}
921+
},
922+
},
923+
)
924+
const res = await result
925+
expect(res.output).toContain("before")
926+
expect(res.output).toContain("User aborted the command")
927+
expect(collected.length).toBeGreaterThan(0)
928+
},
929+
})
930+
}, 15_000)
931+
932+
test("terminates command on timeout", async () => {
933+
await Instance.provide({
934+
directory: projectRoot,
935+
fn: async () => {
936+
const bash = await BashTool.init()
937+
const result = await bash.execute(
938+
{
939+
command: `echo started && sleep 60`,
940+
description: "Timeout test",
941+
timeout: 500,
942+
},
943+
ctx,
944+
)
945+
expect(result.output).toContain("started")
946+
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
947+
},
948+
})
949+
}, 15_000)
950+
951+
test.skipIf(process.platform === "win32")("captures stderr in output", async () => {
952+
await Instance.provide({
953+
directory: projectRoot,
954+
fn: async () => {
955+
const bash = await BashTool.init()
956+
const result = await bash.execute(
957+
{
958+
command: `echo stdout_msg && echo stderr_msg >&2`,
959+
description: "Stderr test",
960+
},
961+
ctx,
962+
)
963+
expect(result.output).toContain("stdout_msg")
964+
expect(result.output).toContain("stderr_msg")
965+
expect(result.metadata.exit).toBe(0)
966+
},
967+
})
968+
})
969+
970+
test("returns non-zero exit code", async () => {
971+
await Instance.provide({
972+
directory: projectRoot,
973+
fn: async () => {
974+
const bash = await BashTool.init()
975+
const result = await bash.execute(
976+
{
977+
command: `exit 42`,
978+
description: "Non-zero exit",
979+
},
980+
ctx,
981+
)
982+
expect(result.metadata.exit).toBe(42)
983+
},
984+
})
985+
})
986+
987+
test("streams metadata updates progressively", async () => {
988+
await Instance.provide({
989+
directory: projectRoot,
990+
fn: async () => {
991+
const bash = await BashTool.init()
992+
const updates: string[] = []
993+
const result = await bash.execute(
994+
{
995+
command: `echo first && sleep 0.1 && echo second`,
996+
description: "Streaming test",
997+
},
998+
{
999+
...ctx,
1000+
metadata: (input) => {
1001+
const output = (input.metadata as { output?: string })?.output
1002+
if (output) updates.push(output)
1003+
},
1004+
},
1005+
)
1006+
expect(result.output).toContain("first")
1007+
expect(result.output).toContain("second")
1008+
expect(updates.length).toBeGreaterThan(1)
1009+
},
1010+
})
1011+
})
1012+
})
1013+
8991014
describe("tool.bash truncation", () => {
9001015
test("truncates output exceeding line limit", async () => {
9011016
await Instance.provide({

0 commit comments

Comments
 (0)