import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner" import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem" import * as NodePath from "@effect/platform-node-shared/NodePath" import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as FileSystem from "effect/FileSystem" import * as Layer from "effect/Layer" import * as Path from "effect/Path" import * as PlatformError from "effect/PlatformError" import * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import * as TestClock from "effect/testing/TestClock" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" const TEST_BASH_SCRIPTS_PATH = [__dirname, "fixtures", "bash"] const NodeServices = NodeChildProcessSpawner.layer.pipe( Layer.provideMerge(Layer.mergeAll( NodeFileSystem.layer, NodePath.layer )) ) // Helper to collect stream output into a string const decodeByteStream = Effect.fnUntraced( function*( stream: Stream.Stream, encoding: ChildProcess.Encoding = "utf-8" ) { const chunks = yield* Stream.runCollect(stream) const totalLength = chunks.reduce((acc, c) => acc + c.length, 0) const result = new Uint8Array(totalLength) let offset = 0 for (const chunk of chunks) { result.set(chunk, offset) offset += chunk.length } return new TextDecoder(encoding).decode(result).trim() } ) describe("NodeChildProcessSpawner", () => { it.layer(NodeServices)((it) => { describe("spawn", () => { describe("basic spawning", () => { it.effect("should spawn a simple command and collect output", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("node", ["--version"]) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // Verify it contains "v" (version string starts with v) assert.isTrue(output.includes("v")) }).pipe(Effect.scoped)) it.effect("should spawn echo command", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["hello", "world"]) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "hello world") }).pipe(Effect.scoped)) it.effect("should spawn with template literal", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo spawned` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "spawned") }).pipe(Effect.scoped)) }) describe("cwd option", () => { it.effect("should handle command with working directory", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("pwd", [], { cwd: "/tmp" }) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // On macOS, /tmp is a symlink to /private/tmp assert.isTrue(output.includes("tmp")) }).pipe(Effect.scoped)) it.effect("should use cwd with template literal form", () => Effect.gen(function*() { const handle = yield* ChildProcess.make({ cwd: "/tmp" })`pwd` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.isTrue(output.includes("tmp")) }).pipe(Effect.scoped)) }) describe("env option", () => { it.effect("should handle environment variables", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo $TEST_VAR"], { env: { TEST_VAR: "test_value" }, extendEnv: true }) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "test_value") }).pipe(Effect.scoped)) it.effect("should handle multiple environment variables", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo $VAR1-$VAR2-$VAR3"], { env: { VAR1: "one", VAR2: "two", VAR3: "three" }, extendEnv: true }) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "one-two-three") }).pipe(Effect.scoped)) it.effect("should merge environment variables with setEnv", () => Effect.gen(function*() { const command = ChildProcess.make("sh", ["-c", "echo $VAR1-$VAR2-$VAR3"], { env: { VAR1: "one", VAR2: "two" }, extendEnv: true }).pipe(ChildProcess.setEnv({ VAR2: "override", VAR3: "three" })) const handle = yield* command const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "one-override-three") }).pipe(Effect.scoped)) }) describe("shell option", () => { it.effect("should execute with shell when using sh -c", () => Effect.gen(function*() { // Use sh -c to test shell expansion without triggering deprecation warning const handle = yield* ChildProcess.make("sh", ["-c", "echo $HOME"]) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // With shell, $HOME should be expanded assert.isTrue(output.length > 0) assert.isFalse(output.includes("$HOME")) }).pipe(Effect.scoped)) it.effect("should not expand variables without shell", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["$HOME"], { shell: false }) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // Without shell, $HOME should not be expanded assert.strictEqual(output, "$HOME") }).pipe(Effect.scoped)) it.effect("should allow piping with shell", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo hello | tr a-z A-Z"]) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO") }).pipe(Effect.scoped)) }) describe("template literal forms", () => { it.effect("should work with template literal form", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "hello") }).pipe(Effect.scoped)) it.effect("should handle string interpolation", () => Effect.gen(function*() { const name = "world" const handle = yield* ChildProcess.make`echo hello ${name}` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "hello world") }).pipe(Effect.scoped)) it.effect("should handle number interpolation", () => Effect.gen(function*() { const count = 42 const handle = yield* ChildProcess.make`echo count is ${count}` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "count is 42") }).pipe(Effect.scoped)) it.effect("should handle array interpolation", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const dir = yield* fs.makeTempDirectoryScoped() const file = path.join(dir, "array-interpolation.txt") const args = ["-l", "-a"] yield* fs.writeFile(file, new TextEncoder().encode("test")) const handle = yield* ChildProcess.make`ls ${args} ${dir}` const exitCode = yield* handle.exitCode const output = yield* decodeByteStream(handle.stdout) assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.isTrue(output.includes("array-interpolation.txt")) }).pipe(Effect.scoped)) it.effect("should handle multiple interpolations", () => Effect.gen(function*() { const greeting = "hello" const target = "world" const handle = yield* ChildProcess.make`echo ${greeting} ${target}` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "hello world") }).pipe(Effect.scoped)) it.effect("should handle options with template literal", () => Effect.gen(function*() { const filename = "test.txt" const handle = yield* ChildProcess.make({ cwd: "/tmp" })`echo ${filename}` const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "test.txt") }).pipe(Effect.scoped)) }) describe("stderr streaming", () => { it.effect("should capture stderr output", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo error message >&2"]) const stderr = yield* decodeByteStream(handle.stderr) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stderr, "error message") }).pipe(Effect.scoped)) it.effect("should capture both stdout and stderr", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo stdout; echo stderr >&2"]) const stdout = yield* decodeByteStream(handle.stdout) const stderr = yield* decodeByteStream(handle.stderr) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, "stdout") assert.strictEqual(stderr, "stderr") }).pipe(Effect.scoped)) it.effect("should handle more stdout than stderr", () => Effect.gen(function*() { // Process outputs many lines to stdout but only one to stderr const handle = yield* ChildProcess.make( "sh", ["-c", "echo line1; echo line2; echo line3; echo line4; echo line5; echo error >&2"] ) const [stdout, stderr] = yield* Effect.all([ decodeByteStream(handle.stdout), decodeByteStream(handle.stderr) ], { concurrency: "unbounded" }) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, ["line1", "line2", "line3", "line4", "line5"].join("\n")) assert.strictEqual(stderr, "error") }).pipe(Effect.scoped)) it.effect("should handle more stderr than stdout", () => Effect.gen(function*() { // Process outputs many lines to stderr but only one to stdout const handle = yield* ChildProcess.make( "sh", ["-c", "echo output; echo err1 >&2; echo err2 >&2; echo err3 >&2; echo err4 >&2; echo err5 >&2"] ) const stdout = yield* decodeByteStream(handle.stdout) const stderr = yield* decodeByteStream(handle.stderr) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, "output") assert.strictEqual(stderr, ["err1", "err2", "err3", "err4", "err5"].join("\n")) }).pipe(Effect.scoped)) it.effect("should allow reading only stdout when stderr is empty", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["only stdout"]) // Read streams in parallel to avoid deadlock when one stream is empty const [stdout, stderr] = yield* Effect.all([ decodeByteStream(handle.stdout), decodeByteStream(handle.stderr) ], { concurrency: "unbounded" }) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, "only stdout") assert.strictEqual(stderr, "") }).pipe(Effect.scoped)) it.effect("should allow reading only stderr when stdout is empty", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo only stderr >&2"]) // Read streams in parallel to avoid deadlock when one stream is empty const [stdout, stderr] = yield* Effect.all([ decodeByteStream(handle.stdout), decodeByteStream(handle.stderr) ], { concurrency: "unbounded" }) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, "") assert.strictEqual(stderr, "only stderr") }).pipe(Effect.scoped)) }) describe("combined output (all)", () => { it.effect("should read interspersed stdout and stderr via .all", () => Effect.gen(function*() { // Use sleep to force buffer flushes between writes, ensuring // stdout and stderr chunks arrive separately for proper interleaving const handle = yield* ChildProcess.make( "sh", [ "-c", [ "echo stdout1; sleep 0.01;", "echo stderr1 >&2; sleep 0.01;", "echo stdout2; sleep 0.01;", "echo stderr2 >&2" ].join(" ") ] ) const all = yield* decodeByteStream(handle.all) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // With delays forcing buffer flushes, we should see proper interleaving assert.strictEqual(all, ["stdout1", "stderr1", "stdout2", "stderr2"].join("\n")) }).pipe(Effect.scoped)) it.effect("should capture only stdout via .all when no stderr", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) const all = yield* decodeByteStream(handle.all) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(all, "hello from stdout") }).pipe(Effect.scoped)) it.effect("should capture only stderr via .all when no stdout", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo hello from stderr >&2"]) const all = yield* decodeByteStream(handle.all) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(all, "hello from stderr") }).pipe(Effect.scoped)) it.effect("should handle many lines of interspersed output via .all", () => Effect.gen(function*() { // Use sleep to force buffer flushes, ensuring interleaved arrival const handle = yield* ChildProcess.make( "sh", ["-c", "for i in 1 2 3 4 5; do echo stdout$i; sleep 0.01; echo stderr$i >&2; sleep 0.01; done"] ) const all = yield* decodeByteStream(handle.all) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // Verify all lines are present in interleaved order const expected = [] for (let i = 1; i <= 5; i++) { expected.push(`stdout${i}`, `stderr${i}`) } assert.strictEqual(all, expected.join("\n")) }).pipe(Effect.scoped)) it.effect("should allow reading .all independently", () => Effect.gen(function*() { const handle = yield* ChildProcess.make( "sh", ["-c", "echo out; sleep 0.01; echo err >&2"] ) const all = yield* decodeByteStream(handle.all) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(all, ["out", "err"].join("\n")) }).pipe(Effect.scoped)) }) describe("stdout streaming", () => { it.effect("should stream stdout", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["streaming output"]) const output = yield* decodeByteStream(handle.stdout) assert.strictEqual(output, "streaming output") }).pipe(Effect.scoped)) it.effect("should stream multiple lines", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "echo line1; echo line2; echo line3"]) const output = yield* decodeByteStream(handle.stdout) assert.strictEqual(output, ["line1", "line2", "line3"].join("\n")) }).pipe(Effect.scoped)) }) describe("process control", () => { it.effect("should kill a process", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sleep", ["10"]) yield* handle.kill() // After killing, exitCode should eventually resolve (with signal error) const exit = yield* Effect.exit(handle.exitCode) assert.isTrue(exit._tag === "Failure") }).pipe(Effect.scoped)) it.effect("should kill with specific signal", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sleep", ["10"]) yield* handle.kill({ killSignal: "SIGKILL" }) const exit = yield* Effect.exit(handle.exitCode) assert.isTrue(exit._tag === "Failure") }).pipe(Effect.scoped)) it.effect("should force kill a process after the initial signal times out", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const directory = yield* fs.makeTempDirectoryScoped() const ready = path.join(directory, "ready") const handle = yield* ChildProcess.make("node", [ "-e", // Installing a listener suppresses Node's default SIGTERM exit so the test exercises force-kill escalation. "process.on('SIGTERM', () => {}); require('node:fs').writeFileSync(process.argv[1], ''); setInterval(() => {}, 1000)", ready ], { killSignal: "SIGKILL", stdout: "ignore", stderr: "ignore" }) yield* fs.exists(ready).pipe( Effect.repeat({ while: (exists) => !exists, schedule: Schedule.spaced("10 millis") }), Effect.timeout("1 second"), TestClock.withLive ) const completed = yield* handle.kill({ killSignal: "SIGTERM", forceKillAfter: "50 millis" }).pipe( Effect.as(true), Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(false) }), TestClock.withLive ) assert.isTrue(completed) }).pipe(Effect.scoped)) it.effect("should force kill a process when its scope closes", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const directory = yield* fs.makeTempDirectoryScoped() const ready = path.join(directory, "ready") const completed = yield* Effect.scoped(Effect.gen(function*() { yield* ChildProcess.make("node", [ "-e", // Installing a listener suppresses Node's default SIGTERM exit so the test exercises force-kill escalation. "process.on('SIGTERM', () => {}); require('node:fs').writeFileSync(process.argv[1], ''); setTimeout(() => process.exit(0), 2000)", ready ], { killSignal: "SIGTERM", forceKillAfter: "50 millis", stdout: "ignore", stderr: "ignore" }) yield* fs.exists(ready).pipe( Effect.repeat({ while: (exists) => !exists, schedule: Schedule.spaced("10 millis") }), Effect.timeout("1 second") ) })).pipe( Effect.as(true), Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(false) }), TestClock.withLive ) assert.isTrue(completed) }).pipe(Effect.scoped)) }) }) describe("pipeline spawning", () => { it.effect("should spawn a simple pipeline", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello world`.pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO WORLD") }).pipe(Effect.scoped)) it.effect("should spawn a three-stage pipeline", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello world`.pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`), ChildProcess.pipeTo(ChildProcess.make("tr", [" ", "-"])) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO-WORLD") }).pipe(Effect.scoped)) it.effect("should pipe grep output", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["line1\nline2\nline3"]).pipe( ChildProcess.pipeTo(ChildProcess.make`grep line2`) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "line2") }).pipe(Effect.scoped)) it.effect("should handle mixed command forms in pipeline", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["hello"]).pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO") }).pipe(Effect.scoped)) }) describe("pipeline pipe options", () => { it.effect("should pipe stderr to stdin with { from: 'stderr' }", () => Effect.gen(function*() { // Command that writes "error" to stderr const handle = yield* ChildProcess.make("sh", ["-c", "echo error >&2"]).pipe( ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "stderr" }) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "error") }).pipe(Effect.scoped)) it.effect("should pipe combined output with { from: 'all' }", () => Effect.gen(function*() { // Command that writes to both stdout and stderr with small delays const handle = yield* ChildProcess.make("sh", [ "-c", "echo out1; sleep 0.01; echo err1 >&2; sleep 0.01; echo out2" ]).pipe( ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "all" }) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, ["out1", "err1", "out2"].join("\n")) }).pipe(Effect.scoped)) it.effect("should default to stdout when no options provided", () => Effect.gen(function*() { // Command that writes to both stdout and stderr const handle = yield* ChildProcess.make("sh", ["-c", "echo stdout; echo stderr >&2"]).pipe( ChildProcess.pipeTo(ChildProcess.make`cat`) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) // Only stdout should be piped (default behavior) assert.strictEqual(output, "stdout") }).pipe(Effect.scoped)) it.effect("should work with empty options object", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello`.pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, {}) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO") }).pipe(Effect.scoped)) it.effect("should work with explicit { from: 'stdout' }", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello`.pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, { from: "stdout" }) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO") }).pipe(Effect.scoped)) it.effect("should work with explicit { to: 'stdin' }", () => Effect.gen(function*() { const handle = yield* ChildProcess.make`echo hello`.pipe( ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, { to: "stdin" }) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "HELLO") }).pipe(Effect.scoped)) it.effect("should support chained pipes with different options", () => Effect.gen(function*() { // First pipe: stdout to stdin (default) // Second pipe: from stderr const handle = yield* ChildProcess.make`echo hello`.pipe( ChildProcess.pipeTo(ChildProcess.make("sh", ["-c", "cat; echo error >&2"])), ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "stderr" }) ) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, "error") }).pipe(Effect.scoped)) }) describe("error handling", () => { it.effect("should return non-zero exit code", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", "exit 1"]) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1)) }).pipe(Effect.scoped)) it.effect("should fail for invalid command", () => Effect.gen(function*() { const exit = yield* Effect.exit( ChildProcess.make("nonexistent-command-12345") ) assert.isTrue(exit._tag === "Failure") }).pipe(Effect.scoped)) it.effect("should handle spawn error with invalid cwd", () => Effect.gen(function*() { const exit = yield* Effect.exit( ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }) ) assert.isTrue(exit._tag === "Failure") }).pipe(Effect.scoped)) it.effect("should throw permission denied as a typed error", () => Effect.gen(function*() { const path = yield* Path.Path const cwd = path.join(...TEST_BASH_SCRIPTS_PATH) const command = ChildProcess.make({ cwd })`./no-permissions.sh` const result = yield* Effect.flip(command) assert.deepStrictEqual( result, PlatformError.systemError({ _tag: "PermissionDenied", module: "ChildProcess", method: "spawn", pathOrDescriptor: "./no-permissions.sh ", syscall: "spawn ./no-permissions.sh" }) ) }).pipe(Effect.scoped)) }) describe("stdin", () => { it.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* ChildProcess.make("cat", { stdin }) const output = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.deepStrictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(output, input) }).pipe(Effect.scoped)) }) describe("additionalFds", () => { it.effect("should read data from an output fd (fd3)", () => Effect.gen(function*() { // Use a shell script that writes to fd3 // The script echoes "hello from fd3" to file descriptor 3 const handle = yield* ChildProcess.make("sh", ["-c", "echo 'hello from fd3' >&3"], { additionalFds: { fd3: { type: "output" } } }) const fd3Output = yield* decodeByteStream(handle.getOutputFd(3)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(fd3Output, "hello from fd3") }).pipe(Effect.scoped)) it.effect("should write data to an input fd (fd3)", () => Effect.gen(function*() { // Use a shell script that reads from fd3 and echoes it to stdout // The script reads from file descriptor 3 and outputs to stdout const inputData = "data from parent" const inputStream = Stream.make(new TextEncoder().encode(inputData)) const handle = yield* ChildProcess.make("sh", ["-c", "cat <&3"], { additionalFds: { fd3: { type: "input", stream: inputStream } } }) const stdout = yield* decodeByteStream(handle.stdout) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, inputData) }).pipe(Effect.scoped)) it.effect("should handle multiple additional fds", () => Effect.gen(function*() { // Script that writes different messages to fd3 and fd4 const handle = yield* ChildProcess.make( "sh", ["-c", "echo 'output on fd3' >&3; echo 'output on fd4' >&4"], { additionalFds: { fd3: { type: "output" }, fd4: { type: "output" } } } ) const fd3Output = yield* decodeByteStream(handle.getOutputFd(3)) const fd4Output = yield* decodeByteStream(handle.getOutputFd(4)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(fd3Output, "output on fd3") assert.strictEqual(fd4Output, "output on fd4") }).pipe(Effect.scoped)) it.effect("should handle fd gaps (e.g., fd3 and fd5 without fd4)", () => Effect.gen(function*() { // Script that writes to fd3 and fd5, skipping fd4 const handle = yield* ChildProcess.make( "sh", ["-c", "echo 'on fd3' >&3; echo 'on fd5' >&5"], { additionalFds: { fd3: { type: "output" }, fd5: { type: "output" } } } ) const fd3Output = yield* decodeByteStream(handle.getOutputFd(3)) const fd5Output = yield* decodeByteStream(handle.getOutputFd(5)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(fd3Output, "on fd3") assert.strictEqual(fd5Output, "on fd5") }).pipe(Effect.scoped)) it.effect("should return empty stream for unconfigured output fd", () => Effect.gen(function*() { const handle = yield* ChildProcess.make("echo", ["test"]) // fd3 was not configured, should return empty stream const fd3Output = yield* decodeByteStream(handle.getOutputFd(3)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(fd3Output, "") }).pipe(Effect.scoped)) it.effect("should handle bidirectional communication via separate fds", () => Effect.gen(function*() { // Script that reads from fd3, transforms it, and writes to fd4 const inputData = "hello" const inputStream = Stream.make(new TextEncoder().encode(inputData)) const handle = yield* ChildProcess.make( "sh", ["-c", "cat <&3 | tr a-z A-Z >&4"], { additionalFds: { fd3: { type: "input", stream: inputStream }, fd4: { type: "output" } } } ) const fd4Output = yield* decodeByteStream(handle.getOutputFd(4)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(fd4Output, "HELLO") }).pipe(Effect.scoped)) it.effect("should work alongside normal stdin/stdout/stderr", () => Effect.gen(function*() { // Script that uses all standard streams plus fd3 const handle = yield* ChildProcess.make( "sh", ["-c", "echo 'stdout'; echo 'stderr' >&2; echo 'fd3' >&3"], { additionalFds: { fd3: { type: "output" } } } ) const stdout = yield* decodeByteStream(handle.stdout) const stderr = yield* decodeByteStream(handle.stderr) const fd3Output = yield* decodeByteStream(handle.getOutputFd(3)) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) assert.strictEqual(stdout, "stdout") assert.strictEqual(stderr, "stderr") assert.strictEqual(fd3Output, "fd3") }).pipe(Effect.scoped)) }) describe("process supervision", () => { const countMatchingProcesses = (pattern: string) => Effect.gen(function*() { const handle = yield* ChildProcess.make("bash", [ "-c", `ps aux | grep '${pattern}' | grep -v grep | wc -l` ]) const output = yield* decodeByteStream(handle.stdout) return Number.parseInt(output.trim()) }).pipe(Effect.orElseSucceed(() => 0)) const killMatchingProcesses = (pattern: string) => Effect.gen(function*() { const escaped = `[${pattern[0]}]${pattern.slice(1)}` const handle = yield* ChildProcess.make("bash", ["-c", `pkill -f '${escaped}' || true`]) yield* Effect.ignore(handle.exitCode) }).pipe(Effect.asVoid) const longRunningCommand = () => ChildProcess.make("node", ["-e", "setTimeout(() => {}, 30000)"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }) it.effect("should kill all child processes in process group", () => Effect.gen(function*() { const path = yield* Path.Path const cwd = path.join(...TEST_BASH_SCRIPTS_PATH) // Start the process that spawns children and grandchildren const handle = yield* ChildProcess.make("./spawn-children.sh", { cwd }) // Give it time to spawn all processes yield* TestClock.withLive(Effect.sleep("100 millis")) // Verify the main process is running const isRunningBeforeKill = yield* handle.isRunning assert.isTrue(isRunningBeforeKill) // Count processes before killing - should be at least 7 (1 parent + 3 children + 3 grandchildren) const beforeKillHandle = yield* ChildProcess.make("bash", [ "-c", "ps aux | grep spawn-children.sh | grep -v grep | wc -l" ]) const beforeKill = yield* decodeByteStream(beforeKillHandle.stdout).pipe( Effect.map((s) => Number.parseInt(s.trim())), Effect.orElseSucceed(() => 0) ) assert.isAtLeast(beforeKill, 7) // Kill the main process yield* handle.kill() // Verify the main process is no longer running const isRunningAfterKill = yield* handle.isRunning assert.isFalse(isRunningAfterKill) // Give a moment for cleanup to complete yield* TestClock.withLive(Effect.sleep("100 millis")) // Check that no processes from the script are still running const afterKillHandle = yield* ChildProcess.make("bash", [ "-c", "ps aux | grep spawn-children.sh | grep -v grep | wc -l" ]) const afterKill = yield* decodeByteStream(afterKillHandle.stdout).pipe( Effect.map((s) => Number.parseInt(s.trim())), Effect.orElseSucceed(() => 0) ) assert.strictEqual(afterKill, 0) }).pipe(Effect.scoped)) it.effect("should cleanup child processes when parent exits with non-zero code", () => Effect.gen(function*() { const path = yield* Path.Path const cwd = path.join(...TEST_BASH_SCRIPTS_PATH) // Count processes before running the command const beforeRunHandle = yield* ChildProcess.make("bash", [ "-c", "ps aux | grep parent-exits-early.sh | grep -v grep | wc -l" ]) const beforeRun = yield* decodeByteStream(beforeRunHandle.stdout).pipe( Effect.map((s) => Number.parseInt(s.trim())), Effect.orElseSucceed(() => 0) ) assert.strictEqual(beforeRun, 0) // Run command in a separate scope so cleanup happens before we check const exitCode = yield* Effect.scoped(Effect.gen(function*() { const handle = yield* ChildProcess.make({ cwd })`./parent-exits-early.sh` return yield* handle.exitCode })) assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1)) // Allow cleanup to occur yield* TestClock.withLive(Effect.sleep("100 millis")) const afterExitHandle = yield* ChildProcess.make("bash", [ "-c", "ps aux | grep 'sleep 30' | grep -v grep | wc -l" ]) const afterExit = yield* decodeByteStream(afterExitHandle.stdout).pipe( Effect.map((s) => Number.parseInt(s.trim())), Effect.orElseSucceed(() => 0) ) // Child processes should be cleaned up after non-zero exit assert.strictEqual(afterExit, 0) }).pipe(Effect.scoped)) it.effect("should not kill an unrefed process when scope closes", () => Effect.gen(function*() { const scope = yield* Scope.make() const handle = yield* Scope.provide(scope)(Effect.gen(function*() { return yield* longRunningCommand() })).pipe( Effect.provide(NodeServices) ) // @effect-diagnostics-next-line floatingEffect:off yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(NodeServices)) yield* Scope.close(scope, Exit.void) yield* TestClock.withLive(Effect.sleep("100 millis")) const isRunning = yield* handle.isRunning assert.isTrue(isRunning) yield* handle.kill({ killSignal: "SIGKILL" }) }).pipe(Effect.provide(NodeServices))) it.effect("should kill a restored process when scope closes", () => Effect.gen(function*() { const scope = yield* Scope.make() const handle = yield* Scope.provide(scope)(Effect.gen(function*() { return yield* longRunningCommand() })).pipe( Effect.provide(NodeServices) ) const reref = yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(NodeServices)) yield* reref yield* Scope.close(scope, Exit.void) yield* TestClock.withLive(Effect.sleep("100 millis")) const isRunning = yield* handle.isRunning assert.isFalse(isRunning) }).pipe(Effect.provide(NodeServices))) it.effect("should resolve exitCode after closing the original scope of an unrefed process", () => Effect.gen(function*() { const scope = yield* Scope.make() const handle = yield* Scope.provide(scope)(Effect.gen(function*() { return yield* ChildProcess.make("node", ["-e", "setTimeout(() => process.exit(0), 50)"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }) })).pipe(Effect.provide(NodeServices)) // @effect-diagnostics-next-line floatingEffect:off yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(NodeServices)) yield* Scope.close(scope, Exit.void) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) }).pipe(Effect.provide(NodeServices))) it.effect("should cleanup descendants after an unrefed parent exits non-zero", () => Effect.gen(function*() { const path = yield* Path.Path const cwd = path.join(...TEST_BASH_SCRIPTS_PATH) const scope = yield* Scope.make() const handle = yield* Scope.provide(scope)(Effect.gen(function*() { return yield* ChildProcess.make({ cwd })`./parent-exits-early.sh` })).pipe(Effect.provide(NodeServices)) // @effect-diagnostics-next-line floatingEffect:off yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(NodeServices)) yield* Scope.close(scope, Exit.void) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1)) yield* TestClock.withLive(Effect.sleep("100 millis")) const remaining = yield* countMatchingProcesses("sleep 30") assert.strictEqual(remaining, 0) }).pipe(Effect.provide(NodeServices))) it.effect("should unref every process in a pipeline", () => Effect.gen(function*() { const scope = yield* Scope.make() const rootMarker = "pipeline-unref-root" const tailMarker = "pipeline-unref-tail" const handle = yield* Scope.provide(scope)(Effect.gen(function*() { return yield* ChildProcess.make("node", ["-e", "setTimeout(() => {}, 30000)", rootMarker], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }).pipe( ChildProcess.pipeTo( ChildProcess.make("node", ["-e", "setTimeout(() => {}, 30000)", tailMarker], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) ) ) })).pipe(Effect.provide(NodeServices)) // @effect-diagnostics-next-line floatingEffect:off yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(NodeServices)) yield* Scope.close(scope, Exit.void) yield* TestClock.withLive(Effect.sleep("100 millis")) const rootCount = yield* countMatchingProcesses(rootMarker) const tailCount = yield* countMatchingProcesses(tailMarker) assert.strictEqual(rootCount, 1) assert.strictEqual(tailCount, 1) yield* killMatchingProcesses(rootMarker) yield* killMatchingProcesses(tailMarker) }).pipe(Effect.provide(NodeServices))) }) it.effect("should not deadlock on large stdout output", () => Effect.gen(function*() { // Generate ~5MB of output — enough to exceed the default PassThrough // highWaterMark (16KB) many times over. Without the fix, the unread // combinedPassThrough (.all) would exert backpressure on the source // stream, blocking stdout too. const handle = yield* ChildProcess.make("sh", ["-c", "seq 1 100000"]) const output = yield* handle.stdout.pipe( Stream.decodeText(), Stream.runFold(() => "", (acc, chunk) => acc + chunk) ) const exitCode = yield* handle.exitCode assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0)) const lines = output.trim().split("\n") assert.strictEqual(lines.length, 100000) assert.strictEqual(lines[0], "1") assert.strictEqual(lines[99999], "100000") }).pipe(Effect.scoped), { timeout: 10_000 }) it.effect("ChildProcess.string should not deadlock on large output", () => Effect.gen(function*() { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const output = yield* spawner.string(ChildProcess.make("sh", ["-c", "seq 1 100000"])) const lines = output.trim().split("\n") assert.strictEqual(lines.length, 100000) assert.strictEqual(lines[0], "1") assert.strictEqual(lines[99999], "100000") }), { timeout: 10_000 }) }) })