import { describe, expect } from "bun:test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { Deferred, Effect, Exit, PlatformError, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { testEffect } from "../lib/effect" const live = LayerNode.compile(CrossSpawnSpawner.node) const fx = testEffect(live) function js(code: string, opts?: ChildProcess.CommandOptions) { return ChildProcess.make("node", ["-e", code], opts) } function decodeByteStream(stream: Stream.Stream) { return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim())) } function alive(pid: number) { try { process.kill(pid, 0) return true } catch { return false } } async function tmpdir() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-test-")) return { path: dir, async [Symbol.asyncDispose]() { await fs.rm(dir, { recursive: true, force: true }) }, } } async function gone(pid: number, timeout = 5_000) { const end = Date.now() + timeout while (Date.now() < end) { if (!alive(pid)) return true await new Promise((resolve) => setTimeout(resolve, 50)) } return !alive(pid) } describe("cross-spawn spawner", () => { describe("basic spawning", () => { fx.effect( "captures stdout", Effect.gen(function* () { const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), ) expect(out).toBe("ok") }), ) fx.effect( "captures multiple lines", Effect.gen(function* () { const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') const out = yield* decodeByteStream(handle.stdout) expect(out).toBe("line1\nline2\nline3") }), ) fx.effect( "returns exit code", Effect.gen(function* () { const handle = yield* js("process.exit(0)") const code = yield* handle.exitCode expect(code).toBe(ChildProcessSpawner.ExitCode(0)) }), ) fx.effect( "returns non-zero exit code", Effect.gen(function* () { const handle = yield* js("process.exit(42)") const code = yield* handle.exitCode expect(code).toBe(ChildProcessSpawner.ExitCode(42)) }), ) }) describe("cwd option", () => { fx.effect( "uses cwd when spawning commands", Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => svc.string( ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), ), ) expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path))) }), ) fx.effect( "fails for invalid cwd", Effect.gen(function* () { const exit = yield* Effect.exit( ChildProcessSpawner.ChildProcessSpawner.use((svc) => svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })), ), ) expect(Exit.isFailure(exit)).toBe(true) }), ) }) describe("env option", () => { fx.effect( "passes environment variables with extendEnv", Effect.gen(function* () { const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { env: { TEST_VAR: "test_value" }, extendEnv: true, }) const out = yield* decodeByteStream(handle.stdout) expect(out).toBe("test_value") }), ) fx.effect( "passes multiple environment variables", Effect.gen(function* () { const handle = yield* js( "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", { env: { VAR1: "one", VAR2: "two", VAR3: "three" }, extendEnv: true, }, ) const out = yield* decodeByteStream(handle.stdout) expect(out).toBe("one-two-three") }), ) }) describe("stderr", () => { fx.effect( "captures stderr output", Effect.gen(function* () { const handle = yield* js('process.stderr.write("error message")') const err = yield* decodeByteStream(handle.stderr) expect(err).toBe("error message") }), ) fx.effect( "captures both stdout and stderr", Effect.gen(function* () { const handle = yield* js( [ "let pending = 2", "const done = () => {", " pending -= 1", " if (pending === 0) setTimeout(() => process.exit(0), 0)", "}", 'process.stdout.write("stdout\\n", done)', 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { concurrency: 2, }) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), ) }) describe("combined output (all)", () => { for (const output of ["stdout", "stderr", "all"] as const) { fx.live( `captures ${output} when reading starts after process exit`, Effect.gen(function* () { const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")') expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0)) // Let exit callbacks finish before attaching a reader; the handle scope remains open. yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))) expect((yield* decodeByteStream(handle[output])).split("\n").toSorted()).toEqual( output === "all" ? ["stderr", "stdout"] : [output], ) }).pipe(Effect.timeout("3 seconds")), ) } fx.live( "drains output larger than the capture buffers", Effect.gen(function* () { const text = "x".repeat(1024 * 1024) const handle = yield* js( `const text = "x".repeat(${text.length}); process.stdout.write(text); process.stderr.write(text)`, ) const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { concurrency: 2, }) expect(stdout).toBe(text) expect(stderr).toBe(text) expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0)) }).pipe(Effect.timeout("3 seconds")), ) fx.effect( "captures stdout via .all when no stderr", Effect.gen(function* () { const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) const all = yield* decodeByteStream(handle.all) expect(all).toBe("hello from stdout") }), ) fx.effect( "captures stderr via .all when no stdout", Effect.gen(function* () { const handle = yield* js('process.stderr.write("hello from stderr")') const all = yield* decodeByteStream(handle.all) expect(all).toBe("hello from stderr") }), ) }) describe("stdin", () => { fx.effect( "allows providing standard input to a command", Effect.gen(function* () { const input = "a b c" const stdin = Stream.make(Buffer.from(input, "utf-8")) const handle = yield* js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', { stdin }, ) const out = yield* decodeByteStream(handle.stdout) yield* handle.exitCode expect(out).toBe("a b c") }), ) }) describe("process control", () => { fx.live( "reports exit without waiting for unread stdout", Effect.gen(function* () { const handle = yield* js("process.stdout.write(Buffer.alloc(1024 * 1024)); process.exit(0)") expect(yield* Effect.promise(() => gone(Number(handle.pid)))).toBe(true) expect(yield* handle.exitCode.pipe(Effect.timeout("500 millis"))).toBe(ChildProcessSpawner.ExitCode(0)) expect(yield* handle.isRunning).toBe(false) }), ) fx.live( "releases a process with unread buffered stdout", Effect.gen(function* () { const pid = yield* Effect.scoped( Effect.gen(function* () { const handle = yield* js( 'process.stdout.write("x".repeat(1024 * 1024)); process.stderr.write("ready"); setInterval(() => {}, 10_000)', { forceKillAfter: 100 }, ) expect(yield* decodeByteStream(handle.stderr.pipe(Stream.take(1)))).toBe("ready") return Number(handle.pid) }), ) expect(yield* Effect.promise(() => gone(pid))).toBe(true) }).pipe(Effect.timeout("3 seconds")), ) // Node puts non-detached Windows children in a kill-on-parent-exit job; this guards POSIX group cleanup. const groupTest = process.platform === "win32" ? fx.live.skip : fx.live groupTest( "preserves successful descendants when an exit-only scope closes", Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const pidFile = path.join(tmp.path, "child.pid") yield* Effect.addFinalizer(() => Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe( Effect.ignore, ), ) yield* Effect.scoped( Effect.gen(function* () { // This fixture's child shares the process group and holds stdio after the parent exits on stdin EOF. const handle = yield* ChildProcess.make( "node", [path.join(import.meta.dir, "../fixture/held-stdio.cjs"), "mcp", pidFile], { stdin: "ignore", forceKillAfter: 100 }, ) expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0)) }), ) expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true) }).pipe(Effect.timeout("3 seconds")), ) for (const mode of ["exit", "SIGKILL"] as const) { const test = mode === "SIGKILL" && process.platform === "win32" ? fx.live.skip : fx.live test( `finishes capture after ${mode} while a grandchild holds stdio`, Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const pidFile = path.join(tmp.path, "child.pid") yield* Effect.addFinalizer(() => Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe( Effect.ignore, ), ) const ready = yield* Deferred.make() // The grandchild keeps both pipes open independently of the foreground process. const handle = yield* ChildProcess.make( "node", [path.join(import.meta.dir, "../fixture/held-stdio.cjs"), mode, pidFile], { stdin: "ignore", forceKillAfter: 100 }, ) const [exit, stdout, stderr] = yield* Effect.all( [ Effect.exit(handle.exitCode), decodeByteStream(handle.stdout), decodeByteStream(handle.stderr.pipe(Stream.tap(() => Deferred.succeed(ready, undefined)))), mode === "SIGKILL" ? Deferred.await(ready).pipe(Effect.andThen(handle.kill({ killSignal: "SIGKILL" }))) : Effect.void, ], { concurrency: "unbounded" }, ).pipe(Effect.timeout("3 seconds")) // Completion must retain foreground output without waiting for the inherited pipes to close. expect(stdout).toBe("foreground-out") expect(stderr).toBe("foreground-err") expect(Exit.isSuccess(exit)).toBe(mode === "exit") if (Exit.isSuccess(exit)) expect(exit.value).toBe(ChildProcessSpawner.ExitCode(0)) expect(yield* handle.isRunning).toBe(false) expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true) }), 10_000, ) } fx.effect( "kills a running process", Effect.gen(function* () { const exit = yield* Effect.exit( Effect.gen(function* () { const handle = yield* js("setTimeout(() => {}, 10_000)") yield* handle.kill() return yield* handle.exitCode }), ) expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) }), ) fx.effect( "uses the configured kill signal when scope exits", Effect.gen(function* () { const pid = yield* Effect.scoped( Effect.gen(function* () { const handle = yield* js("setInterval(() => {}, 10_000)", { killSignal: "SIGKILL" }) return Number(handle.pid) }), ) const done = yield* Effect.promise(() => gone(pid)) expect(done).toBe(true) }), ) fx.live( "forceKillAfter escalates for stubborn processes", Effect.gen(function* () { if (process.platform === "win32") return const exit = yield* Effect.exit( Effect.gen(function* () { const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') yield* handle.kill({ forceKillAfter: 100 }) return yield* handle.exitCode }), ) expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) }), ) fx.effect( "isRunning reflects process state", Effect.gen(function* () { const handle = yield* js('process.stdout.write("done")') yield* handle.exitCode const running = yield* handle.isRunning expect(running).toBe(false) }), ) }) describe("error handling", () => { fx.effect( "fails for invalid command", Effect.gen(function* () { const exit = yield* Effect.exit( Effect.gen(function* () { const handle = yield* ChildProcess.make("nonexistent-command-12345") return yield* handle.exitCode }), ) expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) }), ) }) describe("pipeline", () => { fx.effect( "pipes stdout of one command to stdin of another", Effect.gen(function* () { const handle = yield* js('process.stdout.write("hello world")').pipe( ChildProcess.pipeTo( js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', ), ), ) const out = yield* decodeByteStream(handle.stdout) yield* handle.exitCode expect(out).toBe("HELLO WORLD") }), ) fx.effect( "three-stage pipeline", Effect.gen(function* () { const handle = yield* js('process.stdout.write("hello world")').pipe( ChildProcess.pipeTo( js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', ), ), ChildProcess.pipeTo( js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', ), ), ) const out = yield* decodeByteStream(handle.stdout) yield* handle.exitCode expect(out).toBe("HELLO-WORLD") }), ) fx.effect( "pipes stderr with { from: 'stderr' }", Effect.gen(function* () { const handle = yield* js('process.stderr.write("error")').pipe( ChildProcess.pipeTo( js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', ), { from: "stderr" }, ), ) const out = yield* decodeByteStream(handle.stdout) yield* handle.exitCode expect(out).toBe("error") }), ) fx.effect( "pipes combined output with { from: 'all' }", Effect.gen(function* () { const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( ChildProcess.pipeTo( js( 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', ), { from: "all" }, ), ) const out = yield* decodeByteStream(handle.stdout) yield* handle.exitCode expect(out).toContain("stdout") expect(out).toContain("stderr") }), ) }) describe("Windows-specific", () => { fx.effect( "uses shell routing on Windows", Effect.gen(function* () { if (process.platform !== "win32") return const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => svc.string( ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { shell: true, extendEnv: true, env: { OPENCODE_TEST_SHELL: "ok" }, }), ), ) expect(out).toContain("OPENCODE_TEST_SHELL=ok") }), ) fx.effect( "runs cmd scripts with spaces on Windows without shell", Effect.gen(function* () { if (process.platform !== "win32") return const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const dir = path.join(tmp.path, "with space") const file = path.join(dir, "echo cmd.cmd") yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) yield* Effect.promise(() => fs.writeFile(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => svc.exitCode( ChildProcess.make(file, ["--stdio"], { stdin: "pipe", stdout: "pipe", stderr: "pipe", }), ), ) expect(code).toBe(ChildProcessSpawner.ExitCode(0)) }), ) }) })