Standard library Dodo 0.1.4

Processes

Direct native process execution, owned child handles, explicit environments and bounded output collection.

On this page

std/process runs another executable and manages its lifetime. Start with Command.output to collect stdout and stderr together into bounded buffers. Choose Command.spawn when you need to manage a running child yourself. This hosted API supports Linux GNU x86-64 and Windows x64.

An executable and its arguments are separate values. Command.arg("two words") passes one argument containing a space; do not add shell quotes around it. The API does not expand *, $HOME, pipes, or redirects. PATH lookup is an explicit platform-dependent option.

Quickstart

In a fresh example directory, save this companion program as child.dodo:

package child
import "std/console"
fn main() -> i32 {
    match console.println("Hello, command!") {
        ok(_) => { return 0 }, err(_) => { return 1 },
    }
}

Compile it first (the .exe suffix also works on Linux):

dodo compile child.dodo -o child.exe

Save this as command_start.dodo:

package command_start
import "std/process"
import "std/console"
import "std/io"

fn main() -> i32 {
    storage := process.CommandStorage.new()
    command := match process.Command.new("./child.exe", &mut storage) {
        ok(value) => { value }, err(_) => { return 1 },
    }
    stdout := [0u8; 128]
    stderr := [0u8; 128]
    match command.output(&mut stdout, &mut stderr, 5000) {
        ok(report) => {
            if !report.status.success() { return 3 }
            output := console.stdout()
            match io.write_all(&mut output, &stdout[..report.stdout_len]) {
                ok(_) => { return 0 }, err(_) => { return 4 },
            }
        },
        err(_) => { return 2 },
    }
}
dodo run command_start.dodo

Expected stdout is Hello, command! followed by a newline; success exits 0. Both output arrays are caller-owned; CommandStorage.new() supplies fixed native workspaces for the executable, directory and arguments. Command borrows that storage until it is destroyed. No global allocator or shell is used.

Exit 1 means an invalid/oversized executable name. Exit 2 means spawn, capture or wait failed: verify child.exe exists and is executable, inspect CollectError.cause and its stdout/stderr prefix counts, and increase the buffers or deadline only when appropriate. Exit 3 means the child ran but reported failure; inspect its exit status and stderr. Exit 4 means printing failed.

output drains both pipes and waits, preventing the common deadlock from waiting before draining captured output. Its timeout starts after synchronous spawn, and cleanup can exceed the budget. Failure kills/reaps this child; descendants are not killed. Command.spawn provides explicit child ownership when you need interactive streams or a custom environment. std/process/alloc is the advanced bounded allocated-output alternative.

UTF-8 command builder

Create CommandStorage.new() once, then Command.new(executable, &mut storage). The command exclusively borrows storage. Each arg(text) appends one copied argument; it does not retain the input string. Empty arguments, whitespace, quotes, and trailing backslashes are preserved. clear_args() resets the list. current_dir(text) selects the child directory. Invalid NUL/empty executable or directory names, and capacity failures, preserve the previous configuration. A failed arg leaves all prior arguments intact. Drop the command to reuse its storage with a different executable.

Storage bounds are 4096 native units each for executable and directory (including NUL), plus 32768 units for all arguments (one NUL per argument). Storage occupies 40984 bytes on Linux and 81944 on Windows, including counters and alignment; there is no hidden allocator or growth. The OS imposes additional limits: Linux also limits explicit arguments to 4095 (plus the executable at argv[0]) and custom environment entries to 4096 in the native backend; exceeding either returns BufferTooSmall at spawn. OS exec limits apply as well. Windows bounds the quoted command line to 32768 UTF-16 units including NUL. Quotes/backslashes can expand the Windows command line, so a successful arg can still lead to a BufferTooSmall spawn failure. The Windows round-trip guarantee applies to CRT argument parsing; programs with custom command-line parsers can differ.

command.options starts with inherited environment and standard streams, inherited working directory, and search_path=false. spawn() uses these options; with inherit_environment=false it supplies an empty environment. spawn_with_environment(native_list) uses an explicit native environment when inheritance is disabled, compatible with env.child_environment. output(stdout, stderr, timeout_ms) overrides streams with null stdin and piped stdout/stderr while retaining the other options. Use spawn_with_environment with capture options, followed by Child.collect, when you need an explicit child environment and capture together.

Each output slice is an independent byte limit. Success reports actual lengths, EOF on both pipes, and an exit status; nonzero child exit is successful collection. Exact-capacity streams succeed, including empty streams with zero capacity. Excess output returns BufferTooSmall; errors retain exactly the prefixes named by CollectError.stdout_len and stderr_len. Startup errors report zero lengths and leave output untouched. Output bytes are never implicitly decoded.

The relative millisecond timeout covers pipe draining and this child’s exit, starting after synchronous spawn. process.FOREVER disables it; zero permits one immediate attempt. Duration.timeout_millis() rounds fractional milliseconds up and rejects overflow/the infinite sentinel. Spawn, process termination/reaping, OS scheduling, and descendants are outside the time guarantee. Collection errors close owned pipes, terminate and reap this child. Descendants are not terminated; a descendant holding a pipe open can cause timeout even after the child exits. Child.wait_timeout instead leaves a live child for explicit retry/cancellation.

Complete runnable sources are hosted_command.dodo and hosted_child.dodo.

API and contracts

std/process provides direct executable execution on x86-64 Linux/glibc and Windows x64. Its selected std/process/native adapter uses an explicit C ABI boundary. Importing it does not import filesystem, environment, threading, or synchronization packages. The compiler links its small native boundary only when needed; object-only consumers must also compile and link stdlib/std/process/runtime.c with their target C toolchain.

Selecting a command

After creating the command in the quickstart, configure it before calling output or spawn. Inside a helper returning !error.Error, arguments are added one at a time:

command.arg("--output")?
command.arg("report with spaces.txt")?
command.arg("")?

This passes three arguments. The final one is empty. Use current_dir("work") to choose the child’s directory; it does not change the parent’s directory. Use an absolute executable path if you also change the child directory and need identical resolution behavior on Linux and Windows.

Execution API Owns the child after return? Captures output?
command.output(stdout, stderr, timeout) No; the child has completed or been cleaned up. Yes, both streams within independent limits.
command.spawn() Yes, through the returned Child. Only if command.options selects pipes.
command.spawn_with_environment(list) Yes, through the returned Child. According to options; environment replacement also requires inherit_environment=false.
process.spawn(...) Yes, through the returned Child. According to explicit native Options.

process.spawn(executable, arguments, environment, directory, options) returns Child!std/platform/error.Error. The executable is a borrowed std/platform/native.NativeString; arguments and environment are NativeList values. A list contains consecutive NUL-terminated native strings, with no extra sentinel: an additional final NUL is an actual empty argument. Arguments exclude argv[0], which is the executable. The directory is Option<&native.NativeString>. All input storage can be released after spawn returns, including on startup failure.

Options.new() inherits all three standard streams and the parent environment. Options.capture() selects null stdin and piped stdout/stderr. Set each stream to Stdio.Inherit, Stdio.Piped, or Stdio.Null independently. With inherit_environment = false, the supplied list is the complete child environment, including when empty. With inheritance enabled, that list is ignored after validation. std/env offers snapshot and builder facilities.

The default selects the executable directly. There is no command-string parsing, shell expansion, wildcard expansion, or implicit shell. Shell execution is an explicit spawn of /bin/sh, cmd.exe, or another selected shell, with that shell’s command option and command text supplied as arguments. Shell command text is interpreted by the selected shell and has its own escaping rules.

Linux search_path = true explicitly enables posix_spawnp and observes the parent’s PATH, even when a different child environment is supplied. Windows returns typed Unsupported for this option; provide the executable path. Relative executable paths follow native platform resolution: Linux spawn file actions change directory before execution, while Windows resolves the application independently of the child’s requested working directory. Use an absolute executable when that distinction matters.

Native strings and limits

Linux arguments and environment values preserve arbitrary non-NUL bytes; filenames and arguments need not be UTF-8. Windows preserves native UTF-16, including unpaired surrogate code units. Native string conversions and their explicit lossy alternative are provided by std/platform/native; no locale conversion takes place during spawn.

Windows passes the executable separately to CreateProcessW. Every argument is quoted using the Windows C-runtime backslash/quote convention, preserving empty arguments, embedded quotes, and trailing backslashes. Programs using a custom command-line parser can interpret that command line differently. Explicit child environments are sorted with Windows ordinal case-insensitive comparison and terminated with two NUL units.

The native boundary uses bounded stack scratch, without a global Dodo allocator. Linux supports at most 4,096 total arguments including argv[0], and 4,096 explicit environment entries. Windows supports a command line below 32,768 UTF-16 units and an explicit environment below 32,768 units. Exceeding an adapter bound returns BufferTooSmall; the OS can impose additional native limits such as Linux E2BIG. Native process creation and Windows handle setup can allocate OS-managed resources.

Child and pipe ownership

Child uniquely owns the process and each untaken pipe. take_stdin, take_stdout, and take_stderr return Option<Pipe> and transfer that pipe’s ownership exactly once. Pipe implements std/io’s read/write contracts and has a recoverable close operation; destruction closes once, best effort. A taken pipe has an independent lifetime. Unix pipe writes suppress only a newly generated SIGPIPE in the writing thread, so a closed child reader returns a recoverable native EPIPE error. Linux captured output pipes are nonblocking; a direct read without available bytes returns an error carrying EAGAIN.

wait() waits indefinitely without draining pipes. wait_timeout(milliseconds) returns TimedOut while retaining the live child. try_wait() returns none while running. The timeout clock is monotonic, polled at approximately one millisecond intervals; scheduling can delay observation of a deadline.

ExitStatus.kind distinguishes Exited, Unix Signalled, and explicit Cancelled. code carries the ordinary exit code or signal number; Windows 32-bit exit-code bits are preserved in the signed i32 representation. success() requires ordinary exit with code zero. Spawn errors are separate from every exit status. terminate() requests SIGTERM on Linux and uses TerminateProcess with exit code 1 on Windows; it does not wait. cancel() forcibly terminates and reaps an unfinished child and records Cancelled. Cancelling an already observed completed child preserves its status.

Dropping an unfinished Child closes its owned pipes, forcibly terminates the child, and reaps it. Completed children release their process resources. This policy prevents leaked zombies and abandoned subprocesses on every normal Dodo scope-exit path. It applies to the immediate child; descendants are not managed as a process group or Windows job. OS scheduling may delay destruction. Dodo’s aborting traps do not unwind, so they do not run these destructors. Process detach and process-tree management are not supplied.

Linux creates pipes close-on-exec and uses spawn close-from actions to prevent inheritance of unrelated descriptors. Windows duplicates inherited standard handles, makes parent pipe endpoints non-inheritable, and passes an explicit three-handle inheritance whitelist. Every partial startup path releases pipes, file actions, attribute lists, and process/thread handles before returning.

Collecting output

There are two distinct kinds of failure to handle. err(...) means the library could not finish spawning, reading, or waiting. ok(report) with !report.status.success() means the child ran and returned a nonzero exit or another unsuccessful status. Stderr may be nonempty even on a successful exit; its contents do not determine success().

For a captured child, prefer collect over waiting first. A child can block when its output pipe fills; a parent waiting for that child can then block forever. collect drains stdout and stderr while waiting. If you take either pipe, your program assumes responsibility for draining and closing that pipe.

child.collect(stdout_storage, stderr_storage, timeout_ms) closes owned stdin, drains both captured streams fairly, and waits for exit. It handles output larger than either OS pipe capacity without the stdout/stderr ordering deadlock. There is no scheduler or worker thread. Returned Output contains an exit status and each initialized byte count; output is arbitrary bytes, with no implicit text decoding. process.FOREVER is an explicit unlimited deadline.

The two supplied slice lengths are independent hard size limits. Exactly full output is accepted after probing for EOF; one additional byte triggers BufferTooSmall. Collection failure reports each retained initialized prefix, closes the remaining pipes, forcibly terminates and reaps the child. Timeout is reported as TimedOut, and native read/wait failures preserve native error codes. Taken output pipes are no longer collected. Writing interactive input must happen before collection or through separately managed taken pipes; this API does not multiplex simultaneous streaming stdin.

For allocation-dependent collection, import std/process/alloc. collect_arena(child, stdout_arena, stderr_arena, stdout_limit, stderr_limit, timeout_ms) allocates the two limits explicitly and returns owned buffers whose allocator borrows remain checked. Generic unsafe collect accepts two allocators satisfying std/bytes_alloc.new’s documented contract. Allocation failure leaves the child untouched. Collection failure reaps it and releases both buffers; use caller-backed collection to retain partial output on failure. There is no global allocator or hidden growth beyond the chosen limits.

Verification

Native fixtures execute at -O0 and -O3 with controlled C children and temporary directories. They cover 262,144 bytes on each output stream, per-stream limits, quotes, spaces, empty arguments, trailing backslashes, native Unix argument bytes, environment inheritance and replacement, explicit working directories, stdin ownership and EOF, exit/signal/cancellation/timeout distinctions, missing executables, and destructor cleanup. Windows CI executes the Windows fixtures natively with scripts/test_windows_native.py; the Wine suite also runs them. Windows-specific checks verify that unrelated inheritable handles stay in the parent, a concurrent child cannot hold stdin EOF open, and repeated spawn, failed spawn, and destruction release process and pipe handles.

Complete API reference

For every public type, field, constant, and function signature, see std/process, std/process/alloc.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?