Thirty seconds.
Long enough for a real encode of a short clip, short enough that a task
parked on a program that will never answer is eventually collected. A
command that genuinely takes longer says so with with_timeout_ms.
:= {
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.
This is the escape hatch to tools this platform will never vendor: ffmpeg
to mux a recording into an MP4, magick to convert a sprite sheet, a solver
or a data preprocessor that already knows a format the app does not.
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 Cmd.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 not an error. It arrives as Ok(output) carrying
that code, because a program's exit status is one of the things it is trying
to tell the caller: grep says "no match" with 1, ffmpeg says "bad
arguments" with 1, and a test runner says how many tests failed. Only a
run that produced no exit status at all -- no such program, no permission to
start it, the deadline expired, more output than the app allowed -- is an
Err. That is the same rule Http follows for a 404.
No shell is involved. program is the executable and args are handed to
it one by one, so nothing here splits on spaces, expands *, follows a
pipe, or reads a redirect. A command line that needs any of that names a
shell as the program and passes the line as one argument.
Authority: running a program grants that program this process's own
authority. The child inherits the user account, the working directory, and
(unless with_clear_envs says otherwise) the environment; nothing here
restricts which executable may be named or what it may then do. Cmd is
unrestricted in exactly the way Files is -- an app that can read
/etc/hosts can run /bin/sh -- and it is deliberately more than that,
because a program the app starts is not bound by any of this platform's
rules. The one output root this platform enforces belongs to Capture, and
a subprocess trivially defeats it. Depend on Cmd when the app is trusted
with the machine it runs on, and not otherwise.
What the child does not inherit is its streams. Standard input is closed, so
a program that would have prompted reads end of stream instead of competing
with the application for the terminal, and both output streams are captured
rather than passed through, so a chatty tool cannot write over an app that
is using Stdout itself.
Shutdown kills a running child. Shutdown cancels every live task, and a
task parked in run! is interrupted through this facility's own mechanism:
the host terminates the child it started, the parked task resumes on the
cancelled path, and nothing is left running behind the app. A child that has
already replaced itself with something detached is outside that guarantee,
exactly as forced process termination is.
Thirty seconds.
Long enough for a real encode of a short clip, short enough that a task
parked on a program that will never answer is eventually collected. A
command that genuinely takes longer says so with with_timeout_ms.
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.
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.
Append one argument, exactly as the child will see it.
Append several arguments, in order.
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.
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.
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| {
if input.devices.key_pressed(KeyE) {
Task.spawn!(
input,
|| match Cmd.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 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!.
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,
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
PermissionDenied is one that is there and may not be 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 carries 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 handed back.
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.