Chapter 20 15 min read

Process

stdlib::process is a builder-style API for spawning external commands with argv-level control: captured output, env overrides, working directory, piped stdin, exit codes, and live read/write pipes. It uses fork+execvp on POSIX and CreateProcess on Windows — there is no shell intermediary, so there is no quoting hazard and no shell-injection surface.

Import

import stdlib::process::*;

Overview

The typical flow is: build a Command, configure it with chained methods, then call run() (synchronous, blocks until exit) or spawn() (non-blocking, returns a Child).

let cmd: *Command = Command::new("git");
cmd.arg("rev-parse").arg("HEAD").capture_out();
let r: !ProcessResult = cmd.run();
if r.ok {
    println!("HEAD =", r.val.stdout.trim());
}
cmd.free();

Public surface at a glance

Item Kind Summary
ProcessResult struct Outcome of a finished run (exit code, captured output, signal info).
Command struct + builder Configure and launch a subprocess.
Child struct Handle to a spawned, still-running process.
ChildStdin / ChildStdout / ChildStderr struct Live pipe handles exposed after spawn().
Command::new / arg / args / env / env_clear / cwd / stdin_str builder fn Configure program, args, env, dir, stdin.
Command::capture_out / capture_err / capture builder fn Request output capture (consumed by run()).
Command::pipe_stdin / pipe_stdout / pipe_stderr builder fn Request live pipes (consumed by spawn()).
Command::run fn Run synchronously; returns !ProcessResult.
Command::spawn fn Start without waiting; returns !Child.
Command::free fn Release the builder.
Child::wait / kill fn Block for exit / signal the child.
ChildStdin::write / close fn Feed and close the child's stdin.
ChildStdout::read / read_all / close fn Read the child's stdout.
ChildStderr::read / read_all / close fn Read the child's stderr.
process_kill fn Signal an arbitrary PID.
process_exists fn Liveness check for a PID.

Result and configuration types

ProcessResult

The outcome of a finished run. Returned by Command::run() and Child::wait().

pub struct ProcessResult {
    pub exit_code: i32,   // 0 = success
    pub stdout: string,   // empty if not captured
    pub stderr: string,   // empty if not captured
    pub signaled: bool,   // true if child died from a signal (POSIX)
    pub signal: i32,      // signal number; 0 if !signaled
}
Field Type Meaning
exit_code i32 Process exit status; 0 is success. On POSIX a signal death is reported as 128 + signal.
stdout string Captured stdout, or "" if no capture flag was set.
stderr string Captured stderr, or "" if no capture flag was set.
signaled bool true if the child was killed by a signal (POSIX only).
signal i32 The terminating signal number; 0 when signaled is false.

Command

The builder. Construct with Command::new, configure with the chained methods below, then run. The fields are public but you normally only touch them through methods.

pub struct Command {
    pub program: string,
    pub args: *Vector<string>,
    pub env_kv: *Vector<string>,   // entries are "KEY=VAL" strings
    pub cwd_path: string,
    pub stdin_payload: string,
    pub flags: i32,                // bit 0 capture stdout, bit 1 capture stderr,
                                   // bit 2 env_clear, bit 3 pipe stdin,
                                   // bit 4 pipe stdout, bit 5 pipe stderr
}

Child

A handle to a spawned, still-running process. The pipe handles are non-null only when the matching pipe_* builder was used.

pub struct Child {
    pub pid: i32,
    pub stdin:  *ChildStdin,    // null unless pipe_stdin()
    pub stdout: *ChildStdout,   // null unless pipe_stdout()
    pub stderr: *ChildStderr,   // null unless pipe_stderr()
}

Pipe handle structs

Each wraps a single OS handle (HANDLE on Windows, fd on POSIX).

pub struct ChildStdin  { pub handle: i64 }
pub struct ChildStdout { pub handle: i64 }
pub struct ChildStderr { pub handle: i64 }

Building a command

All builder methods return *Command, so they chain. Command::new allocates; release it with free() (ideally via defer).

new / free

Function Signature Description
new fn new(program: string) -> *Command Create a builder for program. Args/env/cwd/stdin default to inherit; output is forwarded to the parent unless a capture_* method is called.
free fn free(self: *Command) Release the builder (its args + env_kv vectors and the struct). Pair with defer.
let cmd: *Command = Command::new("echo");
defer cmd.free();
cmd.arg("hi").run();

Arguments, environment, and directory

Method Signature Description
arg fn arg(self: *Command, a: string) -> *Command Append one positional argument.
args fn args(self: *Command, vs: *Vector<string>) -> *Command Append every string in vs as an argument (copies the entries; you still own vs).
env fn env(self: *Command, key: string, value: string) -> *Command Set an env var for the child (override on top of the inherited env). Stored internally as "KEY=VAL".
env_clear fn env_clear(self: *Command) -> *Command Discard the parent's environment; only vars set via env(k,v) reach the child.
cwd fn cwd(self: *Command, path: string) -> *Command Set the child's working directory.
stdin_str fn stdin_str(self: *Command, s: string) -> *Command Feed s to the child's stdin (run() only); the pipe is closed after writing, signaling EOF.
let extras: *Vector<string> = Vector::new();
extras.push_all!("--verbose", "input.txt");

let cmd: *Command = Command::new("tool");
cmd.arg("build")
   .args(extras)
   .env_clear()
   .env("PATH", "/usr/bin:/bin")   // only PATH reaches the child now
   .cwd("/tmp/repo")
   .stdin_str("data\n")
   .capture();

let r: !ProcessResult = cmd.run();
if r.ok { println!("exit:", r.val.exit_code); }
cmd.free();
extras.free();

Output capture

These set capture flags consumed by run(). Without any of them, the child's stdout/stderr inherit the parent's and ProcessResult.stdout/.stderr are "".

Method Signature Description
capture_out fn capture_out(self: *Command) -> *Command Capture stdout into ProcessResult.stdout (sets flag bit 0).
capture_err fn capture_err(self: *Command) -> *Command Capture stderr into ProcessResult.stderr (sets flag bit 1).
capture fn capture(self: *Command) -> *Command Capture both (equivalent to capture_out().capture_err(); sets bits 0+1).
let cmd: *Command = Command::new("wc");
cmd.arg("-l").stdin_str("a\nb\nc\n").capture_out().capture_err();
let r: !ProcessResult = cmd.run();
if r.ok {
    if r.val.exit_code == 0 {
        println!("lines:", r.val.stdout.trim());   // "3"
    } else {
        println!("failed:", r.val.stderr);
    }
}
cmd.free();

Capturing only stderr is the idiom for "run a compiler/linter and show its diagnostics on failure":

let cmd: *Command = Command::new("gcc");
cmd.arg("missing.c").capture_err();
let r: !ProcessResult = cmd.run();
if r.ok {
    if r.val.exit_code != 0 {
        println!("compile failed:", r.val.stderr.trim());
    } else {
        println!("ok");
    }
} else {
    println!("could not launch gcc:", r.err);
}
cmd.free();

Live pipe requests

These set flags honored only by spawn(). After spawn() the matching Child.stdin / .stdout / .stderr handle is non-null.

Method Signature Description
pipe_stdin fn pipe_stdin(self: *Command) -> *Command Open a live stdin pipe (flag bit 3); use child.stdin.write(s) / .close().
pipe_stdout fn pipe_stdout(self: *Command) -> *Command Open a live stdout pipe (flag bit 4); read with child.stdout.read(n) / .read_all().
pipe_stderr fn pipe_stderr(self: *Command) -> *Command Open a live stderr pipe (flag bit 5); read with child.stderr.read(n) / .read_all().

Running

run

pub fn run(self: *Command) -> !ProcessResult

Runs synchronously and blocks until the child exits. Honors capture_*, stdin_str, env, env_clear, cwd. Returns Err only on spawn failure; a non-zero exit is still ok(...).

let r: !ProcessResult = Command::new("echo").arg("hi").capture_out().run();
if r.ok {
    println!("exit:", r.val.exit_code);
    println!("out:",  r.val.stdout.trim());   // "hi"
}

Because run() returns !ProcessResult, you can propagate spawn failures with ? and reserve Err for genuine launch problems, mapping a non-zero exit to your own error:

fn capture_uname() -> !string {
    let cmd: *Command = Command::new("uname");
    defer cmd.free();
    let r: ProcessResult = cmd.arg("-s").capture_out().run()?;
    if r.exit_code != 0 { return err("uname failed"); }
    return ok(r.stdout.trim());
}

spawn

pub fn spawn(self: *Command) -> !Child

Starts the process without waiting and returns a Child. stdin/stdout/stderr inherit the parent's unless a pipe_* method was called. The capture_* flags and stdin_str are ignored on this path — use the live pipes instead.

let r: !Child = Command::new("sleep").arg("60").spawn();
if r.ok {
    let ch: *Child = &r.val;
    println!("pid:", ch.pid);
    let k: ! = ch.kill(9);          // SIGKILL on POSIX, TerminateProcess on Windows
    if !k.ok { println!(k.err); }
    let res: !ProcessResult = ch.wait();
    if res.ok {
        if res.val.signaled {
            println!("killed by signal", res.val.signal);
        } else {
            println!("exited", res.val.exit_code);
        }
    }
}

Controlling a Child

wait / kill

Method Signature Description
wait fn wait(self: *Child) -> !ProcessResult Block until the child exits and collect its result. stdout/stderr are always empty (spawn doesn't capture).
kill fn kill(self: *Child, sig: i32) -> ! Send signal sig. POSIX: kill(pid, sig). Windows: ignores sig and calls TerminateProcess. Returns Err on failure.
let r: !Child = Command::new("true").spawn();
if r.ok {
    let res: !ProcessResult = r.val.wait();
    if res.ok { println!("exited", res.val.exit_code); }
}

Streaming pipes

When spawn() is called after pipe_stdin / pipe_stdout / pipe_stderr, the Child exposes live read/write handles. Every read/write/close method returns a Result and surfaces OS errors as err("...").

ChildStdin

Method Signature Description
write fn write(self: *ChildStdin, s: string) -> !i32 Write s to the child's stdin; returns bytes written.
close fn close(self: *ChildStdin) -> ! Close the pipe (sends EOF). Idempotent — sets the handle to -1.

ChildStdout

Method Signature Description
read fn read(self: *ChildStdout, n: i32) -> !string Read up to n bytes; returns "" on EOF.
read_all fn read_all(self: *ChildStdout) -> !string Read everything until EOF.
close fn close(self: *ChildStdout) -> ! Close the stdout pipe.

ChildStderr

Method Signature Description
read fn read(self: *ChildStderr, n: i32) -> !string Read up to n bytes; returns "" on EOF.
read_all fn read_all(self: *ChildStderr) -> !string Read everything written to stderr until EOF.
close fn close(self: *ChildStderr) -> ! Close the stderr pipe.

A full round-trip through cat — write input, close stdin for EOF, drain stdout, then wait():

let cmd: *Command = Command::new("cat");
cmd.pipe_stdin().pipe_stdout();
let r: !Child = cmd.spawn();
if r.ok {
    let ch: *Child = &r.val;
    if ch.stdin != null {
        let w: !i32 = ch.stdin.write("hello\n");
        if w.ok { println!("wrote", w.val); }
        let c: ! = ch.stdin.close();        // EOF
        if !c.ok { println!(c.err); }
    }
    if ch.stdout != null {
        let all: !string = ch.stdout.read_all();
        if all.ok { print!(all.val); }
        let oc: ! = ch.stdout.close();
        if !oc.ok { println!(oc.err); }
    }
    let done: !ProcessResult = ch.wait();
    if done.ok { println!("exit", done.val.exit_code); }
}
cmd.free();

To stream live output without buffering everything, loop on bounded read(n) and stop when it returns an empty string (EOF):

let r: !Child = Command::new("ls").arg("-la").pipe_stdout().spawn();
if r.ok {
    let ch: *Child = &r.val;
    if ch.stdout != null {
        while true {
            let chunk: !string = ch.stdout.read(4096);
            if !chunk.ok { println!(chunk.err); break; }
            if chunk.val.len() == 0 { break; }   // EOF
            print!(chunk.val);
        }
        let c: ! = ch.stdout.close();
        if !c.ok { println!(c.err); }
    }
    let done: !ProcessResult = ch.wait();
    if done.ok { println!("exit", done.val.exit_code); }
}

Module-level helpers

These act on a raw PID, with no Command/Child involved.

Function Signature Description
process_kill fn process_kill(pid: i32, sig: i32) -> ! Send signal sig to pid. POSIX: kill(pid, sig). Windows: TerminateProcess (sig ignored). Returns Err on failure.
process_exists fn process_exists(pid: i32) -> bool true if a live process has this PID. POSIX: kill(pid, 0). Windows: OpenProcess + GetExitCodeProcess(STILL_ACTIVE).
if process_exists(1234) {
    let r: ! = process_kill(1234, 15);   // SIGTERM
    if !r.ok { println!(r.err); }
} else {
    println!("pid 1234 not running");
}

Caveats and platform notes

  • No shell. Command::new("ls -la") looks for a program literally named
  • ls -la. Pass the program and each argument separately: Command::new("ls").arg("-la"). To use shell features, invoke the shell explicitly:

  let cmd: *Command = Command::new("sh");
  cmd.arg("-c").arg("ls -la | wc -l").capture_out();
  let r: !ProcessResult = cmd.run();
  if r.ok && r.val.exit_code == 0 {
      println!("entries:", r.val.stdout.trim());
  }
  cmd.free();
  ```

- **Streaming + `run()` don't mix.** `run()` ignores `pipe_*`; `spawn()` ignores
  `capture_*` and `stdin_str`. Pick one model per command.
- **Signals are POSIX-shaped.** `signaled` / `signal` are only meaningful on
  POSIX; on Windows `kill` always force-terminates regardless of `sig`.
- **Exit code `127`** from a `run()` that returned `ok` usually means the program
  was not found or `chdir` to the `cwd` failed in the child (`execvp` / `chdir`
  failure inside the forked child calls `_exit(127)`).
- **Free the builder.** `Command::new` allocates with `malloc`; always `free()`
  it (use `defer cmd.free();` right after construction) to release the struct and
  its internal vectors.

## See also

- [`stdlib::os`](09-os.md) — lower-level OS primitives (`os_shell` and friends).
- [`stdlib::env`](10-env.md) — read/inspect the parent's environment variables.
- [`fs-io`](08-fs-io.md) — `eprintln` and stream helpers for logging captured
  output.