1import { assert, describe, it } from "@effect/vitest"
2import * as Effect from "effect/Effect"
3import * as Exit from "effect/Exit"
4import * as FileSystem from "effect/FileSystem"
5import type * as Layer from "effect/Layer"
6import * as Path from "effect/Path"
7import type * as PlatformError from "effect/PlatformError"
8import { ChildProcess, ChildProcessSpawner } from "effect/process"
9import * as Schedule from "effect/Schedule"
10import * as Scope from "effect/Scope"
11import * as Stream from "effect/Stream"
12import * as TestClock from "effect/testing/TestClock"
13
14export interface Options {
15 readonly processGroups: boolean
16 readonly additionalFds?: boolean | undefined
17}
18
19// Helper to collect stream output into a string
20const decodeByteStream = Effect.fnUntraced(
21 function*(
22 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>
23 ) {
24 const chunks = yield* Stream.runCollect(stream)
25 const totalLength = chunks.reduce((acc, c) => acc + c.length, 0)
26 const result = new Uint8Array(totalLength)
27 let offset = 0
28 for (const chunk of chunks) {
29 result.set(chunk, offset)
30 offset += chunk.length
31 }
32 return new TextDecoder().decode(result).trim()
33 }
34)
35
36export const suite = (
37 name: string,
38 layer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path>,
39 options: Options
40) =>
41 describe(name, () => {
42 it.layer(layer)((it) => {
43 describe("spawn", () => {
44 describe("basic spawning", () => {
45 it.effect("should spawn a simple command and collect output", () =>
46 Effect.gen(function*() {
47 const handle = yield* ChildProcess.make("sh", ["-c", "printf portable"])
48 const output = yield* decodeByteStream(handle.stdout)
49 const exitCode = yield* handle.exitCode
50
51 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
52 assert.strictEqual(output, "portable")
53 }))
54
55 it.effect("should spawn echo command", () =>
56 Effect.gen(function*() {
57 const handle = yield* ChildProcess.make("echo", ["hello", "world"])
58 const output = yield* decodeByteStream(handle.stdout)
59 const exitCode = yield* handle.exitCode
60
61 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
62 assert.strictEqual(output, "hello world")
63 }))
64
65 it.effect("should spawn with template literal", () =>
66 Effect.gen(function*() {
67 const handle = yield* ChildProcess.make`echo spawned`
68 const output = yield* decodeByteStream(handle.stdout)
69 const exitCode = yield* handle.exitCode
70
71 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
72 assert.strictEqual(output, "spawned")
73 }))
74 })
75
76 describe("cwd option", () => {
77 it.effect("should handle command with working directory", () =>
78 Effect.gen(function*() {
79 const handle = yield* ChildProcess.make("pwd", [], { cwd: "/tmp" })
80 const output = yield* decodeByteStream(handle.stdout)
81 const exitCode = yield* handle.exitCode
82
83 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
84 // On macOS, /tmp is a symlink to /private/tmp
85 assert.isTrue(output.includes("tmp"))
86 }))
87
88 it.effect("should use cwd with template literal form", () =>
89 Effect.gen(function*() {
90 const handle = yield* ChildProcess.make({ cwd: "/tmp" })`pwd`
91 const output = yield* decodeByteStream(handle.stdout)
92 const exitCode = yield* handle.exitCode
93
94 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
95 assert.isTrue(output.includes("tmp"))
96 }))
97 })
98
99 describe("env option", () => {
100 it.effect("should handle environment variables", () =>
101 Effect.gen(function*() {
102 const handle = yield* ChildProcess.make("sh", ["-c", "echo $TEST_VAR"], {
103 env: { TEST_VAR: "test_value" },
104 extendEnv: true
105 })
106 const output = yield* decodeByteStream(handle.stdout)
107 const exitCode = yield* handle.exitCode
108
109 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
110 assert.strictEqual(output, "test_value")
111 }))
112
113 it.effect("should handle multiple environment variables", () =>
114 Effect.gen(function*() {
115 const handle = yield* ChildProcess.make("sh", ["-c", "echo $VAR1-$VAR2-$VAR3"], {
116 env: { VAR1: "one", VAR2: "two", VAR3: "three" },
117 extendEnv: true
118 })
119 const output = yield* decodeByteStream(handle.stdout)
120 const exitCode = yield* handle.exitCode
121
122 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
123 assert.strictEqual(output, "one-two-three")
124 }))
125
126 it.effect("should merge environment variables with setEnv", () =>
127 Effect.gen(function*() {
128 const command = ChildProcess.make("sh", ["-c", "echo $VAR1-$VAR2-$VAR3"], {
129 env: { VAR1: "one", VAR2: "two" },
130 extendEnv: true
131 }).pipe(ChildProcess.setEnv({ VAR2: "override", VAR3: "three" }))
132 const handle = yield* command
133 const output = yield* decodeByteStream(handle.stdout)
134 const exitCode = yield* handle.exitCode
135
136 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
137 assert.strictEqual(output, "one-override-three")
138 }))
139 })
140
141 describe("shell option", () => {
142 it.effect("should execute with shell when using sh -c", () =>
143 Effect.gen(function*() {
144 // Use sh -c to test shell expansion without triggering deprecation warning
145 const handle = yield* ChildProcess.make("sh", ["-c", "echo $SHELL_EXPANSION_VAR"], {
146 env: { SHELL_EXPANSION_VAR: "expanded" },
147 extendEnv: true
148 })
149 const output = yield* decodeByteStream(handle.stdout)
150 const exitCode = yield* handle.exitCode
151
152 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
153 assert.strictEqual(output, "expanded")
154 }))
155
156 it.effect("should not expand variables without shell", () =>
157 Effect.gen(function*() {
158 const handle = yield* ChildProcess.make("echo", ["$HOME"], { shell: false })
159 const output = yield* decodeByteStream(handle.stdout)
160 const exitCode = yield* handle.exitCode
161
162 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
163 // Without shell, $HOME should not be expanded
164 assert.strictEqual(output, "$HOME")
165 }))
166
167 it.effect("should allow piping with shell", () =>
168 Effect.gen(function*() {
169 const handle = yield* ChildProcess.make("sh", ["-c", "echo hello | tr a-z A-Z"])
170 const output = yield* decodeByteStream(handle.stdout)
171 const exitCode = yield* handle.exitCode
172
173 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
174 assert.strictEqual(output, "HELLO")
175 }))
176 })
177
178 describe("template literal forms", () => {
179 it.effect("should work with template literal form", () =>
180 Effect.gen(function*() {
181 const handle = yield* ChildProcess.make`echo hello`
182 const output = yield* decodeByteStream(handle.stdout)
183 const exitCode = yield* handle.exitCode
184
185 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
186 assert.strictEqual(output, "hello")
187 }))
188
189 it.effect("should handle string interpolation", () =>
190 Effect.gen(function*() {
191 const name = "world"
192 const handle = yield* ChildProcess.make`echo hello ${name}`
193 const output = yield* decodeByteStream(handle.stdout)
194 const exitCode = yield* handle.exitCode
195
196 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
197 assert.strictEqual(output, "hello world")
198 }))
199
200 it.effect("should handle number interpolation", () =>
201 Effect.gen(function*() {
202 const count = 42
203 const handle = yield* ChildProcess.make`echo count is ${count}`
204 const output = yield* decodeByteStream(handle.stdout)
205 const exitCode = yield* handle.exitCode
206
207 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
208 assert.strictEqual(output, "count is 42")
209 }))
210
211 it.effect("should handle array interpolation", () =>
212 Effect.gen(function*() {
213 const fs = yield* FileSystem.FileSystem
214 const path = yield* Path.Path
215 const dir = yield* fs.makeTempDirectoryScoped()
216 const file = path.join(dir, "array-interpolation.txt")
217 const args = ["-l", "-a"]
218 yield* fs.writeFile(file, new TextEncoder().encode("test"))
219
220 const handle = yield* ChildProcess.make`ls ${args} ${dir}`
221 const output = yield* decodeByteStream(handle.stdout)
222 const exitCode = yield* handle.exitCode
223
224 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
225 assert.isTrue(output.includes("array-interpolation.txt"))
226 }))
227
228 it.effect("should handle multiple interpolations", () =>
229 Effect.gen(function*() {
230 const greeting = "hello"
231 const target = "world"
232 const handle = yield* ChildProcess.make`echo ${greeting} ${target}`
233 const output = yield* decodeByteStream(handle.stdout)
234 const exitCode = yield* handle.exitCode
235
236 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
237 assert.strictEqual(output, "hello world")
238 }))
239
240 it.effect("should handle options with template literal", () =>
241 Effect.gen(function*() {
242 const filename = "test.txt"
243 const handle = yield* ChildProcess.make({ cwd: "/tmp" })`echo ${filename}`
244 const output = yield* decodeByteStream(handle.stdout)
245 const exitCode = yield* handle.exitCode
246
247 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
248 assert.strictEqual(output, "test.txt")
249 }))
250 })
251
252 describe("stderr streaming", () => {
253 it.effect("should capture stderr output", () =>
254 Effect.gen(function*() {
255 const handle = yield* ChildProcess.make("sh", ["-c", "echo error message >&2"])
256 const stderr = yield* decodeByteStream(handle.stderr)
257 const exitCode = yield* handle.exitCode
258
259 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
260 assert.strictEqual(stderr, "error message")
261 }))
262
263 it.effect("should capture both stdout and stderr", () =>
264 Effect.gen(function*() {
265 const handle = yield* ChildProcess.make("sh", ["-c", "echo stdout; echo stderr >&2"])
266 const [stdout, stderr] = yield* Effect.all([
267 decodeByteStream(handle.stdout),
268 decodeByteStream(handle.stderr)
269 ], { concurrency: "unbounded" })
270 const exitCode = yield* handle.exitCode
271
272 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
273 assert.strictEqual(stdout, "stdout")
274 assert.strictEqual(stderr, "stderr")
275 }))
276
277 it.effect("should handle more stdout than stderr", () =>
278 Effect.gen(function*() {
279 // Process outputs many lines to stdout but only one to stderr
280 const handle = yield* ChildProcess.make(
281 "sh",
282 ["-c", "echo line1; echo line2; echo line3; echo line4; echo line5; echo error >&2"]
283 )
284 const [stdout, stderr] = yield* Effect.all([
285 decodeByteStream(handle.stdout),
286 decodeByteStream(handle.stderr)
287 ], { concurrency: "unbounded" })
288 const exitCode = yield* handle.exitCode
289
290 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
291 assert.strictEqual(stdout, ["line1", "line2", "line3", "line4", "line5"].join("\n"))
292 assert.strictEqual(stderr, "error")
293 }))
294
295 it.effect("should handle more stderr than stdout", () =>
296 Effect.gen(function*() {
297 // Process outputs many lines to stderr but only one to stdout
298 const handle = yield* ChildProcess.make(
299 "sh",
300 ["-c", "echo output; echo err1 >&2; echo err2 >&2; echo err3 >&2; echo err4 >&2; echo err5 >&2"]
301 )
302 const [stdout, stderr] = yield* Effect.all([
303 decodeByteStream(handle.stdout),
304 decodeByteStream(handle.stderr)
305 ], { concurrency: "unbounded" })
306 const exitCode = yield* handle.exitCode
307
308 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
309 assert.strictEqual(stdout, "output")
310 assert.strictEqual(stderr, ["err1", "err2", "err3", "err4", "err5"].join("\n"))
311 }))
312
313 it.effect("should allow reading only stdout when stderr is empty", () =>
314 Effect.gen(function*() {
315 const handle = yield* ChildProcess.make("echo", ["only stdout"])
316 // Read streams in parallel to avoid deadlock when one stream is empty
317 const [stdout, stderr] = yield* Effect.all([
318 decodeByteStream(handle.stdout),
319 decodeByteStream(handle.stderr)
320 ], { concurrency: "unbounded" })
321 const exitCode = yield* handle.exitCode
322
323 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
324 assert.strictEqual(stdout, "only stdout")
325 assert.strictEqual(stderr, "")
326 }))
327
328 it.effect("should allow reading only stderr when stdout is empty", () =>
329 Effect.gen(function*() {
330 const handle = yield* ChildProcess.make("sh", ["-c", "echo only stderr >&2"])
331 // Read streams in parallel to avoid deadlock when one stream is empty
332 const [stdout, stderr] = yield* Effect.all([
333 decodeByteStream(handle.stdout),
334 decodeByteStream(handle.stderr)
335 ], { concurrency: "unbounded" })
336 const exitCode = yield* handle.exitCode
337
338 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
339 assert.strictEqual(stdout, "")
340 assert.strictEqual(stderr, "only stderr")
341 }))
342 })
343
344 describe("combined output (all)", () => {
345 it.effect("should read interspersed stdout and stderr via .all", () =>
346 Effect.gen(function*() {
347 // Use sleep to force buffer flushes between writes, ensuring
348 // stdout and stderr chunks arrive separately for proper interleaving
349 const handle = yield* ChildProcess.make(
350 "sh",
351 [
352 "-c",
353 [
354 "echo stdout1; sleep 0.01;",
355 "echo stderr1 >&2; sleep 0.01;",
356 "echo stdout2; sleep 0.01;",
357 "echo stderr2 >&2"
358 ].join(" ")
359 ]
360 )
361 const all = yield* decodeByteStream(handle.all)
362 const exitCode = yield* handle.exitCode
363
364 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
365 const lines = all.split("\n")
366 assert.strictEqual(lines.length, 4)
367 assert.deepStrictEqual(lines.filter((line) => line.startsWith("stdout")), ["stdout1", "stdout2"])
368 assert.deepStrictEqual(lines.filter((line) => line.startsWith("stderr")), ["stderr1", "stderr2"])
369 }))
370
371 it.effect("should capture only stdout via .all when no stderr", () =>
372 Effect.gen(function*() {
373 const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
374 const all = yield* decodeByteStream(handle.all)
375 const exitCode = yield* handle.exitCode
376
377 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
378 assert.strictEqual(all, "hello from stdout")
379 }))
380
381 it.effect("should capture only stderr via .all when no stdout", () =>
382 Effect.gen(function*() {
383 const handle = yield* ChildProcess.make("sh", ["-c", "echo hello from stderr >&2"])
384 const all = yield* decodeByteStream(handle.all)
385 const exitCode = yield* handle.exitCode
386
387 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
388 assert.strictEqual(all, "hello from stderr")
389 }))
390
391 it.effect("should handle many lines of interspersed output via .all", () =>
392 Effect.gen(function*() {
393 // Use sleep to force buffer flushes, ensuring interleaved arrival
394 const handle = yield* ChildProcess.make(
395 "sh",
396 ["-c", "for i in 1 2 3 4 5; do echo stdout$i; sleep 0.01; echo stderr$i >&2; sleep 0.01; done"]
397 )
398 const all = yield* decodeByteStream(handle.all)
399 const exitCode = yield* handle.exitCode
400
401 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
402 const lines = all.split("\n")
403 assert.strictEqual(lines.length, 10)
404 assert.deepStrictEqual(
405 lines.filter((line) => line.startsWith("stdout")),
406 ["stdout1", "stdout2", "stdout3", "stdout4", "stdout5"]
407 )
408 assert.deepStrictEqual(
409 lines.filter((line) => line.startsWith("stderr")),
410 ["stderr1", "stderr2", "stderr3", "stderr4", "stderr5"]
411 )
412 }))
413
414 it.effect("should allow reading .all independently", () =>
415 Effect.gen(function*() {
416 const handle = yield* ChildProcess.make(
417 "sh",
418 ["-c", "echo out; sleep 0.01; echo err >&2"]
419 )
420 const all = yield* decodeByteStream(handle.all)
421 const exitCode = yield* handle.exitCode
422
423 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
424 assert.strictEqual(all, ["out", "err"].join("\n"))
425 }))
426 })
427
428 describe("stdout streaming", () => {
429 it.effect("should stream stdout", () =>
430 Effect.gen(function*() {
431 const handle = yield* ChildProcess.make("echo", ["streaming output"])
432 const output = yield* decodeByteStream(handle.stdout)
433
434 assert.strictEqual(output, "streaming output")
435 }))
436
437 it.effect("should stream multiple lines", () =>
438 Effect.gen(function*() {
439 const handle = yield* ChildProcess.make("sh", ["-c", "echo line1; echo line2; echo line3"])
440 const output = yield* decodeByteStream(handle.stdout)
441
442 assert.strictEqual(output, ["line1", "line2", "line3"].join("\n"))
443 }))
444 })
445
446 describe("process control", () => {
447 it.effect("should kill a process", () =>
448 Effect.gen(function*() {
449 const handle = yield* ChildProcess.make("sleep", ["10"])
450
451 yield* handle.kill()
452
453 // After killing, exitCode should eventually resolve (with signal error)
454 const exit = yield* Effect.exit(handle.exitCode)
455 assert.isTrue(exit._tag === "Failure")
456 }))
457
458 it.effect("should kill with specific signal", () =>
459 Effect.gen(function*() {
460 const handle = yield* ChildProcess.make("sleep", ["10"])
461
462 yield* handle.kill({ killSignal: "SIGKILL" })
463
464 const exit = yield* Effect.exit(handle.exitCode)
465 assert.isTrue(exit._tag === "Failure")
466 }))
467
468 it.effect("should force kill a process after the initial signal times out", () =>
469 Effect.gen(function*() {
470 const fs = yield* FileSystem.FileSystem
471 const path = yield* Path.Path
472 const directory = yield* fs.makeTempDirectoryScoped()
473 const ready = path.join(directory, "ready")
474 const handle = yield* ChildProcess.make("sh", [
475 "-c",
476 "trap '' TERM; : > \"$1\"; exec cat",
477 "force-kill",
478 ready
479 ], {
480 killSignal: "SIGKILL",
481 stdin: "pipe",
482 stdout: "ignore",
483 stderr: "ignore"
484 })
485
486 yield* fs.exists(ready).pipe(
487 Effect.repeat({
488 while: (exists) => !exists,
489 schedule: Schedule.spaced("10 millis")
490 }),
491 Effect.timeout("1 second"),
492 TestClock.withLive
493 )
494
495 yield* handle.kill({
496 killSignal: "SIGTERM",
497 forceKillAfter: "50 millis"
498 }).pipe(TestClock.withLive)
499
500 assert.isFalse(yield* handle.isRunning)
501 assert.isTrue(Exit.isFailure(yield* Effect.exit(handle.exitCode)))
502 }))
503
504 it.effect("should force kill a process when its scope closes", () =>
505 Effect.gen(function*() {
506 const fs = yield* FileSystem.FileSystem
507 const path = yield* Path.Path
508 const directory = yield* fs.makeTempDirectoryScoped()
509 const ready = path.join(directory, "ready")
510 const completed = yield* Effect.scoped(Effect.gen(function*() {
511 yield* ChildProcess.make("sh", [
512 "-c",
513 "trap '' TERM; : > \"$1\"; sleep 2",
514 "force-kill",
515 ready
516 ], {
517 killSignal: "SIGTERM",
518 forceKillAfter: "50 millis",
519 stdout: "ignore",
520 stderr: "ignore"
521 })
522
523 yield* fs.exists(ready).pipe(
524 Effect.repeat({
525 while: (exists) => !exists,
526 schedule: Schedule.spaced("10 millis")
527 }),
528 Effect.timeout("1 second")
529 )
530 })).pipe(
531 Effect.as(true),
532 Effect.timeoutOrElse({
533 duration: "1 second",
534 orElse: () => Effect.succeed(false)
535 }),
536 TestClock.withLive
537 )
538
539 assert.isTrue(completed)
540 }))
541 })
542 })
543
544 describe("pipeline spawning", () => {
545 it.effect("should spawn a simple pipeline", () =>
546 Effect.gen(function*() {
547 const handle = yield* ChildProcess.make`echo hello world`.pipe(
548 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`)
549 )
550 const output = yield* decodeByteStream(handle.stdout)
551 const exitCode = yield* handle.exitCode
552
553 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
554 assert.strictEqual(output, "HELLO WORLD")
555 }))
556
557 it.effect("should spawn a three-stage pipeline", () =>
558 Effect.gen(function*() {
559 const handle = yield* ChildProcess.make`echo hello world`.pipe(
560 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`),
561 ChildProcess.pipeTo(ChildProcess.make("tr", [" ", "-"]))
562 )
563 const output = yield* decodeByteStream(handle.stdout)
564 const exitCode = yield* handle.exitCode
565
566 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
567 assert.strictEqual(output, "HELLO-WORLD")
568 }))
569
570 it.effect("should pipe grep output", () =>
571 Effect.gen(function*() {
572 const handle = yield* ChildProcess.make("echo", ["line1\nline2\nline3"]).pipe(
573 ChildProcess.pipeTo(ChildProcess.make`grep line2`)
574 )
575 const output = yield* decodeByteStream(handle.stdout)
576 const exitCode = yield* handle.exitCode
577
578 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
579 assert.strictEqual(output, "line2")
580 }))
581
582 it.effect("should handle mixed command forms in pipeline", () =>
583 Effect.gen(function*() {
584 const handle = yield* ChildProcess.make("echo", ["hello"]).pipe(
585 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`)
586 )
587 const output = yield* decodeByteStream(handle.stdout)
588 const exitCode = yield* handle.exitCode
589
590 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
591 assert.strictEqual(output, "HELLO")
592 }))
593 })
594
595 describe("pipeline pipe options", () => {
596 it.effect("should pipe stderr to stdin with { from: 'stderr' }", () =>
597 Effect.gen(function*() {
598 // Command that writes "error" to stderr
599 const handle = yield* ChildProcess.make("sh", ["-c", "echo error >&2"]).pipe(
600 ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "stderr" })
601 )
602 const output = yield* decodeByteStream(handle.stdout)
603 const exitCode = yield* handle.exitCode
604
605 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
606 assert.strictEqual(output, "error")
607 }))
608
609 it.effect("should pipe combined output with { from: 'all' }", () =>
610 Effect.gen(function*() {
611 // Command that writes to both stdout and stderr with small delays
612 const handle = yield* ChildProcess.make("sh", [
613 "-c",
614 "echo out1; sleep 0.01; echo err1 >&2; sleep 0.01; echo out2"
615 ]).pipe(
616 ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "all" })
617 )
618 const output = yield* decodeByteStream(handle.stdout)
619 const exitCode = yield* handle.exitCode
620
621 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
622 const lines = output.split("\n")
623 assert.strictEqual(lines.length, 3)
624 assert.deepStrictEqual(lines.filter((line) => line.startsWith("out")), ["out1", "out2"])
625 assert.deepStrictEqual(lines.filter((line) => line.startsWith("err")), ["err1"])
626 }))
627
628 it.effect("should default to stdout when no options provided", () =>
629 Effect.gen(function*() {
630 // Command that writes to both stdout and stderr
631 const handle = yield* ChildProcess.make("sh", ["-c", "echo stdout; echo stderr >&2"]).pipe(
632 ChildProcess.pipeTo(ChildProcess.make`cat`)
633 )
634 const output = yield* decodeByteStream(handle.stdout)
635 const exitCode = yield* handle.exitCode
636
637 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
638 // Only stdout should be piped (default behavior)
639 assert.strictEqual(output, "stdout")
640 }))
641
642 it.effect("should work with empty options object", () =>
643 Effect.gen(function*() {
644 const handle = yield* ChildProcess.make`echo hello`.pipe(
645 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, {})
646 )
647 const output = yield* decodeByteStream(handle.stdout)
648 const exitCode = yield* handle.exitCode
649
650 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
651 assert.strictEqual(output, "HELLO")
652 }))
653
654 it.effect("should work with explicit { from: 'stdout' }", () =>
655 Effect.gen(function*() {
656 const handle = yield* ChildProcess.make`echo hello`.pipe(
657 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, { from: "stdout" })
658 )
659 const output = yield* decodeByteStream(handle.stdout)
660 const exitCode = yield* handle.exitCode
661
662 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
663 assert.strictEqual(output, "HELLO")
664 }))
665
666 it.effect("should work with explicit { to: 'stdin' }", () =>
667 Effect.gen(function*() {
668 const handle = yield* ChildProcess.make`echo hello`.pipe(
669 ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`, { to: "stdin" })
670 )
671 const output = yield* decodeByteStream(handle.stdout)
672 const exitCode = yield* handle.exitCode
673
674 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
675 assert.strictEqual(output, "HELLO")
676 }))
677
678 it.effect("should support chained pipes with different options", () =>
679 Effect.gen(function*() {
680 // First pipe: stdout to stdin (default)
681 // Second pipe: from stderr
682 const handle = yield* ChildProcess.make`echo hello`.pipe(
683 ChildProcess.pipeTo(ChildProcess.make("sh", ["-c", "cat; echo error >&2"])),
684 ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "stderr" })
685 )
686 const output = yield* decodeByteStream(handle.stdout)
687 const exitCode = yield* handle.exitCode
688
689 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
690 assert.strictEqual(output, "error")
691 }))
692 })
693
694 describe("error handling", () => {
695 it.effect("should return non-zero exit code", () =>
696 Effect.gen(function*() {
697 const handle = yield* ChildProcess.make("sh", ["-c", "exit 1"])
698 const exitCode = yield* handle.exitCode
699
700 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1))
701 }))
702
703 it.effect("should fail for invalid command", () =>
704 Effect.gen(function*() {
705 const exit = yield* Effect.exit(
706 ChildProcess.make("nonexistent-command-12345")
707 )
708
709 assert.isTrue(exit._tag === "Failure")
710 }))
711
712 it.effect("should handle spawn error with invalid cwd", () =>
713 Effect.gen(function*() {
714 const exit = yield* Effect.exit(
715 ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })
716 )
717
718 assert.isTrue(exit._tag === "Failure")
719 }))
720
721 it.effect("should throw permission denied as a typed error", () =>
722 Effect.gen(function*() {
723 const path = yield* Path.Path
724 const cwd = yield* path.fromFileUrl(new URL("./fixtures/bash/", import.meta.url))
725
726 const command = ChildProcess.make({ cwd })`./no-permissions.sh`
727 const result = yield* Effect.flip(command)
728
729 assert.strictEqual(result.reason._tag, "PermissionDenied")
730 assert.strictEqual(result.reason.module, "ChildProcess")
731 assert.strictEqual(result.reason.method, "spawn")
732 }))
733 })
734
735 describe("stdin", () => {
736 it.effect("allows providing standard input to a command", () =>
737 Effect.gen(function*() {
738 const input = "a b c"
739 const stdin = Stream.make(new TextEncoder().encode(input))
740 const handle = yield* ChildProcess.make("cat", { stdin })
741 const output = yield* decodeByteStream(handle.stdout)
742 const exitCode = yield* handle.exitCode
743
744 assert.deepStrictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
745 assert.strictEqual(output, input)
746 }))
747 })
748
749 describe.skipIf(options.additionalFds === false)("additionalFds", () => {
750 it.effect("should read data from an output fd (fd3)", () =>
751 Effect.gen(function*() {
752 // Use a shell script that writes to fd3
753 // The script echoes "hello from fd3" to file descriptor 3
754 const handle = yield* ChildProcess.make("sh", ["-c", "echo 'hello from fd3' >&3"], {
755 additionalFds: { fd3: { type: "output" } }
756 })
757
758 const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
759 const exitCode = yield* handle.exitCode
760
761 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
762 assert.strictEqual(fd3Output, "hello from fd3")
763 }))
764
765 it.effect("should write data to an input fd (fd3)", () =>
766 Effect.gen(function*() {
767 // Use a shell script that reads from fd3 and echoes it to stdout
768 // The script reads from file descriptor 3 and outputs to stdout
769 const inputData = "data from parent"
770 const inputStream = Stream.make(new TextEncoder().encode(inputData))
771
772 const handle = yield* ChildProcess.make("sh", ["-c", "cat <&3"], {
773 additionalFds: {
774 fd3: { type: "input", stream: inputStream }
775 }
776 })
777
778 const stdout = yield* decodeByteStream(handle.stdout)
779 const exitCode = yield* handle.exitCode
780
781 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
782 assert.strictEqual(stdout, inputData)
783 }))
784
785 it.effect("should handle multiple additional fds", () =>
786 Effect.gen(function*() {
787 // Script that writes different messages to fd3 and fd4
788 const handle = yield* ChildProcess.make(
789 "sh",
790 ["-c", "echo 'output on fd3' >&3; echo 'output on fd4' >&4"],
791 {
792 additionalFds: {
793 fd3: { type: "output" },
794 fd4: { type: "output" }
795 }
796 }
797 )
798
799 const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
800 const fd4Output = yield* decodeByteStream(handle.getOutputFd(4))
801 const exitCode = yield* handle.exitCode
802
803 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
804 assert.strictEqual(fd3Output, "output on fd3")
805 assert.strictEqual(fd4Output, "output on fd4")
806 }))
807
808 it.effect("should handle fd gaps (e.g., fd3 and fd5 without fd4)", () =>
809 Effect.gen(function*() {
810 // Script that writes to fd3 and fd5, skipping fd4
811 const handle = yield* ChildProcess.make(
812 "sh",
813 ["-c", "echo 'on fd3' >&3; echo 'on fd5' >&5"],
814 {
815 additionalFds: {
816 fd3: { type: "output" },
817 fd5: { type: "output" }
818 }
819 }
820 )
821
822 const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
823 const fd5Output = yield* decodeByteStream(handle.getOutputFd(5))
824 const exitCode = yield* handle.exitCode
825
826 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
827 assert.strictEqual(fd3Output, "on fd3")
828 assert.strictEqual(fd5Output, "on fd5")
829 }))
830
831 it.effect("should return empty stream for unconfigured output fd", () =>
832 Effect.gen(function*() {
833 const handle = yield* ChildProcess.make("echo", ["test"])
834
835 // fd3 was not configured, should return empty stream
836 const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
837 const exitCode = yield* handle.exitCode
838
839 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
840 assert.strictEqual(fd3Output, "")
841 }))
842
843 it.effect("should handle bidirectional communication via separate fds", () =>
844 Effect.gen(function*() {
845 // Script that reads from fd3, transforms it, and writes to fd4
846 const inputData = "hello"
847 const inputStream = Stream.make(new TextEncoder().encode(inputData))
848
849 const handle = yield* ChildProcess.make(
850 "sh",
851 ["-c", "cat <&3 | tr a-z A-Z >&4"],
852 {
853 additionalFds: {
854 fd3: { type: "input", stream: inputStream },
855 fd4: { type: "output" }
856 }
857 }
858 )
859
860 const fd4Output = yield* decodeByteStream(handle.getOutputFd(4))
861 const exitCode = yield* handle.exitCode
862
863 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
864 assert.strictEqual(fd4Output, "HELLO")
865 }))
866
867 it.effect("should work alongside normal stdin/stdout/stderr", () =>
868 Effect.gen(function*() {
869 // Script that uses all standard streams plus fd3
870 const handle = yield* ChildProcess.make(
871 "sh",
872 ["-c", "echo 'stdout'; echo 'stderr' >&2; echo 'fd3' >&3"],
873 { additionalFds: { fd3: { type: "output" } } }
874 )
875
876 const [stdout, stderr, fd3Output] = yield* Effect.all([
877 decodeByteStream(handle.stdout),
878 decodeByteStream(handle.stderr),
879 decodeByteStream(handle.getOutputFd(3))
880 ], { concurrency: "unbounded" })
881 const exitCode = yield* handle.exitCode
882
883 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
884 assert.strictEqual(stdout, "stdout")
885 assert.strictEqual(stderr, "stderr")
886 assert.strictEqual(fd3Output, "fd3")
887 }))
888 })
889
890 describe("process supervision", { concurrent: false }, () => {
891 const countMatchingProcesses = (pattern: string) =>
892 Effect.gen(function*() {
893 const handle = yield* ChildProcess.make("bash", [
894 "-c",
895 `ps aux | grep '${pattern}' | grep -v grep | wc -l`
896 ])
897 const output = yield* decodeByteStream(handle.stdout)
898 return Number.parseInt(output.trim())
899 }).pipe(Effect.orElseSucceed(() => 0))
900
901 const killMatchingProcesses = (pattern: string) => {
902 if (!/^[A-Za-z0-9_-]+$/.test(pattern)) {
903 return Effect.die(new Error(`Invalid process pattern: ${pattern}`))
904 }
905 return Effect.gen(function*() {
906 const escaped = `[${pattern[0]}]${pattern.slice(1)}`
907 const handle = yield* ChildProcess.make("bash", ["-c", `pkill -f '${escaped}' || true`])
908 yield* Effect.ignore(handle.exitCode)
909 }).pipe(Effect.asVoid)
910 }
911
912 const longRunningCommand = () =>
913 ChildProcess.make("sh", ["-c", "sleep 30", "long-running-command"], {
914 stdin: "ignore",
915 stdout: "ignore",
916 stderr: "ignore"
917 })
918
919 it.effect.skipIf(!options.processGroups)("should kill all child processes in process group", () =>
920 Effect.gen(function*() {
921 const path = yield* Path.Path
922 const cwd = yield* path.fromFileUrl(new URL("./fixtures/bash/", import.meta.url))
923
924 // Start the process that spawns children and grandchildren
925 const handle = yield* ChildProcess.make("./spawn-children.sh", { cwd })
926
927 const readyCount = yield* handle.stdout.pipe(
928 Stream.decodeText,
929 Stream.splitLines,
930 Stream.filter((line) =>
931 line.includes(" started with PID ")
932 ),
933 Stream.take(7),
934 Stream.runCount,
935 Effect.timeout("2 seconds"),
936 TestClock.withLive
937 )
938 assert.strictEqual(readyCount, 7, "all seven processes must report readiness before stdout closes")
939
940 // Verify the main process is running
941 const isRunningBeforeKill = yield* handle.isRunning
942 assert.isTrue(isRunningBeforeKill)
943
944 // Count processes before killing - should be at least 7 (1 parent + 3 children + 3 grandchildren)
945 const beforeKillHandle = yield* ChildProcess.make("bash", [
946 "-c",
947 "ps aux | grep spawn-children.sh | grep -v grep | wc -l"
948 ])
949 const beforeKill = yield* decodeByteStream(beforeKillHandle.stdout).pipe(
950 Effect.map((s) => Number.parseInt(s.trim())),
951 Effect.orElseSucceed(() => 0)
952 )
953 assert.isAtLeast(beforeKill, 7)
954
955 // Kill the main process
956 yield* handle.kill()
957
958 // Verify the main process is no longer running
959 const isRunningAfterKill = yield* handle.isRunning
960 assert.isFalse(isRunningAfterKill)
961
962 // Give a moment for cleanup to complete
963 yield* TestClock.withLive(Effect.sleep("100 millis"))
964
965 // Check that no processes from the script are still running
966 const afterKillHandle = yield* ChildProcess.make("bash", [
967 "-c",
968 "ps aux | grep spawn-children.sh | grep -v grep | wc -l"
969 ])
970 const afterKill = yield* decodeByteStream(afterKillHandle.stdout).pipe(
971 Effect.map((s) => Number.parseInt(s.trim())),
972 Effect.orElseSucceed(() => 0)
973 )
974 assert.strictEqual(afterKill, 0)
975 }))
976
977 it.effect.skipIf(!options.processGroups)(
978 "should cleanup child processes when parent exits with non-zero code",
979 () =>
980 Effect.gen(function*() {
981 const path = yield* Path.Path
982 const cwd = yield* path.fromFileUrl(new URL("./fixtures/bash/", import.meta.url))
983
984 // Count processes before running the command
985 const beforeRunHandle = yield* ChildProcess.make("bash", [
986 "-c",
987 "ps aux | grep parent-exits-early.sh | grep -v grep | wc -l"
988 ])
989 const beforeRun = yield* decodeByteStream(beforeRunHandle.stdout).pipe(
990 Effect.map((s) => Number.parseInt(s.trim())),
991 Effect.orElseSucceed(() => 0)
992 )
993 assert.strictEqual(beforeRun, 0)
994
995 // Run command in a separate scope so cleanup happens before we check
996 const exitCode = yield* Effect.scoped(Effect.gen(function*() {
997 const handle = yield* ChildProcess.make({ cwd })`./parent-exits-early.sh`
998 return yield* handle.exitCode
999 }))
1000
1001 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1))
1002
1003 // Allow cleanup to occur
1004 yield* TestClock.withLive(Effect.sleep("100 millis"))
1005
1006 const afterExitHandle = yield* ChildProcess.make("bash", [
1007 "-c",
1008 "ps aux | grep 'parent-exits-early-' | grep -v grep | wc -l"
1009 ])
1010 const afterExit = yield* decodeByteStream(afterExitHandle.stdout).pipe(
1011 Effect.map((s) => Number.parseInt(s.trim())),
1012 Effect.orElseSucceed(() => 0)
1013 )
1014 // Child processes should be cleaned up after non-zero exit
1015 assert.strictEqual(afterExit, 0)
1016 })
1017 )
1018
1019 it.effect("should not kill an unrefed process when scope closes", () =>
1020 Effect.gen(function*() {
1021 const scope = yield* Scope.make()
1022 const handle = yield* Scope.provide(scope)(Effect.gen(function*() {
1023 return yield* longRunningCommand()
1024 })).pipe(
1025 Effect.provide(layer)
1026 )
1027
1028 yield* Effect.gen(function*() {
1029 // @effect-diagnostics-next-line floatingEffect:off
1030 yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(layer))
1031 yield* Scope.close(scope, Exit.void)
1032 yield* TestClock.withLive(Effect.sleep("100 millis"))
1033
1034 const isRunning = yield* handle.isRunning
1035 assert.isTrue(isRunning)
1036 }).pipe(Effect.ensuring(Effect.ignore(handle.kill({ killSignal: "SIGKILL" }))))
1037 }).pipe(Effect.provide(layer)))
1038
1039 it.effect("should kill a restored process when scope closes", () =>
1040 Effect.gen(function*() {
1041 const scope = yield* Scope.make()
1042 const handle = yield* Scope.provide(scope)(Effect.gen(function*() {
1043 return yield* longRunningCommand()
1044 })).pipe(
1045 Effect.provide(layer)
1046 )
1047
1048 const reref = yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(layer))
1049 yield* reref
1050 yield* Scope.close(scope, Exit.void)
1051 yield* TestClock.withLive(Effect.sleep("100 millis"))
1052
1053 const isRunning = yield* handle.isRunning
1054 assert.isFalse(isRunning)
1055 }).pipe(Effect.provide(layer)))
1056
1057 it.effect("should resolve exitCode after closing the original scope of an unrefed process", () =>
1058 Effect.gen(function*() {
1059 const scope = yield* Scope.make()
1060 const handle = yield* Scope.provide(scope)(Effect.gen(function*() {
1061 return yield* ChildProcess.make("sh", ["-c", "sleep 0.05"], {
1062 stdin: "ignore",
1063 stdout: "ignore",
1064 stderr: "ignore"
1065 })
1066 })).pipe(Effect.provide(layer))
1067
1068 // @effect-diagnostics-next-line floatingEffect:off
1069 yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(layer))
1070 yield* Scope.close(scope, Exit.void)
1071
1072 const exitCode = yield* handle.exitCode
1073 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
1074 }).pipe(Effect.provide(layer)))
1075
1076 it.effect.skipIf(!options.processGroups)(
1077 "should cleanup descendants after an unrefed parent exits non-zero",
1078 () =>
1079 Effect.gen(function*() {
1080 const path = yield* Path.Path
1081 const cwd = yield* path.fromFileUrl(new URL("./fixtures/bash/", import.meta.url))
1082 const scope = yield* Scope.make()
1083
1084 const handle = yield* Scope.provide(scope)(Effect.gen(function*() {
1085 return yield* ChildProcess.make({ cwd })`./parent-exits-early.sh`
1086 })).pipe(Effect.provide(layer))
1087
1088 // @effect-diagnostics-next-line floatingEffect:off
1089 yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(layer))
1090 yield* Scope.close(scope, Exit.void)
1091
1092 const exitCode = yield* handle.exitCode
1093 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(1))
1094
1095 yield* TestClock.withLive(Effect.sleep("100 millis"))
1096
1097 const remaining = yield* countMatchingProcesses("parent-exits-early-")
1098 assert.strictEqual(remaining, 0)
1099 }).pipe(Effect.provide(layer))
1100 )
1101
1102 it.effect("should unref every process in a pipeline", () =>
1103 Effect.gen(function*() {
1104 const scope = yield* Scope.make()
1105 const rootMarker = "pipeline-unref-root"
1106 const tailMarker = "pipeline-unref-tail"
1107
1108 const handle = yield* Scope.provide(scope)(Effect.gen(function*() {
1109 return yield* ChildProcess.make("sh", ["-c", "sleep 30; :", rootMarker], {
1110 stdin: "ignore",
1111 stdout: "pipe",
1112 stderr: "ignore"
1113 }).pipe(
1114 ChildProcess.pipeTo(
1115 ChildProcess.make("sh", ["-c", "sleep 30; :", tailMarker], {
1116 stdin: "pipe",
1117 stdout: "ignore",
1118 stderr: "ignore"
1119 })
1120 )
1121 )
1122 })).pipe(Effect.provide(layer))
1123
1124 yield* Effect.gen(function*() {
1125 // @effect-diagnostics-next-line floatingEffect:off
1126 yield* Scope.provide(scope)(handle.unref).pipe(Effect.provide(layer))
1127 yield* Scope.close(scope, Exit.void)
1128 yield* TestClock.withLive(Effect.sleep("100 millis"))
1129
1130 const rootCount = yield* countMatchingProcesses(rootMarker)
1131 const tailCount = yield* countMatchingProcesses(tailMarker)
1132 assert.strictEqual(rootCount, 1)
1133 assert.strictEqual(tailCount, 1)
1134 }).pipe(
1135 Effect.ensuring(Effect.ignore(Effect.all([
1136 killMatchingProcesses(rootMarker),
1137 killMatchingProcesses(tailMarker)
1138 ], { discard: true })))
1139 )
1140 }).pipe(Effect.provide(layer)))
1141 })
1142
1143 it.effect("should not deadlock on large stdout output", () =>
1144 Effect.gen(function*() {
1145 // Generate ~5MB of output — enough to exceed the default PassThrough
1146 // highWaterMark (16KB) many times over. Without the fix, the unread
1147 // combinedPassThrough (.all) would exert backpressure on the source
1148 // stream, blocking stdout too.
1149 const handle = yield* ChildProcess.make("sh", ["-c", "seq 1 100000"])
1150 const output = yield* handle.stdout.pipe(
1151 Stream.decodeText(),
1152 Stream.runFold(() => "", (acc, chunk) => acc + chunk)
1153 )
1154 const exitCode = yield* handle.exitCode
1155
1156 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
1157 const lines = output.trim().split("\n")
1158 assert.strictEqual(lines.length, 100000)
1159 assert.strictEqual(lines[0], "1")
1160 assert.strictEqual(lines[99999], "100000")
1161 }), { timeout: 10_000 })
1162
1163 it.effect("ChildProcess.string should not deadlock on large output", () =>
1164 Effect.gen(function*() {
1165 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
1166 const output = yield* spawner.string(ChildProcess.make("sh", ["-c", "seq 1 100000"]))
1167 const lines = output.trim().split("\n")
1168 assert.strictEqual(lines.length, 100000)
1169 assert.strictEqual(lines[0], "1")
1170 assert.strictEqual(lines[99999], "100000")
1171 }), { timeout: 10_000 })
1172 })
1173 })