Manual contentsDigging DeeperBrowse 116 chapters
Manual 9 min read

Processes

The Process facade runs other programs: a build tool, git, an image converter, a script. It captures what the program writes, ends it when it runs too long, cleans up everything it started, runs several side by side, and fakes them all in tests. It is Laravel's Process facade with the command and the shell line kept apart.

use suprnova::Process;

let status = Process::command(["git", "status", "--short"])
    .path("/srv/app")
    .run()
    .await?;

if status.successful() {
    println!("{}", status.output());
}

Commands and shell lines

Process::command takes the program and its arguments. Each argument reaches the program as it is, through no shell, so a value from a form or a file name with spaces is never split or run:

use suprnova::Process;

// `name` might hold "x; rm -rf ~": it is still one file name to convert.
Process::command(["convert", name.as_str(), "thumb.png"]).run().await?;

Process::shell takes a command line and runs it through the system shell, sh -c (cmd /C on Windows), the way Laravel runs a string command. Pipes, &&, redirects, $VAR and globs work:

use suprnova::Process;

Process::shell("npm ci && npm run build > build.log").run().await?;

Never build a shell line from input you do not control: the shell runs whatever the line holds. Use Process::command for that.

Results

run waits for the program and returns a ProcessResult:

Method Returns
successful() / failed() whether the exit code is 0
exit_code() Some(code), or None when a signal ended it
output() / error_output() everything written to standard output and standard error, as text
output_bytes() / error_output_bytes() the same, byte for byte, for output that is not text
see_in_output(text) / see_in_error_output(text) whether the output contains text
command() the command line that ran

A nonzero exit is a result that reports failure, not an error. Call throw() to turn a failed result into a ProcessError::Failed that carries the exit code and both outputs, so ? stops on it:

let built = Process::command(["cargo", "build"]).run().await?.throw()?;

run returns an error only when the program did not give a result: it could not be started (ProcessError::NotStarted, which names the program), or it was killed for a timeout.

Options

Every option takes the builder and returns it:

Option Effect
path(dir) the working directory
env(key, value) adds a variable to the environment the program inherits
input(bytes) writes to standard input, then closes it; without it, standard input is empty
timeout(duration) the longest it may run; 60 seconds unless set, and zero means none, as in Laravel
forever() no timeout
idle_timeout(duration) the longest it may go without writing output
quietly() keeps no output: it is read and thrown away, and no output callback is called
tty() hands the program this terminal, for a program that talks to the user; nothing is captured, and an idle timeout is refused, since nothing can watch the terminal. Process::supports_tty() says whether there is a terminal

run_with(callback) calls the callback with each chunk of output as it arrives, marked OutputKind::Out or OutputKind::Err:

use suprnova::{OutputKind, Process};

Process::command(["npm", "run", "build"])
    .run_with(|kind, chunk| match kind {
        OutputKind::Out => print!("{chunk}"),
        OutputKind::Err => print!("[stderr] {chunk}"),
    })
    .await?;

Timeouts and cleanup

A program that runs past its timeout is killed and run returns ProcessError::TimedOut, naming the command and the timeout. One that writes nothing for its idle timeout is killed the same way, with ProcessError::IdleTimedOut. Both errors carry the output written before the kill.

A kill reaches everything the program started: a script that started background jobs takes them with it. On Unix each program gets a process group of its own, and the group is killed. A tty() program stays in the terminal's group, which it must share to read the terminal, so its descendants are found in the process table and killed one by one. On Windows taskkill /T ends the tree. The same cleanup happens when the future of run is dropped before it completes, as tokio::time::timeout or a select! drops it, and when a started process is dropped. A program that exits by itself is waited on until its output closes, and nothing it left behind is killed.

A child in a group of its own does not get the SIGINT a terminal sends on Ctrl-C. The server and the workers end their children when they shut down; a console command that Ctrl-C kills outright leaves its children running unless it handles the signal, for instance with tokio::signal::ctrl_c() in a select! beside the run.

Started processes

start returns the program running, as an InvokedProcess:

use std::time::Duration;
use suprnova::{Process, Signal};

let mut worker = Process::command(["./app", "queue:work"])
    .forever()
    .start()?;

println!("pid {:?}", worker.id());
while worker.running() {
    print!("{}", worker.latest_output());
    tokio::time::sleep(Duration::from_secs(1)).await;
}
let result = worker.wait().await?;

output() and error_output() return everything so far, and latest_output() and latest_error_output() what came since the last call. signal(Signal::Term) signals the program itself; stop(grace) sends a terminate signal to it and everything it started, then a kill to whatever is left after grace, and returns the result. wait_until(|kind, chunk| ...) waits until the callback returns true for a chunk of output. The timeouts hold for a started process whether or not anything waits on it: a watchdog kills it at its timeout, and wait then returns the timeout error. Call start inside a Tokio runtime: the output is read by tasks of its own.

Pools

Process::pool() runs processes side by side. Add each under a key, or push it to be keyed by its position:

use suprnova::Process;

let results = Process::pool()
    .add("assets", Process::command(["npm", "run", "build"]))
    .add("types", Process::command(["suprnova", "generate-types"]))
    .push(Process::command(["cargo", "check"]))
    .concurrency(2)
    .run()
    .await;

if !results.successful() {
    for key in results.failed() {
        eprintln!("{key} failed");
    }
}

results.get(key) returns that process's result, or its error when it could not run or was killed for its timeout. A failure stops nothing else. A pushed process is keyed by its position, or the next free number when a key already holds it, and adding a key twice replaces the first process. With concurrency(n) at most n run at once and the rest wait for a slot; without it every process starts at once. start() returns the pool running, as an InvokedPool, with running, signal, stop and wait; each running() call starts waiting processes in the slots that freed, so a pool can be polled to the end.

Pipes

Process::pipe() runs processes in order, each with the previous one's output as its input, and returns the last result:

use suprnova::Process;

let sorted = Process::pipe()
    .push(Process::command(["cat", "words.txt"]))
    .push(Process::command(["sort", "-u"]))
    .run()
    .await?;

The first process that fails ends the pipe and its result comes back; the processes after it do not run. The output passes byte for byte, so a pipe can carry an archive or an image. Each process runs to its end before the next starts, as in Laravel. For a streaming pipe, use Process::shell with |.

Testing

Process::fake() stops every process from running while its guard lives. A command whose command line matches a pattern gets that result; any other gets an empty successful one:

use suprnova::Process;

let fake = Process::fake();
fake.when("git *", Process::result("main\n"));
fake.when("npm run *", Process::result("").error_output("failed").exit_code(1));

deploy().await?; // runs git and npm

fake.assert_ran("git branch --show-current");
fake.assert_ran_times("npm run build", 1);
fake.assert_not_ran("git push --force");

The command line is the arguments joined by spaces, or the shell line as given, and * matches any run of characters; the first matching pattern wins. fake.prevent_stray_processes() turns an unmatched command into ProcessError::Stray.

Process::describe() builds a process line by line, for a started process that reports itself running for a number of running() calls:

fake.when(
    "worker *",
    Process::describe()
        .output("processing 1")
        .output("processing 2")
        .exit_code(0)
        .runs_for(2),
);

.id(n) sets the process id a described process reports, and .replace_output(text) and .replace_error_output(text) set all of its lines at once. A faked started process records the signals sent to it, for has_received_signal(Signal::Term).

Process::sequence([...]) answers its results in turn, one a run, and .push(result) adds one at the end. A run after the last is ProcessError::FakeExhausted, unless .dont_fail_when_empty() makes it an empty success or .when_empty(result) gives the result to answer.

The assertions are assert_ran, assert_ran_with(|process| ...), assert_ran_times, assert_ran_in_order, assert_not_ran (and assert_didnt_run) and assert_nothing_ran; recorded() returns every faked process with its command line, working directory, environment, input and result. Runs, starts, pools and pipes are all faked and recorded.

The fake is process-global, like Storage::fake, and its guard serializes the tests that take one. A test that runs real processes at the same time as a faked test would be faked too, so keep real-process tests out of a binary whose tests fake, or mark them #[serial].

Why Suprnova diverges

  • The command and the shell line are two methods. Laravel's run takes either a string, run through a shell, or an array, run as it is, and the difference is easy to miss when a value is interpolated. command and shell make the choice visible.
  • A kill reaches everything the program started. Laravel kills the program; a background job it started keeps running.
  • Pools take a concurrency limit. Laravel starts every process in a pool at once.
  • A started process's timeout holds without a wait. Laravel checks it only while something waits on the process.
  • The fake is a guard. Process::fake() returns the fake, and the assertions are its methods, so a test cannot assert against a fake it did not install.

Next