Cmd

:= {
    program : Str,
    args : List(Str),
    envs : List({ name : Str, value : Str }),
    clear_envs : Bool,
    working_dir : [Inherit, Set(Str)],
    timeout_ms : U64,
    stdout_limit_bytes : U64,
    stderr_limit_bytes : U64,
}

Run an external program and read back what it produced.

A command is built and then run:

encode! : Str => Msg
encode! = |input| {
    cmd = Cmd.new("ffmpeg")
        .with_args(["-y", "-i", input, "out.mp4"])
        .with_timeout_ms(120_000)

    match io.commands().run!(cmd) {
        Ok(output) if output.exit_code == 0 => Encoded("out.mp4")
        Ok(output) => EncodeRefused(Str.from_utf8_lossy(output.stderr))
        Err(err) => EncodeFailed(err)
    }
}

run! waits, so it belongs inside Task.spawn!, where the frame loop keeps drawing while the child runs. It is legal in init!, where it blocks startup, and in tasks, where it parks the task; it is refused in update! and render!.

A non-zero exit code is a completed run and arrives as Ok(output). Err means the process did not produce an exit status, such as a spawn failure, timeout, or output-limit violation.

No shell is involved. program names the executable and args are passed verbatim; spaces, globs, pipes, and redirects are not interpreted.

A child inherits this process's user, working directory, and, unless with_clear_envs is used, environment. The program must be declared with Command, matched exactly as the app names it; a child then has all of the user's authority, which no declaration can narrow.

Standard input is closed. Standard output and error are captured up to the configured limits rather than inherited.

Orderly shutdown terminates children still managed by the host. Detached descendants and forced process termination are outside this guarantee.

default_timeout_ms : U64

Thirty seconds.

Long enough for a real encode of a short clip, short enough that a task parked on a program that will never finish is eventually collected. A command that genuinely takes longer says so with with_timeout_ms.

default_output_limit_bytes : U64

Eight megabytes, applied to each captured stream on its own.

The same figure Http caps a response body at, for the same reason: it holds a large text or JSON payload and not an accidental video.

new : Str -> Cmd

A command to run program with no arguments.

Every bound is set here, so there is no way to build a command without a deadline or without output limits. program is looked up on this process's PATH when it contains no path separator, and used as a path when it does.

with_arg : Cmd, Str -> Cmd

Append one argument, exactly as the child will see it.

with_env : Cmd, Str, Str -> Cmd

Set one environment variable for the child.

A name that is empty, or that contains = or a NUL, cannot be set on any operating system this platform runs on, and fails the run with SpawnFailed rather than being dropped silently.

with_envs : Cmd, List({ name : Str, value : Str }) -> Cmd

Set several environment variables for the child.

with_clear_envs : Cmd, Bool -> Cmd

Whether to drop this process's environment before applying with_env.

Bool.True gives the child only what the command names, which is what a reproducible invocation of a tool wants. It does not remove the child's other inheritances: the working directory, the user account and the open standard streams are unchanged.

with_working_dir : Cmd, Str -> Cmd

Run the child in this directory instead of this process's own.

Only the child moves; nothing changes the working directory of the application itself, so a path this app uses afterwards still resolves the way every other path in it does. A relative program is still resolved against this process's directory, not against this one.

with_timeout_ms : Cmd, U64 -> Cmd

How long to wait for the child before killing it.

The deadline covers the whole run, and 0 is normalized to 1: there is no way to ask for no deadline, because a task parked forever on a program that never exits is a leak the app cannot see.

with_stdout_limit : Cmd, U64 -> Cmd

The most standard output to capture, in bytes.

A child that writes more fails the run with StdoutLimitExceeded rather than handing back a prefix. The host clamps this to sixty-four megabytes, so asking for more than it will hold gets that figure and the refusal arrives there instead.

with_stderr_limit : Cmd, U64 -> Cmd

The most standard error to capture, in bytes. StderrLimitExceeded otherwise, on the same terms as with_stdout_limit.

Output : {
    exit_code : I64,
    stdout : List(U8),
    stderr : List(U8),
}

What a child produced: its exit status and both captured streams.

exit_code is the status the operating system reported. On a child killed by a signal it is the negated signal number, so a segmentation fault is -11 rather than being confused with an ordinary exit; a child killed at the deadline reports -1.

stdout and stderr are captured separately and neither is interleaved into the other, so a program that writes progress to one and data to the other stays readable. Both are ordinary Roc byte lists.

Utf8Output : {
    exit_code : I64,
    stdout : Str,
    stderr : Str,
}

The same, decoded as text by run_utf8!.

CmdErr : [
    CommandNotFound,
    AccessRefused,
    PermissionDenied,
    SpawnFailed,
    Busy,
    Unavailable,
    Timeout(Output),
    StdoutLimitExceeded,
    StderrLimitExceeded,
]

Why the command produced no exit status.

A non-zero exit status is not one of these; it is an Ok.

CommandNotFound is no such executable on PATH or at that path, and AccessRefused is one that is there and the operating system will not start. PermissionDenied is a program the app did not declare with Command; nothing was started. SpawnFailed is every other refusal to start the child or to run it to its end, including a working directory that is not there and an environment name this operating system cannot represent -- one that is empty, or contains = or a NUL.

Timeout holds what the child had written before the deadline killed it, because a program that hangs after printing why is the ordinary case. StdoutLimitExceeded and StderrLimitExceeded are refusals rather than truncations: half a stream decodes into wrong data rather than into an error, so nothing is returned.

Busy is the host already running as many children as it will run at once; nothing was started, and the same command run later can succeed. Unavailable is the app shutting down while the child was running.

Runner

Cmd.Runner :: # (opaque)

Opaque process authority supplied by App.Io, scoped by the app's declared Command entries.

for_host : Authority -> Runner

Private platform construction; no application can manufacture the argument.

run! : Runner, Cmd => Try(Output, CmdErr)

Run the command and wait for the child to finish.

At most eight children run at once. The slot is taken before anything is started and released when the child has been reaped, so a Busy means precisely that no process was created. Bounding what is started is what keeps a task per child from becoming a process per task.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

update! = |model, input, io| {
    if input.devices.key_pressed(KeyE) {
        Task.spawn!(
            input,
            || match io.commands().run!(Cmd.new("git").with_args(["rev-parse", "HEAD"])) {
                Ok(output) => Revision(Str.from_utf8_lossy(output.stdout))
                Err(err) => RevisionFailed(err)
            },
        )
    }
    Ok(model)
}
run_utf8! : Runner, Cmd => Try(Utf8Output, CmdErr)

Run the command and read both streams as text.

The decoding is lossy: bytes that are not valid UTF-8 become the replacement character rather than failing the run, because a tool that puts one stray byte in a progress line has still told the app what it needed to know. Use run! when a stream has to survive exactly.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.