ADR-243: std::process Starts a Program and Waits for It, and the Wait Gives the Thread Up

Status: 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)

1. Context

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.

2. Decisions

D1 — One call, run to the end: 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.

D2 — A non-zero exit is an answer, not a failure.

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.

D3 — The wait gives the thread up, on a thread of its own.

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.

D4 — What it touches, and what its output is worth.

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.

D5 — The child’s environment is the caller’s, changed by 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:

  1. It starts as the caller’s whole environment, or empty where env_clear is true.
  2. Each name in env_remove is taken out. After env_clear there is nothing to take out, and env_remove does nothing; that is not an error.
  3. Each pair in 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.

D6 — 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.

D7 — Every entry is checked before anything starts.

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.

D8 — 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.

D9 — A deadline is an option of 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).

D10 — Amendment (#920, architect, 2026-10-31): 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 ;).

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.

3. Consequences

4. What this does not decide

This site is open source. Improve this page.