std.process
import std.process starts child processes from an argument vector and owns
them until they are reaped. Arguments, variables, and directories are
operating-system byte strings; pipes carry std.bytes data.
No shell takes part unless the program is one. An argument reaches the child
exactly as given: spaces, quotes, $, ;, |, *, and newlines are ordinary
bytes. shell is the one, named way to run shell
syntax. std.shlex can split a shell-style argument string before
passing each resulting word to arg; it does not execute or expand shell syntax.
Request encoding, pipe draining, status decoding, and the reaping rules are
checked Plenty. Five native primitives spawn, hand over a pipe end, wait,
signal, and poll. The module is available on the supported x86-64 Linux GNU
target; elsewhere spawn reports IoError.Unsupported.
import std.process
from std.bytes import BytesError
from std.io import StreamError
from std.process import Stdio
def main() -> Result[(), AllocError | IoError | StreamError | BytesError]:
mut echo = std.process.command("echo")?
echo.arg("hello from a child")?
echo.stdout(Stdio.Piped)
mut child = echo.spawn()?
output = child.output(4096)?
print(output.status.success(), output.stdout.to_text()?.strip()?)?
Ok(())
True hello from a child
Building a command
| Function | Result | Contract |
|---|---|---|
command(program: &str) | Result[Command, IoError] | A command with no arguments |
command_bytes(program: &list[u8]) | Result[Command, IoError] | The same for a name that is not UTF-8 |
shell(script: &str) | Result[Command, IoError] | /bin/sh -c script |
A program name containing / is a path. Any other name is looked up in the
directories of the child’s PATH. The name is also the child’s argv[0].
shell passes script to /bin/sh as one argument. The script is shell
syntax, so text placed in it is interpreted: never build one from untrusted
input. Pass such data as an argument or a variable instead.
Command methods take self: &mut Command:
| Method | Result | Effect |
|---|---|---|
arg(value: &str) | Result[(), IoError] | Append one argument |
arg_bytes(value: &list[u8]) | Result[(), IoError] | Append one argument of any bytes |
arg_path(value: &Path) | Result[(), IoError] | Append a path losslessly |
env(name: &str, value: &str) | Result[(), IoError] | Set a variable for the child |
env_bytes(name: &list[u8], value: &list[u8]) | Result[(), IoError] | The same for any bytes |
env_remove(name: &str), env_remove_bytes(name: &list[u8]) | Result[(), IoError] | The child does not inherit it |
env_clear() | () | The child inherits nothing; earlier edits are dropped |
current_dir(path: &Path) | Result[(), IoError] | The child’s working directory |
stdin(mode: Stdio), stdout(mode: Stdio), stderr(mode: Stdio) | () | Choose a stream |
- A child inherits the parent’s environment and working directory unless the command says otherwise. Edits apply in order, so the last one for a name wins. Nothing changes in the parent.
- A piece containing a NUL byte, an empty program or directory, and a variable
name that is empty or contains
=areIoError.InvalidInput. A failed method leaves the command exactly as it was. - An empty argument is valid and is passed as an empty argument.
- Allocation failure is
IoError.Data(DataError.Allocation(...)). Unrepresentable argument, environment and combined request sizes preserveAllocError.CapacityOverflowin that cause, before mutation or starting a child. - Appending a terminated byte string reserves only its new bytes and NUL, not bytes already held. Existing capacity can satisfy the operation without an allocation; failed growth leaves the original command unchanged.
Stdio selects each standard stream:
| Mode | The child’s stream |
|---|---|
Stdio.Inherit | The parent’s own stream. The default |
Stdio.Null | /dev/null |
Stdio.Piped | A new pipe whose other end the Child owns |
Spawning
command.spawn() borrows the command and returns Result[Child, IoError]. A
command is reusable: each call starts another child.
A program that cannot be started is an error value, not a child that exits
with a conventional code: a missing program or directory is
IoError.System(2), a file without execute permission IoError.System(13).
A failed spawn leaves no process and no open pipe.
Pipe ends are close-on-exec, so one child never inherits another child’s pipes. Other descriptors the program opened are close-on-exec as well.
Close-on-exec still leaves a short window: a child forked by one thread holds
a copy of every descriptor open at that moment until its exec closes them. If
another thread had a program open for writing then, running that program just
after closing it fails with “text file busy” (IoError.System(26)). spawn
retries that error after waits of 1, 2, 4, 8, 16, 32 and 64 ms, then reports
it, so a busy program delays spawn by at most 127 ms. Spawns in one program
take turns, and a retrying spawn keeps its turn while it waits, so no new child
can pick up a descriptor meanwhile; other threads’ child operations other than
a blocking wait wait with it. spawn takes no deadline or cancellation
token. Waiting allocates nothing; each attempt repeats the brief host
allocation described under Limits. A program another process keeps
open for writing still fails after the last wait.
Child
A Child is the single owner of one process. It is not copyable.
| Method | Result | Contract |
|---|---|---|
id() | i64 | The process id |
take_stdin(), take_stdout(), take_stderr() | Option[File] | Move a pipe end out, once |
close_stdin() | Result[(), IoError] | End the child’s input |
wait() | Result[ExitStatus, IoError] | Close stdin, block until the child ends, reap it |
try_wait() | Result[Option[ExitStatus], IoError] | Reap if ended; Nothing while running |
wait_timeout(budget: &Duration, cancel: &CancellationToken) | Result[Option[ExitStatus], WaitError] | Nothing when the budget ran out |
signal(number: i32) | Result[(), IoError] | Send signal 1 to 64 |
terminate() | Result[(), IoError] | SIGTERM: a request the child may ignore |
kill() | Result[(), IoError] | SIGKILL: cannot be caught |
communicate(input: &[u8], limit: NonNegative[i64]) | Result[Output, StreamError] | Feed stdin, collect both outputs, wait |
output(limit: NonNegative[i64]) | Result[Output, StreamError] | communicate with no input |
Methods that reap or move a pipe take self: &mut Child; id and the signal
methods borrow.
Pipes
A taken pipe end is an ordinary File: stdin
is write-only, stdout and stderr are read-only, and none can seek. Use
Bytes.read_file and Bytes.write_file directly, or wrap the end
with std.io.from_file for the retry helpers and buffering.
- A read returns the bytes that have arrived, possibly fewer than asked, and 0 at end of input, which is when the child and every process that inherited the stream have closed it.
- A write to a child that no longer reads is
IoError.System(32)(EPIPE). The program is not sentSIGPIPEfor it. - The child sees end of input only when stdin is closed.
waitandcommunicateclose a stdin theChildstill holds; a taken end is the caller’s to close.
Reading one pipe to the end while the child blocks writing another, or writing
a large input while the child blocks writing output, is a deadlock: a pipe
holds 64 KiB. Use communicate, which serves all three in one loop.
Waiting and status
ExitStatus describes how the child ended:
| Method | Result | Contract |
|---|---|---|
success() | bool | Exit code 0 |
code() | Option[i32] | The exit code, 0 to 255; Nothing if a signal ended it |
signal() | Option[i32] | The terminating signal; Nothing if it exited |
- The first successful
wait,try_wait, orwait_timeoutthat sees the child ended reaps it. The status is kept, and later calls return it without asking the operating system. waitblocks without a limit. It does not drain pipes: a child blocked writing to a full pipe that nobody reads never ends.wait_before(deadline: &Deadline, cancel: &CancellationToken)pollstry_waitwith pauses of at most 10 ms.wait_timeoutconstructs one total deadline from a duration and delegates to it. Both leave stdin open and the child running when cancelled or timed out.WaitError.Cancelledonly ends this wait; it never sends a signal.WaitError.Iopreserves operating-system errors; an overflowing budget isWaitError.Io(IoError.InvalidInput).- Signals reach only a child that has not been reaped. Afterwards the methods
return
Ok(())and send nothing, so a number can never reach an unrelated process that received the same id. A number outside 1 to 64 isIoError.InvalidInput. - A signalled child still has to be waited for.
Collecting output
communicate writes input to a piped stdin, appends piped stdout and stderr
to two new buffers as data arrives, closes each stream at its end, then waits.
Output has public fields status: ExitStatus, stdout: Bytes, and
stderr: Bytes.
- Streams that are not piped, or whose end was taken, are skipped and their buffers stay empty.
limitbounds each output separately. A stream that produces more isStreamError.OutOfRange; at mostlimit + 1of its bytes were buffered. The child is then still running and still owned: kill or wait for it.- A child that stops reading ends the feed without an error. Its status says what happened.
- Interrupted calls are retried. Other failures are the
StreamErrorof the failing transfer. - Memory is the two outputs, grown in 8 KiB steps. Input is written in 4 KiB
pieces and is not copied. A next-buffer extent that cannot fit
i64returnsStreamError.Allocation(AllocError.CapacityOverflow)before growing or reading that chunk, including when the extra limit-probe byte is unrepresentable.
Readiness flags and process identifiers use bit masks. Argument counts, status subtraction and pipe cursors use explicit arithmetic opt-outs justified by successful storage growth, validated statuses and concrete file progress. Caller arithmetic modes do not change these boundaries.
Ownership and reaping
Plenty has no user destructors, so dropping a Child closes the pipe ends it
still holds and does nothing else: the process is neither stopped nor reaped.
Its runtime record, and its zombie once it ends, last until the program exits.
A build with runtime allocation checks reports that record as a leak. Every
child should end in a wait, try_wait, or wait_timeout that returns a
status.
A child that outlives the program is adopted by the system as usual.
Limits
- Linux x86-64 GNU only. Starting a child uses the host allocator briefly for its argument vector; that one allocation is outside fallible accounting.
- No redirection of a stream to a file or to another child, and no merging of
stderr into stdout.
shellcan do both. - Timed child waits poll; scheduling and lock contention can delay return beyond the 10 ms polling interval.
- No process groups, sessions, user or group changes, resource limits, or detached children.
communicateholds whole outputs in memory. For streaming, take the pipes and give each its own thread.
Acknowledgements
- API modelled on Rust’s
std::process::Commandand Python’ssubprocess, whosecommunicateit follows.