std::process Starts a Program and Waits for It, and the Wait Gives the Thread UpStatus: Accepted; D5-D8 (the child’s environment) added October 20, 2026 (#691); D9 (a deadline, within) added October 25, 2026 (#847)
Date: September 27, 2026
Answers: ADR-290 D14’s second half (std gains a
subprocess), #124 step 2
Related: Part III 17 (std::process), ADR-289 D2 and D6 (an operating-system resource belongs in std),
ADR-121 D4 (a worker’s reply wakes the park),
ADR-303 (a wait must not hold an I/O worker),
ADR-135 D2 (an empty list’s element type comes from its use),
ADR-282 D4 (a literal is built into text of its own where it is kept),
ADR-010 D2 (provenance)
ADR-290 D14 decided that std gains a subprocess, on the socket’s
own argument: a child process is an operating-system resource, not a protocol
or a framework, so ADR-289 D2’s subtraction does not push it out.
Part III 17 names the module, std::process, and says nothing of what is in it.
Self-hosting needs it: a compiler written in Nikaia runs rustc and cargo,
and describe runs cargo metadata.
process::run(program, args; dir).use std::process
let out = process::run("cargo", ["metadata", "--format-version", "1"]; dir: root)
if !out.ok { println(out.stderr) }
run starts program with args, waits for it to finish, and hands back a
process::Output: code, ok, stdout and stderr. program is looked up on
PATH where it has no /, as a shell would; the arguments reach the child one
by one, with no shell between them unless the program asks for one. dir is an
option (after the ;) and defaults to the directory the program runs in.
Standard input is empty.
Run to the end, and nothing else. Every caller this tree has wants the
whole answer - cargo metadata’s JSON, rustc’s diagnostics - and a call that
runs to the end needs no handle a program has to close or wait on. Streaming a
child’s output, writing to its input and killing it are each a handle with a
lifetime, which is ADR-297’s question and not this one’s; they are
added when a program needs one.
args is Vec[String], handed over. An empty list has no element type of
its own, and a parameter that named none left run("pwd", []) for rustc to
guess at; a concrete type is the use ADR-135 D2 says answers it.
A list of literals is built into text of its own where it is kept
(ADR-282 D4), so the ordinary call is written as it reads.
run throws io::IoError where the program cannot be started -
NotFound(program), PermissionDenied(program), Other - and where what it
wrote is not UTF-8 (NotText). How the program ended is code and ok,
and the caller decides what a 1 means.
A tool’s refusal is information the tool printed, and a catch would have to
carry it somewhere; making it a value keeps stderr beside the code that
explains it. code is -1 where the program was ended by a signal and has no
exit code; ok is false then, so a caller that only asks ok is never told a
killed program succeeded.
Text and not bytes. Both callers read text, and a program’s output that is
not UTF-8 is refused by name rather than decoded lossily - the rule fs and
io already follow. A Bytes form is added when a caller needs one.
A child runs for as long as it runs. Waiting for it on the thread the program
runs on would stop every other task at user_parallelism = no; waiting for it
on an I/O worker would hold up every read queued behind it, because
io-workers is 1 by default - the ceiling ADR-303 took away
from readiness waits.
So the wait gets a thread of its own (rt::io::beside), and everything a
worker’s reply does after an operation this does too: the answer goes in its
channel, both bells ring (ADR-121 D1), and the operation
counts as pending from before the thread starts until after the bell, so the
executor never mistakes an answered operation for one nobody is working on.
No touches, which is the ledger’s way of saying everything
(ADR-292 D3): a child may write any file this program reads, so a
run keeps its place against every other effect. Its output is untrusted
(ADR-010 D2): the program that ran may have printed what somebody
else sent it, so a map keyed on it is hashed with the keyed function.
env_clear, then env_remove, then env.process::run("cargo", ["build"];
env: [("RUSTFLAGS", "-C overflow-checks=off")],
env_remove: ["CARGO_HOME"],
env_clear: false)
Three options of run: env: Vec[(String, String)] = [], env_remove:
Vec[String] = [] and env_clear: bool = false. The child’s environment is
built in three steps, always in this order:
env_clear is
true.env_remove is taken out. After env_clear there is nothing
to take out, and env_remove does nothing; that is not an error.env is set, in the order written; a later pair with the
same name replaces an earlier one. A name both removed and set is set.Names are the same where the platform says they are: byte for byte on Unix,
without regard to case on Windows. ("Path", p) on Windows replaces an
inherited PATH, and two pairs PATH and Path are one name there and two
on Unix; env_remove matches the same way.
Why. Adding to what is inherited is the case nearly every caller has, and a
default that keeps PATH and HOME is one a mistake cannot empty (Go’s,
Python’s and Node’s env replace, and losing PATH is their known
mistake). Starting empty is written where it is meant, for a run that must see
only what it is given. One order, the same on every call, means no option
changes what another one does except as written here. Pairs, not "K=V"
texts: a value may hold =. Rejected: env replacing the environment; only
adding, which leaves a reproducible run unwritable; an order that depends on
how the options are written.
program is looked up on the caller’s PATH, never on the child’s.A program with no path separator in it (/, and on Windows \ too) is
looked up on the PATH of the calling program as it is when run is called,
and the child is started by the full name found there. env, env_remove and
env_clear change what the child sees and nothing about which program
starts. Where it is not found, run throws NotFound(program).
Why. A call names the same program whatever its options say:
env_clear: true does not make cargo unfindable, and an env that sets
PATH for the child cannot swap the program for another one of the same name.
Rust searches the child’s PATH on Unix where it is changed, and its own rule
on Windows; one rule on every platform is easier to read than two. A program
that wants another PATH searched names the program’s full path.
A name that is empty or holds = or a NUL, and a value that holds a NUL, in
env or env_remove, is invalid. run checks every entry before it starts
anything; where one is invalid it starts nothing and throws
io::IoError::Other with a message that names every invalid entry, not only
the first. A name or value written as a literal in the call is checked while
building, and an invalid one is refused (NK1286).
Why. A child started with part of what was asked for runs on a wrong premise, and a partly applied environment cannot be undone; one message with every fault spares a run per fault. The operating system refuses these entries, or reads them as another name; Nikaia says so in its own words before it gets there.
env_clear is not a sandbox.env_clear decides which variables the child sees, and nothing else. The child
runs as the same user, with the same rights, in dir, and reaches every file,
the network and the other programs the caller could reach. Some programs need
a variable to start at all (SystemRoot on Windows, HOME for many tools);
clearing does not put them back.
Why. An option named clear reads like isolation; a reader who takes it for one trusts a child with what the caller could do. Isolation is a question for a handle on a running child (ADR-297), not for this option.
run: within, and the child is killed at it.let out = process::run("cargo", ["test"]; within: 10.minutes())
if out.timed_out { println("cargo test did not finish: {out.stderr}") }
within: time::Duration? = null bounds the run. At the deadline run kills
the program and everything it started, waits for it, and hands back the
Output as usual, with timed_out true, code -1 and ok false;
stdout and stderr hold what was written until then, and elapsed and the
other figures are the run’s own. Without within, run waits as long as the
program runs, and timed_out is false.
An answer, not a failure, as D2 has it: how the program ended is the
caller’s to judge, and what it printed before it was stopped (the test that
hung, the last line of a log) is the explanation a throw would drop. ok is
false, so a caller that only asks ok never takes a stopped program for one
that succeeded.
The program and everything it started. On Unix a run with within
starts the program in a process group of its own and, at the deadline, sends
SIGKILL to that group. Killing only the child would leave a grandchild (a
shell’s command, a test runner’s workers) holding the output pipes, and run
reads them to their end before it waits (ADR-368 D3), so it would wait for the
grandchild after all. A program that leaves the group (setsid) escapes the
kill; that is the operating system’s line, not this call’s. On Windows the
program itself is killed. A group of its own also means a terminal’s Ctrl-C
does not reach the program; that is the price of the bound, and a run
without within keeps the caller’s group.
Why an option and not a handle. A deadline needs nothing a program holds
after the call: the bound is known at the start and the call still runs to an
end, so it fits D1’s shape. Streaming, input and a kill at a time the caller
chooses still need a handle (ADR-297). The name is TaskHandle.join’s
(within:), so one word bounds a wait in std; unlike join, which has no
value to hand back, run has one.
Rejected: throwing task::TimedOut (loses the output, against D2); a
grace period with SIGTERM first (a second number, and a program that
ignores it still needs the SIGKILL; added when a caller needs a clean stop);
killing the child alone (the pipe wait above).
stdin, stdout and stderr are options of run.let built = process::run("cargo", ["build", "--message-format=json"]; stderr: process::Stream::Inherited)
let echoed = process::run("cat", []; stdin: process::Input::Text("hello"))
run gains three options after within, in this order:
stdin: process::Input = process::Input::Closed,
stdout: process::Stream = process::Stream::Captured and
stderr: process::Stream = process::Stream::Captured, with
enum Stream { Captured, Inherited } and
enum Input { Closed, Inherited, Text(String) }. The defaults are D1’s
behaviour byte for byte, so each option costs only where it is written
(the ADR-243 style of options after ;).
Output holds "" for it.Text(s) is written on a thread of its own while the output pipes are
read, then closed; a child that writes before it reads cannot fill a pipe
while run waits to write. A child that exits before reading all of s
is no failure: the broken pipe on that write is ignored, and the run’s
answer is the child’s, as D2 has it.within keeps working: the group kill (D9) does not depend on the
pipes.This decides D9’s “input” and part of its “streaming” without a handle: what a caller knows at the start (the text to feed, where output goes) fits D1’s shape. Reading the output piece by piece while the program runs, and writing input in answer to it, still need ADR-297’s handle.
rustc, cargo and sh, which is the first half
of the I/O describe and a self-hosted compiler need.rt::io::beside), which the next such wait reuses.std::cli.process::exit, and this program’s own arguments beyond cli.process is on a target without an operating system - Part III 17.2
already says it does not exist there, and no such target builds yet.