Module process/command
High-level child-process spawning with builder-style configuration.
Wraps std/sys/process with a fluent API for constructing argv, capturing
stdout/stderr through pipes, and waiting for the child to exit.
Example
{ Command } :: import "std/process/command";
{ Exception, AnyError } :: import "std/error";
main :: (fn(io : Io, exn : Exception) -> unit)({
cmd := Command.new(`echo`);
cmd.arg(`hello`);
cmd.arg(`world`);
out := io.await(cmd.output(io), { io, exn });
assert(out.status.success(), "echo should succeed");
});
Streaming a child's pipes
Command.spawn returns a Child; take_stdin/take_stdout/take_stderr
hand you the parent ends as Writer/Reader handles, so all of
std/io's surface — write_all, read_to_string, read_exact,
BufReader.lines — applies to a child's streams:
{ Command, Stdio } :: import("std/process/command");
{ BufReader } :: import("std/io/buffered");
cmd := Command.new(`sort`);
cmd.stdin(Stdio.Piped);
cmd.stdout(Stdio.Piped);
child := io.await(cmd.spawn(io), ie);
match(child.take_stdin(), .Some(w) => {
io.await(w.write_string(`b\na\n`, io), ie);
io.await(w.close(io), ie); // EOF — or just drop `w`
}, .None => ());
text := match(
child.take_stdout(),
.Some(r) => io.await(r.read_to_string(io), ie),
.None => String.new()
);
io.await(child.wait(io), ie);
Taking a stream MOVES its fd out of the Child, so wait() will not close
it and the handle's own close (or its drop) does. Read a piped stdout
before wait() when the child may produce more than a pipe buffer holds,
or the child blocks writing while you block waiting.
Stability
unstable — there are two ways into a child's pipes and one of them has to
go. The typed handles (take_stdin/take_stdout/take_stderr) landed
2026-09-10 and are the ones that put a child's streams inside std/io's
surface; the three bespoke methods they replace (write_stdin,
read_stdout_to_end, read_stderr_to_end) are still exported beside
them, so the same fd is reachable through two vocabularies with different
ownership rules. Removing the older pair is breaking, so it has not
happened yet, and freezing now would freeze both.
The manual path is also missing the piece that makes it safe. Draining
stdout to EOF and then stderr deadlocks on any child that fills the 64 KiB
stderr pipe first, which is why output() spawns a task per pipe
(issues/fixed/command-output-drains-stdout-then-stderr-sequentially.md).
A caller who took the handles has to rebuild that concurrency by hand —
Rust offers Child::wait_with_output for exactly this, and Yo's
equivalent is only reachable if you did NOT take the handles. That helper
is additive; the duplicate stream API is not. Freezing follows the removal
of the bespoke trio, not a release count.
Settled and not blocking: kill throws through Exception rather than
returning an errno (D1, changed 2026-09-06), and ExitStatus/Stdio/the
Command builder have been stable across the campaign. kill does take a
raw signal NUMBER where Rust's takes none — deliberate, since the whole
POSIX signal set is reachable and Windows has no equivalent to narrow it
to.
Types
What to do with one of the child's standard streams
(plans/archive/STD_API_AUDIT.md §7 P0 item 5).
Variants
| Variant | Fields | Description |
|---|---|---|
Inherit | Share the parent's stream (the default). | |
Piped | Connect a pipe; the parent end is on the returned | |
Null | Redirect to /dev/null. |
Builder for a child-process invocation.
Construct with Command.new(program), then chain builder methods
(arg, args, env, env_clear, stdin, stdout, stderr — each
returns Self, and Command is a ref type, so chaining and
statement-style calls both work) before status() / output() /
spawn().
Fields
| Name | Type | Description |
|---|---|---|
_program | String | |
_args | ArrayList(String) | |
_env_overrides | ArrayList(String) | |
_env_clear | bool | |
_stdin | Stdio | |
_stdout | Stdio | |
_stderr | Stdio | |
_cwd | Option(String) |
impl(Command, ...)
new : (Command) fn(program : String) -> Commandarg : (Command) fn(self : Command, a : String) -> Commandargs : (Command) fn(self : Command, more : ArrayList(String)) -> Commandenv : (Command) fn(self : Command, key : String, value : String) -> Commandcurrent_dir : (Command) fn(self : Command, dir : String) -> Commandenv_clear : (Command) fn(self : Command) -> CommandStop the child inheriting this process's environment, so it starts
from an EMPTY one plus whatever env() supplied — Rust's
Command::env_clear.
It does not clear the env() entries already recorded, and Rust's
does. This sets a flag that is consulted at spawn time and only
suppresses the walk over environ; the overrides are collected
separately and always survive. So cmd.env(A, 1).env_clear()
runs the child with A=1 here, and with nothing at all in Rust —
call order makes no difference in either direction.
With neither this nor any env() call, no envp is constructed at
all and the child inherits by the platform's own mechanism.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Command |
Returns: Command
stdin : (Command) fn(self : Command, mode : Stdio) -> Commandstdout : (Command) fn(self : Command, mode : Stdio) -> Commandimpl(Command, ...)
status : (Command) fn(self : Command, io : Io) -> Impl : (Future[Future](ExitStatus) IoExn : IoExn)Spawn the child with stdio inherited from the parent, wait for it to
exit, and return its ExitStatus.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Command | |
io | Io |
Returns: Impl : (Future[Future](ExitStatus) IoExn : IoExn)
output : (Command) fn(self : Command, io : Io) -> Impl : (Future[Future](Output) IoExn : IoExn)impl(Command, ...)
spawn : (Command) fn(self : Command, io : Io) -> Impl : (Future[Future](Child) IoExn : IoExn)The result of a finished child process.
Holds the raw waitpid status code along with helpers to extract the exit code and termination signal.
Fields
| Name | Type | Description |
|---|---|---|
raw | i32 | Encoded waitpid status. Use |
impl(ExitStatus, ...)
code : (ExitStatus) fn(self : ExitStatus) -> Option(i32)The process exit code (0..255 on Unix), or .None when the process
was TERMINATED BY A SIGNAL — a signal death is not an exit code of 0
(plans/archive/STD_API_AUDIT.md §4 process row).
Parameters
| Name | Type | Notes |
|---|---|---|
self | ExitStatus |
Returns: Option(i32)
signal : (ExitStatus) fn(self : ExitStatus) -> i32Returns the signal number that terminated the process, or 0 if the process exited normally.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ExitStatus |
Returns: i32
success : (ExitStatus) fn(self : ExitStatus) -> boolReturns true if the child exited with status code 0.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ExitStatus |
Returns: bool
Captured output of a child process.
Returned by Command.output. Holds the exit status plus the bytes captured
from the child's stdout and stderr.
Fields
| Name | Type | Description |
|---|---|---|
status | ExitStatus | Spawn the child with stdio inherited from the parent, wait for it to
exit, and return its |
stdout | ArrayList(u8) | Configure the child's stdout. |
stderr | ArrayList(u8) | Configure the child's stderr. |
A running child process, returned by Command.spawn. Pipe fds exist for
the streams configured Stdio.Piped; wait() reaps the child.
Fields
| Name | Type | Description |
|---|---|---|
_pid | i32 | |
_stdin_fd | Option(i32) | |
_stdout_fd | Option(i32) | |
_stderr_fd | Option(i32) | |
_reaped | bool |
impl(Child, ...)
pid : (Child) fn(self : Child) -> i32take_stdin : (Child) fn(self : Child) -> Option(ChildStdin)Take the parent's write end of the child's stdin pipe as a Writer.
.None if stdin was not Stdio.Piped, or if it has already been taken
or closed.
Taking MOVES the fd out of the Child: wait() no longer closes it, the
returned handle owns it, and write_stdin/close_stdin on this Child
throw BadFileDescriptor from then on. Dropping the handle closes the
pipe, which is the EOF the child is usually waiting for.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Child |
Returns: Option(ChildStdin)
take_stdout : (Child) fn(self : Child) -> Option(ChildStdout)Take the parent's read end of the child's stdout pipe as a Reader, so
read_to_string, read_exact and BufReader apply. .None if stdout
was not Stdio.Piped, or if it has already been taken.
Taking MOVES the fd out of the Child, exactly as take_stdin does.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Child |
Returns: Option(ChildStdout)
take_stderr : (Child) fn(self : Child) -> Option(ChildStderr)Take the parent's read end of the child's stderr pipe as a Reader.
.None if stderr was not Stdio.Piped, or if it has already been taken.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Child |
Returns: Option(ChildStderr)
write_stdin : (Child) fn(self : Child, data : ArrayList(u8), io : Io) -> Impl : (Future[Future](usize) IoExn : IoExn)close_stdin : (Child) fn(self : Child, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)read_stdout_to_end : (Child) fn(self : Child, io : Io) -> Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)read_stderr_to_end : (Child) fn(self : Child, io : Io) -> Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)kill : (Child) fn(self : Child, signum : i32, exn : Exception) -> unitSend a signal to the child (e.g. 15 = SIGTERM, 9 = SIGKILL). A failure —
ESRCH once the child has been reaped, EPERM — is thrown as an
IoError through exn, Rust's Child::kill -> io::Result<()>. Until
2026-09-06 this handed back the raw -errno as an i32
(issues/fixed/child-kill-returned-a-raw-errno.md).
Parameters
| Name | Type | Notes |
|---|---|---|
self | Child | |
signum | i32 | |
exn | Exception |
Returns: unit
wait : (Child) fn(self : Child, io : Io) -> Impl : (Future[Future](ExitStatus) IoExn : IoExn)Wait for the child to exit and return its status. Closes any pipe fds still open on this handle.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Child | |
io | Io |
Returns: Impl : (Future[Future](ExitStatus) IoExn : IoExn)
The parent's write end of a child's stdin pipe — a Writer, so
write_all, write_string and the rest of std/io's writer surface work
on it. Take it off a Child with take_stdin(); closing it (explicitly,
or by dropping it) is the EOF the child is usually waiting for.
Fields
| Name | Type | Description |
|---|---|---|
_fd | i32 | |
_closed | bool |
Trait Implementations
impl(ChildStdin, ...)
fd : (ChildStdin) fn(self : ChildStdin) -> i32is_closed : (ChildStdin) fn(self : ChildStdin) -> boolwrite : (ChildStdin) fn(self : ChildStdin, buf : *(u8), size : usize, io : Io) -> Impl : (Future[Future](usize) IoExn : IoExn)Write up to size bytes. Returns the number written.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin | |
buf | *(u8) | |
size | usize | |
io | Io |
close : (ChildStdin) fn(self : ChildStdin, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)Close the read end. Safe to call multiple times. Sets the flag after the
close, for the reason ChildStdin.close records.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin | |
io | Io |
impl(ChildStdin, IoTraits)
impl(ChildStdin, ...)
write_string : (ChildStdin) fn(self : ChildStdin, data : String, io : Io) -> Impl : (Future[Future](usize) IoExn : IoExn)Write a String, in full. File.write_string spells the same
convenience.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin | |
data | String | |
io | Io |
impl(ChildStdin, Dispose(...))
dispose : (ChildStdin) fn(self : ChildStdin) -> unitRelease the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin |
Returns: unit
Methods
flush : (ChildStdin) fn(self : ChildStdin, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)A no-op that always succeeds: a pipe write goes straight to the
kernel, so there is nothing held here to push. Everything write
reported as written is already visible to the child, and flushing
is NOT what delivers EOF — close (or dropping the handle) is.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin | |
io | Io |
write_all : (ChildStdin) fn(self : ChildStdin, buf : *(u8), size : usize, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdin | |
buf | *(u8) | |
size | usize | |
io | Io |
The parent's read end of a child's stdout pipe — a Reader, so
read_to_end, read_to_string, read_exact and BufReader work on it.
Take it off a Child with take_stdout().
Fields
| Name | Type | Description |
|---|---|---|
_fd | i32 | |
_closed | bool |
Trait Implementations
impl(ChildStdout, ...)
fd : (ChildStdout) fn(self : ChildStdout) -> i32is_closed : (ChildStdout) fn(self : ChildStdout) -> boolread : (ChildStdout) fn(self : ChildStdout, buf : *(u8), size : usize, io : Io) -> Impl : (Future[Future](usize) IoExn : IoExn)Read up to size bytes. Returns the number read; 0 means EOF.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdout | |
buf | *(u8) | |
size | usize | |
io | Io |
close : (ChildStdout) fn(self : ChildStdout, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)Close the read end. Safe to call multiple times. Sets the flag after the
close, for the reason ChildStdin.close records.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdout | |
io | Io |
impl(ChildStdout, IoTraits)
impl(ChildStdout, Dispose(...))
dispose : (ChildStdout) fn(self : ChildStdout) -> unitRelease the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdout |
Returns: unit
Methods
read_to_end : (ChildStdout) fn(self : ChildStdout, io : Io) -> Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdout | |
io | Io |
Returns: Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)
read_to_string : (ChildStdout) fn(self : ChildStdout, io : Io) -> Impl : (Future[Future](String) IoExn : IoExn)Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStdout | |
io | Io |
The parent's read end of a child's stderr pipe. Structurally identical to
ChildStdout and distinct for the same reason Rust keeps them apart: the
type says which stream you are holding.
Fields
| Name | Type | Description |
|---|---|---|
_fd | i32 | |
_closed | bool |
Trait Implementations
impl(ChildStderr, ...)
fd : (ChildStderr) fn(self : ChildStderr) -> i32is_closed : (ChildStderr) fn(self : ChildStderr) -> boolread : (ChildStderr) fn(self : ChildStderr, buf : *(u8), size : usize, io : Io) -> Impl : (Future[Future](usize) IoExn : IoExn)Read up to size bytes. Returns the number read; 0 means EOF.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStderr | |
buf | *(u8) | |
size | usize | |
io | Io |
close : (ChildStderr) fn(self : ChildStderr, io : Io) -> Impl : (Future[Future](unit) IoExn : IoExn)Close the read end. Safe to call multiple times. Sets the flag after the
close, for the reason ChildStdin.close records.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStderr | |
io | Io |
impl(ChildStderr, IoTraits)
impl(ChildStderr, Dispose(...))
dispose : (ChildStderr) fn(self : ChildStderr) -> unitRelease the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStderr |
Returns: unit
Methods
read_to_end : (ChildStderr) fn(self : ChildStderr, io : Io) -> Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStderr | |
io | Io |
Returns: Impl : (Future[Future](ArrayList(u8)) IoExn : IoExn)
read_to_string : (ChildStderr) fn(self : ChildStderr, io : Io) -> Impl : (Future[Future](String) IoExn : IoExn)Parameters
| Name | Type | Notes |
|---|---|---|
self | ChildStderr | |
io | Io |