1070 lines
45 KiB
TypeScript
1070 lines
45 KiB
TypeScript
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 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<Uint8Array, PlatformError.PlatformError>,
|
|
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))
|
|
})
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|