system — processes, environment & system primitives
The system builtin category — run external commands, inspect and mutate the
process/OS environment, and produce the small security/identity primitives
(quoting, hashing, UUIDs, passwords) that glue scripting to the outside world.
List them live with mix builtins system; one-line help for any single name
with mix what NAME.
Mix is an Bus-native shell: for mesh work prefer send / emit
over shelling out, and for remote work prefer ssh_run / ssh_must /
ssh_mix over hand-built ssh command strings. In particular,
use the ssh_mix + heredoc headline idiom
for multi-line remote Mix or anything with nested quotes. This page covers
the local surface — launching processes and reading the machine. The category
listing also shows families documented on their own pages: the ssh_* builtins
(remote), http_* (http), the date/time helpers
(datetime), dns_lookup (builtins index; its
fuller sibling dns_query is covered below), and
panic (errors).
The structured-return win
The headline difference from bash: a command's result is structured data, not
$?-soup you re-parse. The local runners have explicit contracts:
run("cmd" [, {timeout: s}]) -> stdout STRING (trailing whitespace stripped); RAISES a catchable die on non-zero exit; /bin/sh
run_rc("cmd" [, {timeout: s}]) -> map {rc, stdout, stderr, timed_out, interrupted}; NEVER raises — branch on .rc; /bin/sh
run_argv(argv [, opts]) -> full process_result map; argv direct (NO shell), structured stdio routing, captured streams bounded, optional live tee; NEVER raises on command failure (v0.29.0; stream v0.47.0)
run_argv_must(argv [, opts]) -> stdout STRING (untrimmed); RAISES PROCESS_* structured errors with the result map in $err.details.result (v0.29.0)
run_pipeline(stages [, opts]) -> distinct pipeline_result map with per-stage outcomes; argv direct (NO shell); NEVER raises on ordinary pipeline failure
run_pipeline_must(stages [, opts]) -> final stdout STRING (untrimmed); RAISES PIPELINE_* with the pipeline_result in $err.details.result
run_stream(argv [, {env, clear_env, cwd}]) -> exit-code NUMBER; inherited stdio (live stream, no /bin/sh); opts v0.51.0
run_parallel(jobs [, {max, timeout}]) -> LIST of process_result maps in input order; runs many run_argv jobs concurrently with a bounded pool; one job's failure is DATA, never a raise (v0.82.0)
Pick by intent: run_argv is the default for operational code — injection-inert
argv, captured output, a real deadline, and one consistent result shape;
run_argv_must for a must-succeed step in argv form; run/run_rc when you
genuinely want /bin/sh semantics (pipes, globs) in the command string;
run_stream when the child must own the terminal (live progress, an interactive
prompt); run_argv(..., {stream: true}) when output must be both visible live and
captured for the result. The capturing runners give the child /dev/null-style
stdin by default — a child that prompts gets instant EOF, so an interactive
command needs run_stream (or run_argv's stdin: option for pre-supplied
input). Tee mode does not give the child a terminal or inherited stdin.
run_argv — structured argv execution (v0.29.0)
run_argv(argv, [opts]) runs an argv list directly (no shell anywhere),
routes stdin/stdout/stderr explicitly, enforces a deadline, and returns one
consistent map:
$r = run_argv(["pct", "start", "" .. $vmid], {timeout: 60})
if not $r.ok then
eprint("start failed (exit " .. $r.exit_code .. "): " .. $r.stderr)
end
The result map always contains, in order: ok (true only for exit 0 with no
timeout/interrupt/signal/spawn error), exit_code (number, or nil when the
child didn't exit normally), stdout, stderr (lossy UTF-8, untrimmed),
timed_out, interrupted, signal (number or nil), duration_ms,
stdout_truncated, stderr_truncated, utf8_lossy, error_code, error
(both nil unless process setup/lifecycle itself failed — PROCESS_STDIO /
PROCESS_SPAWN / PROCESS_IO / PROCESS_INTERNAL; an ordinary non-zero exit is NOT an error
here and never raises).
Options (unknown keys are a hard OPTION_INVALID error):
-
timeout: seconds, default 30,0disables, fractional ok. The clock starts before file routes are opened, so it bounds setup as well as the child and capture drains. Withtimeout: 0, Mix waits for every captured stream to reach EOF; it does not abandon a reader merely because the direct child has exited. Noterun/run_rcdefault to no deadline. -
grace: seconds, default 0, fractional ok — what happens AT the deadline.0SIGKILLs the child's process group at once (the historic hard kill). A positive grace sends SIGTERM to the group, then waits until the whole group is gone orgraceruns out, then SIGKILLs whatever is left. The grace covers the group, not only the child. Insh -c "pg_dump app | gzip > f",shmay die at once, butpg_dumpandgzipstill get the full grace to finish. A descendant that ignores SIGTERM is killed at the grace deadline, never left running. The same escalation applies when the child has already exited but a descendant still holds a captured stream open at the deadline. The call can therefore take up totimeout + grace. The result still reportstimed_out: true, andsignalsays how the child ended:15if it obeyed SIGTERM,9if it had to be killed.gracewithtimeout: 0raisesOPTION_INVALID, because without a deadline it would do nothing.$r = run_argv(["pg_dump", "app"], {timeout: 600, grace: 10, stdout: {file: "/srv/app.sql"}}) if $r.timed_out then eprint("dump cut off at the deadline (signal " .. $r.signal .. ")") end -
stdin:nilor{null: true}closes stdin; string/bytes/buffer supplies those bytes;{file: path}opens a local file for the child to read.{inherit: true}hands the child mix's own stdin, but only when that stdin is not a terminal, such as a pipe or a redirected file:-- producer | mix filter.mix $r = run_argv(["sort", "-u"], {stdin: {inherit: true}})When mix's stdin is a terminal,
{inherit: true}raisesSTDIN_TERMINALbefore anything spawns. run_argv puts its child in a new process group, so it is not the terminal's foreground group, and a terminal read would stop it withSIGTTIN. The call would then hang until its deadline. That is why there is no string form either:stdin: "inherit"is ordinary stdin data, the seven bytesinherit. Userun_streamwhen a child must own the terminal and its stdin. The same rule applies to stage 0 ofrun_pipelineand torun_paralleljobs. At most onerun_paralleljob may inherit stdin. Inheriting stdin makes sense for a script run asmix script.mixormix -, where stdin is data. A bare REPL fed through a pipe reads its own input lines from that same stdin, so an inheriting child there would compete with the REPL for the lines of the script. Two or more raiseOPTION_INVALIDbefore any job runs, because concurrent readers of one pipe would race for its bytes. Withtimeout: 0, Mix also waits for a stdin-data writer to finish after the direct child exits. A descendant which retains the read end without consuming the data can therefore make the call wait indefinitely; that is the explicit no-deadline contract, and the writer is not detached as a leaked thread. -
stdout:"capture"(default),"inherit","null", or{file: path, append?: bool, mode?: number}. -
stderr:"capture"(default),"inherit","null","stdout"(the2>&1merge), or the same file map. -
cwd,env(map overlaid on the inherited environment; keys[A-Za-z_][A-Za-z0-9_]*, values string/number/bool — a nil value REMOVES the variable from the child, theenv -uform), andclear_env(bool — start from an empty environment) retain their existing meanings. Removals apply AFTER sets, so a key both set and unset ends unset. This also applies torun_argv_must,run_paralleljobs and eachrun_pipelinestage, including captured and routed output. -
max_output: bytes per captured stream, default 8 MiB,0disables. Excess is drained and discarded — the child is never blocked or killed by the cap, and the captured stream's truncation flag is set. The same flag is true when a deadline forces Mix to abandon a capture before EOF; in that case the returned bytes are the partial prefix received before abandonment. -
stream: bool, defaultfalse; when true, captured stdout chunks are also written to the parent's stdout and captured stderr chunks to the parent's stderr as they arrive, with a flush after each chunk. Streaming continues aftermax_outputtruncates capture.stream: truewithstdout: "inherit"is rejected asOPTION_INVALID: the child already owns that stream, so teeing it is meaningless.
File output defaults to mode 0o600, append: false, and therefore truncates an
existing file. mode is the creation mode (subject to the process umask). All
local file routes are opened before the child is spawned. An open/create
failure returns the normal failure-shaped process_result with ok: false,
exit_code: nil, and error_code: "PROCESS_STDIO"; it never raises from
run_argv, and the child is not run.
On Unix, an output FIFO is opened non-blocking during setup and restored to
blocking mode for the child. With no reader its open fails immediately with
ENXIO, reported as PROCESS_STDIO (not timed_out). An input FIFO retains
normal wait-for-writer open semantics, but that wait is performed by a bounded
worker. Mix anchors the FIFO inode with a descriptor before starting the worker;
reaching the call deadline wakes that same inode and reaps the worker even if
the pathname was concurrently renamed, unlinked, or replaced, then returns
PROCESS_STDIO with no child spawned. The wake open is non-blocking. Repeated
no-writer deadlines therefore do not accumulate blocked FIFO-open threads.
Pipeline routes use the same rules and return PIPELINE_STDIO.
Every route is opened first and a non-append route is truncated only once the
whole set has opened, so a bad stderr path cannot leave the stdout path's
existing file emptied on the way to reporting PROCESS_STDIO. (The guarantee
is "nothing is truncated until everything has opened", not a transaction: if the
truncation of one route then fails — an immutable file opens but will not
shorten — an already-truncated earlier route stays truncated.) A route pointing
at a symlink is followed, as > would; a route pointing at a non-regular target
(/dev/null, a character device) is written to, never truncated.
A non-captured stdout/stderr route always leaves that result field as "" and
its *_truncated flag false. max_output does not apply to inherited, null, or
file-routed streams; Mix never silently caps them. With stderr: "stdout", both
child streams go to the selected stdout destination, the combined bytes count
against stdout's cap when stdout is captured, and result .stderr stays ""
with stderr_truncated: false.
With stream: true, a non-consuming parent stdout or stderr can block its drain
worker and delay return past timeout; the child is still killed at the
deadline. The tee-enabled check and parent-stream write are one critical
section. Before abandoning a reader Mix disables teeing under that same lock,
so an in-flight write finishes before return and later chunks from an escaped
descendant are suppressed. The captured prefix is returned with its truncation
flag set.
Kill discipline matches the whole family: the child runs in its own process group; a timeout SIGKILLs the group immediately; Ctrl-C sends SIGTERM, waits up to 2s, then SIGKILLs.
run_argv_must(argv, [opts]) accepts the same options and routing rules, and
returns $r.stdout unchanged when ok and
neither stream was truncated; otherwise it raises a structured error
(PROCESS_EXIT_NONZERO, PROCESS_TIMEOUT, PROCESS_SIGNAL,
PROCESS_INTERRUPTED, PROCESS_OUTPUT_LIMIT, or the setup/lifecycle code,
including PROCESS_STDIO)
carrying the complete result map in $err.details.result — catch with
catch $msg, $err (see errors).
run_pipeline — structured shell-free pipelines
run_pipeline(stages, [opts]) runs one or more argv stages directly and connects
stage i stdout to stage i + 1 stdin. It does not invoke /bin/sh, parse a
command string, or reinterpret argv characters:
$r = run_pipeline([
["sha256sum", "/srv/image.raw"],
["cut", "-d ", "-f1"]
], {timeout: 60})
if $r.ok then
print(trim($r.stdout))
else
eprint("pipeline failed: " .. $r.stderr)
end
A stage is either an argv list or a map:
[
{argv: ["producer"], cwd: "/srv/input", env: {MODE: "raw"}, stdin: {file: "/srv/request"}},
{argv: ["filter"], clear_env: true, env: {LANG: "C"}, stderr: "inherit"},
{argv: ["consumer"], stdout: {file: "/srv/result", mode: 0o600}}
]
Every stage map requires argv. All stages accept cwd, env, clear_env, and
stderr. Only the first accepts stdin; only the last accepts stdout (a
one-stage pipeline is both first and last). These values use the exact
run_argv grammar and validation:
stdin data/null/file, stdout capture/inherit/null/file, and stderr
capture/inherit/null/stdout/file. A misplaced route, unknown key, or invalid
option raises OPTION_INVALID before spawning; malformed stages/argv raise
TYPE_MISMATCH. An empty pipeline is invalid. A one-stage pipeline has the same
execution and familiar result fields as run_argv, plus its one-element
.stages list.
The distinct pipeline_result map always contains, in order:
ok, exit_code, stdout, stderr, timed_out, interrupted, signal,
duration_ms, stdout_truncated, stderr_truncated, utf8_lossy,
error_code, error, stages, status, failed_stage, summary.
exit_codeandsignaldescribe the last stage. They do not alone decide overall success: a middle-stage failure makes.okfalse even when the last stage exits 0.stdoutis the last stage's captured stdout, untrimmed. It is""when the last stage routes stdout elsewhere.stderrconcatenates captured stage stderr in stage order. Routed/merged stderr is absent from this aggregate.stderr_truncatedandutf8_lossyare aggregate flags.stdout_truncatedapplies to final stdout. A truncation flag also records a capture abandoned at the deadline before EOF, not only bytes discarded bymax_output.error_code/errorare normallynil. Setup/lifecycle failures usePIPELINE_STDIO,PIPELINE_SPAWN,PIPELINE_IO, orPIPELINE_INTERNALand are returned asok: false; ordinary non-zero exits and signals remain data. A later-stagePIPELINE_SPAWNis the unavoidable partial-run case:.stagescontains only stages already started and reaped. Their captured stderr is abandoned during emergency cleanup and marked truncated.
Each .stages[i] map contains, in order: index, argv, ok, exit_code,
signal, duration_ms, stderr, stderr_truncated, utf8_lossy,
accepted_signal, status, broken_pipe. Stage stderr is untrimmed.
accepted_signal records the SIGPIPE policy below; it is false for ordinary
exits and rejected signals.
Why each stage ended — status, failed_stage, summary
Gates branch on these fields, never on stderr or summary text. A stage's
status is the first of these that applies:
stage status | meaning |
|---|---|
ok | exited 0 |
timeout / interrupted | still running when the deadline / Ctrl-C made Mix signal its group — the death is Mix's, not the stage's |
broken_pipe | killed by SIGPIPE: its reader closed (also set when allow_signal accepted it — .ok carries the acceptance, status the fact) |
signal | killed by any other signal (.signal names it) |
exit_nonzero | exited with a non-zero code (.exit_code) |
setup_error | started, then killed because a later stage could not be set up |
broken_pipe (bool) is true whenever SIGPIPE killed the stage. SIGPIPE is the
only evidence of a closed reader that a stage cannot forge: a program that
ignores SIGPIPE and exits non-zero on EPIPE reports exit_nonzero.
The pipeline's status is setup_error (with error_code), else
interrupted, else timeout, else ok when .ok is true, else the status of
failed_stage. failed_stage is the rightmost stage whose ok is false —
set -o pipefail's rule — or nil. Rightmost because a downstream stage that
exits early leaves its writer with a broken pipe: in yes | sh -c 'exit 3' the
cause is stage 1 (exit_nonzero) and stage 0's broken_pipe is the symptom.
For PIPELINE_SPAWN it is the stage that could not start; other setup errors
name no stage.
summary is a one-line human rendering with no durations, so it is stable
across runs:
ok (2 stages)
ok (2 stages; stage[0] yes broken pipe accepted)
stage[1] sh exited 3
stage[0] sh killed by signal 15
stage[0] yes: broken pipe (its reader closed)
timed out (deadline 300 ms); killed stage[1] sleep
A captured stream that truncated appends [output truncated].
$r = run_pipeline([["producer"], ["filter"], ["consumer"]], {timeout: 60})
if $r.status == "timeout" then
eprint("slow: " .. $r.summary)
elif $r.status != "ok" then
$s = $r.stages[$r.failed_stage]
eprint("stage " .. $s.index .. " " .. $s.status .. ": " .. $s.stderr)
end
Pipeline options (unknown keys raise OPTION_INVALID):
-
timeout: one wall-clock deadline in seconds for the whole pipeline, starting before route and pipe setup; default 30,0disables, fractional values are accepted. A route open that reaches the deadline returnsPIPELINE_STDIObefore any stage runs. Process enforcement polls at 50 ms, exactly asrun_argvdoes, so a pipeline that finishes inside the first poll of an unreachably short deadline is reported as a normal completion, not a timeout. A deadline bounds a wedge; it is not a stopwatch. -
max_output: bytes per captured stream, default 8 MiB,0disables. It caps final stdout and each captured stage stderr independently; excess is drained and flagged, not allowed to block a child. -
allow_signal: bool, defaultfalse. By default every stage must exit normally with code 0, so any signal death — SIGPIPE included — makes.okfalse. Setallow_signal: trueto accept a non-final stage killed by SIGPIPE when every downstream stage succeeded, which is what the ordinary early-reader idiom needs:$r = run_pipeline([["yes"], ["head", "-1"]], {allow_signal: true})The default is
falsebecause the signal is all Mix can see. A stage that kills itself with SIGPIPE for a fatal reason of its own is indistinguishable from one whose reader simply closed early, so accepting it by default reports this as success:run_pipeline([["yes"], ["sh", "-c", "printf fatal >&2; kill -PIPE $$"], ["true"]])The middle stage announced a fatal condition and killed itself, every downstream stage exited 0, and
.okwould have beentrue. Reporting that as success is the silent-wrong-answer class this surface exists to remove, so the honest answer is the default and the ergonomic one is opt-in. This matchesset -o pipefail, which likewise reports 141 foryes | head -1.Per-stage
.accepted_signalrecords where an acceptance was applied.
Every stage's file routes and every inter-stage/data/capture pipe are prepared
before any stage is spawned. Non-append routes are truncated only after the
full set has opened. A later open or pipe-creation failure therefore returns
PIPELINE_STDIO with no stage run; the truncation pass itself is not a
transaction if one truncation succeeds and a later one fails.
Every stage leads its own process group. The pipeline owns one deadline: expiry
SIGKILLs every stage group, including descendants, and sets timed_out: true.
Ctrl-C sends SIGTERM to every group, allows the same two-second grace as
run_argv, then SIGKILLs survivors. A descendant that deliberately escapes its
stage group can retain a captured descriptor; Mix waits only a short bounded
drain window, then returns the captured prefix with the relevant truncation
flag true rather than waiting indefinitely for EOF. When timeout: 0 disables
the deadline, capture instead waits for EOF and is never abandoned merely
because every direct stage has exited. A supplied stdin-data writer is likewise
joined: a descendant which holds its read end without consuming can make an
explicit no-deadline pipeline wait indefinitely, but cannot leave a detached
native writer thread behind.
run_pipeline_must(stages, [opts]) accepts the same forms. It returns final
stdout unchanged only when the aggregate .ok is true and no captured stream
was truncated. Otherwise it raises PIPELINE_EXIT_NONZERO, PIPELINE_TIMEOUT,
PIPELINE_SIGNAL, PIPELINE_INTERRUPTED, PIPELINE_OUTPUT_LIMIT, or the
setup/lifecycle code above. The complete pipeline_result is always available as
$err.details.result. A stage failure is raised from that result's own
status and failed_stage, so the raise and the result always agree. A
signal or broken_pipe status gives PIPELINE_SIGNAL, anything else gives
PIPELINE_EXIT_NONZERO, and the message names failed_stage. For
yes | sh -c 'exit 3' that is PIPELINE_EXIT_NONZERO for stage 1, not
stage 0's broken pipe.
run — stdout string, fail-fast
run(cmd) runs cmd through /bin/sh -c, returns its stdout as a string with
trailing whitespace stripped (leading whitespace is preserved), and on a
non-zero exit (or signal kill) raises a catchable die carrying the
command excerpt, the exit status, and a tail of stderr.
$out = run("echo hello world")
print($out)
print("len: " .. length($out))
hello world
len: 11
The die on failure — wrap a must-succeed step in try/catch:
try
run("false")
catch $e
print("caught: " .. ("" .. $e))
end
caught: run: 'false' failed (rc=1)
The die message includes a stderr tail when there is one (run: 'cmd' failed (rc=2): <stderr tail>, last 200 characters). A signal-killed child reports the
shell convention rc = 128 + signo — SIGTERM shows as failed (rc=143), not a
separate signal=N form. The excerpt and tail are run through
sanitize so a hostile command/stderr can't smuggle control
characters into your logs.
run_rc — the {rc, stdout, stderr, timed_out, interrupted} map
run_rc(cmd) runs the same way but never raises — it returns a map so you
branch on the exit code yourself. stdout and stderr are both stripped of
trailing whitespace. The timed_out / interrupted bools are always present
(false on a normal exit) — see Timeouts below.
$r = run_rc("ls /nonexistent-xyz")
print("rc=" .. $r.rc)
print("stderr=" .. $r.stderr)
rc=2
stderr=ls: cannot access '/nonexistent-xyz': No such file or directory
The idiomatic exit-code branch — the structured-return payoff:
$r = run_rc("systemctl is-active some-service")
if $r.rc != 0 then
print("not running")
else
print("active: " .. $r.stdout)
end
rc is the real exit code as a number. A signal-killed child reports
128 + signo (SIGTERM → 143); -1 means the timeout deadline fired and
-2 means Ctrl-C interrupted the wait (see below) — never confuse those with a
child's own exit code. Trailing newlines are stripped, so a one-line tool's
stdout is clean to compare or concatenate:
$r = run_rc("printf 'a\nb\n\n'")
print("[" .. $r.stdout .. "]")
[a
b]
Timeouts
run and run_rc take an optional second argument — an opts map with a single
timeout key, in whole seconds (every ssh/run timeout in Mix is seconds; the
Bus send … timeout=2000 form is the only millisecond surface). The default is
0 = no deadline — the historic contract, so a long build never gets cut
off by surprise. With a deadline set, a hung child can never wedge the shell:
$r = run_rc("sleep 5", {timeout: 1})
print("rc=" .. $r.rc .. " timed_out=" .. ("" .. $r.timed_out))
rc=-1 timed_out=true
try
run("sleep 5", {timeout: 1})
catch $e
print("caught: " .. ("" .. $e))
end
caught: run: 'sleep 5' timed out after 1s
The full status contract:
| Outcome | run | run_rc |
|---|---|---|
| normal exit | stdout string / die on non-zero | rc = exit code |
| deadline fired | dies (catchable): timed out after Ns | rc: -1, timed_out: true |
| Ctrl-C during the wait | raises run: interrupted | rc: -2, interrupted: true |
| child signal-killed | dies: failed (rc=128+sig) | rc = 128 + signo (SIGTERM → 143) |
Kill mechanics (the same machinery as ssh_run): the child is
spawned in its own process group, so the kill reaches every descendant — an
ssh helper or forked worker can't keep the pipes open past the deadline. A
timeout SIGKILLs the group immediately (run_argv can opt into SIGTERM first
with grace); a Ctrl-C sends SIGTERM, waits a 2-second grace, then SIGKILLs.
Every SIGTERM path waits for the whole group to empty, bounded by its grace,
then SIGKILLs the group. A descendant that honours SIGTERM can finish its
cleanup; one that ignores it is killed at the deadline, not orphaned. On Linux
the child is reaped only after that, so its zombie keeps the group id
reserved and every signal reaches the right group. "The group has emptied"
is read from /proc, and any record Mix cannot read counts as still alive.
A member hidden from /proc entirely, by another pid namespace or
hidepid=2 for another user's processes, cannot be counted. There the grace
may end early, but the SIGKILL still reaches the whole group. An interrupt that lands on the same poll as the deadline
wins the tie — it's reported as the cause.
The opts map is validated loudly — a mistake can't silently leave a call unbounded:
run_rc("echo hi", {bogus: 1}) -> Runtime error: run_rc: unknown opt "bogus" (supported: timeout)
run_rc("echo", {timeout: 1.5}) -> Runtime error: run_rc: timeout must be a non-negative integer, got 1.5
run_rc("echo", "notamap") -> Runtime error: run_rc: opts must be a map like {timeout: 30}, got string
run_rc("echo", {timeout: 1}, "x") -> Runtime error: run_rc() expects at most 2 argument(s), got 3
run_stream takes no timeout opt; it blocks until the child exits, which is
the point of handing it the terminal. Since v0.51.0 it has an options map, so
{timeout: 5} is no longer a silently-ignored surplus argument — it raises
OPTION_INVALID naming run_argv as the runner that honours a deadline (as do
stdin, stdout, stderr, max_output and stream). spawn doesn't need one — it returns
immediately and you kill() the PID yourself. The http_* builtins have their
own deadline (default 30 s) — see http.
run_stream — live stdio, argv list, no shell
run_stream(argv_list) runs an argv list directly — no /bin/sh, so no
word-splitting, globbing, quoting, or operator interpretation. Each list element
is one argument verbatim, which makes user values injection-inert by
construction. It inherits the parent's stdin/stdout/stderr (output streams live
as it happens) and returns the exit code as a number.
$code = run_stream(["echo", "streamed", "live"])
print("exit code: " .. $code)
streamed live
exit code: 0
Use it for a foreground one-shot where the child should own the terminal — live
progress (a build, a long copy) or an interactive prompt (a password, an apt
confirmation) — provided the command allocates a pty itself, e.g.
run_stream(["ssh", "-t", $host, $cmd]). A signal-killed child reports
128 + signo (the shell convention).
It is strict about its argument: a non-list, an empty list, or a non-string element all raise rather than silently stringifying:
try
run_stream("ls")
catch $e
print("caught: " .. ("" .. $e))
end
caught: run_stream: argument must be a list of strings, got string
Because it blocks the evaluator until the child exits, keep it out of a hot
on … async event handler — there it stalls dispatch like run/run_rc
would, and there's no terminal for interactivity anyway.
run_stream options — env, clear_env, cwd (v0.51.0)
The optional second argument is {env, clear_env, cwd}, with exactly the
semantics run_argv gives those
three keys — same name validation ([A-Za-z_][A-Za-z0-9_]*), same NUL
rejection, same OPTION_INVALID code, all checked before the child is
spawned. The order is clear-then-layer, so {clear_env: true, env: {…}} means
"exactly these":
$code = run_stream(["npm", "install"], {
cwd: "/srv/app",
env: {NODE_ENV: "production", CI: "1"}
})
Values reach the child through execve, not a shell — spaces, globs and a
literal $HOME in a value survive verbatim.
Use it instead of prefixing the argv with coreutils env. That old workaround
still runs, but it puts every value in the child's ps argv, where any user on
the box can read it — the option does not, which makes it the route for a token
or password handed to an interactive child. The export statement keeps a
value out of argv as well — it is the mechanism behind ssh_run's
default mix env transport and ssh_mix — but it mutates this process's
environment: the variable stays set for the rest of the run, and every child
spawned after it inherits the value unless that child clears or overrides it.
The option is scoped to the single call.
⚠️ On a mix older than 0.51.0 the map is not an error — default compatible
arity silently ignores a surplus argument, so the child runs with the inherited
environment, the inherited cwd, and no sign that anything was dropped. That is
a fail-open version boundary. A caller that may meet a mixed-version fleet
should install the new binary everywhere before the caller, run under
--strict-arity (where the old contract's arity raises ARITY_MISMATCH), or
probe the option behaviourally: spawn ["/bin/sh", "-c", "test x$SENTINEL = xVALUE"] with the sentinel in env and check the exit code before trusting
the real run. Prove the sentinel is absent from the parent first — an
inherited value passes the probe on the very binary the probe exists to catch.
The four run_argv-only keys are refused by name rather than ignored:
timeout (this runner blocks until the child exits), stdin (it inherits the
parent's), and max_output / stream (it captures nothing). Each error names
run_argv as the runner that does honour the key.
⚠️ A bare argv[0] is resolved against the child's PATH, not the
parent's — so env: {PATH: "/nonexistent"} makes run_stream(["sh", …]) fail
to spawn, even though sh is on yours. (clear_env: true alone leaves no
PATH at all, and the C library's default path still finds /bin/sh on Linux —
but that is the platform's fallback, not a guarantee.) Pass an absolute
argv[0] whenever you touch PATH or clear the environment; see
the minimal-PATH rule. Pass one under cwd too — a
relative argv[0] is resolved in a directory the OS deliberately leaves
platform-specific, so run_stream(["./build.sh"], {cwd: "/srv/app"}) is not
portable; spell it /srv/app/build.sh.
run_parallel — process-level fan-out (v0.82.0)
run_parallel(jobs [, {max, timeout}]) runs many run_argv jobs at once through
a bounded worker pool and returns a list of process_result maps in input
order — each map is exactly what the same run_argv call would return, so
existing result-handling code ports unchanged. One job's ordinary failure
(nonzero exit, timeout, a spawn error) is data in its map, never a raise.
-- Fan a health check across a fleet; collect every result.
$hosts = ["alpha", "beta", "gamma"]
$jobs = map($hosts, fn($h) = ["ssh", $h, "uptime"])
$results = run_parallel($jobs, {max: 8, timeout: 10})
for each $i in range(0, len($hosts) - 1) -- range is INCLUSIVE: len-1, never len
$r = $results[$i]
print($hosts[$i] .. ": " .. ($r.ok ? trim($r.stdout) : "DOWN (" .. $r.exit_code .. ")"))
end
A job is either an argv list (["ssh", $h, "uptime"]) or a {argv, …}
map carrying the same per-job options run_argv accepts (stdin, cwd,
env, clear_env, stdout, stderr, max_output, timeout). max bounds
concurrency (default 8, hard-capped at 256 live workers — a larger max
still runs every job, just no more than 256 at once); a top-level timeout
(seconds) overrides every job's own. A job may not disable its deadline
(timeout: 0 is refused): one never-exiting job would park a worker forever and,
because the call waits for all workers, hostage the whole batch. A parse error in
any job fails the whole call before a single process spawns, exactly as
run_argv validates before spawning.
This is PROCESS-level fan-out, not in-language concurrency. The evaluator is
single-threaded by construction and Mix Values are never shared across
threads: run_parallel parses every job to plain owned data first, and the
worker threads touch only process plumbing (argv, pipes, exit codes), marshalling
results back on the caller's thread. There is deliberately no parallel(list, fn) that runs Mix functions concurrently — that would mean rebuilding the value
model, and it is not planned. A fleet sweep that walked N nodes serially becomes
one run_parallel of N ssh jobs; for Mix source, ssh_mix_many
is the same pool with ssh_mix's bindings, decode and result shape kept. A job's
stream flag is ignored (a parallel live tee would interleave into garbage).
send_mail — report mail through the local MTA
send_mail(msg [, {host, sendmail, timeout, mix_bin}]) is the last step of every nightly
report: render a plain-text message and hand it to the MTA. It replaces the
hand-written .eml file, the run_argv(["/usr/sbin/sendmail", "-t", "-f", $from], {stdin: {file: …}}) and the cleanup.
$r = send_mail({
to: ["ops@example.com", "Audit <audit@example.com>"],
from: "Reports <reports@example.com>",
subject: "Weekly spam report",
body: $report
})
if !$r.ok then
eprint("mail failed: " .. $r.exit_code .. " " .. $r.stderr)
end
msgneedsto(a string or a list of strings),from,subjectandbody.headersis an optional map of extra headers. Any other key raises. There is noccorbcckey: putCcandBccinheaders.- The message is built for you:
From,To,Subject,Date(UTC),Message-ID,MIME-Version: 1.0andContent-Type: text/plain; charset=utf-8. A CRLF body is normalised to LF. - Line limits are kept. A long header (a long subject, forty recipients)
is folded at whitespace so lines stay within 78 characters; a header with
an unbreakable run over 998 characters raises. An ASCII body whose lines
all fit in 998 bytes goes as
7bit. A non-ASCII body, or one with a longer line (a JSON or CSV dump), goes asquoted-printable, so no MTA can split or mangle it. - A non-ASCII subject is RFC 2047-encoded (as
rfc2047_encodedoes it). Every other header value must be ASCII, and a CR, LF or NUL in any header raises. There is no way to inject a second header through a value. headersadds headers, or replaces a generatedDate,Message-IDorMIME-Version.From,ToandSubjectcome only frommsg, andContent-TypeandContent-Transfer-Encodingonly fromsend_mail, which encodes the body itself. Naming any of those five inheadersraises. A label of your own would describe other bytes than the ones sent: a quoted-printable body labelled8bitreads as literal=C3=BC. The body is alwaystext/plain; charset=utf-8.- Delivery is
sendmail -t -i -f <envelope>with the message on stdin, never a network SMTP client.-ttakes the recipients from the headers, so aBccheader works and is stripped by the MTA.-ikeeps a line holding a lone.as body text. fromis exactly one mailbox:a@example.comorDisplay Name <a@example.com>, where the display name is plain words or one"quoted string". The envelope sender is that address. A comment, a second mailbox and the null sender<>raise rather than guess. An address without@(a local user such asroot) is sent as it is, and theMessage-IDdomain then falls back tolocalhost.- Which sendmail: the first
sendmailonPATH, then/usr/sbin/sendmail, then/usr/lib/sendmail. Thesendmailoption names one explicitly. hostsends from another host — the one with a working MTA — throughssh_exec. The message is still built locally; only the sendmail run is remote, with/usr/sbin/sendmailas the default there. The result carrieshostasssh_exec's does, and only then.mix_bin(withhostonly) names the remote mix binary the hop runs, resolved as forssh_mix:mix_bin, else localMIX_REMOTE_BIN, else/opt/mixos/bin/mix.- The result is
run_argv'sprocess_resultplusmessage_id.okmeans the MTA accepted the message for submission, not that it was delivered. An MTA that refuses the message, or a sendmail that is not there, is DATA:ok: falsewithexit_code,stderranderror_code. - Bad input raises before anything runs:
OPTION_INVALIDfor a badmsgfield or option,TYPE_MISMATCHfor amsgthat is not a map or the wrong number of arguments, and withhost,ssh_exec's own validation errors. timeoutis in seconds, as forrun_argv(default 30).
Mail that a filter must recognise should use one fixed from for every
report, with the envelope matching it, which send_mail does by
construction.
Which runner?
| Need | Use |
|---|---|
| stdout string, abort on failure | run |
| inspect a non-zero exit code as data | run_rc |
| live output / interactive child / inject-safe argv | run_stream |
| run Mix source on another node | ssh_mix + heredoc |
| run a shell snippet on another node | ssh_run / ssh_must |
| talk to a local/mesh Bus broker | send / emit |
What the command string sees
run/run_rc hand the whole command string to /bin/sh -c, so POSIX-sh
syntax inside it just works — and three constructs behave differently there
than on a shell-dispatch line:
-
Subshell
( … )/ brace-group{ …; }grouping: shell dispatch (the REPL /mix -cshell branch) does not support POSIX command grouping — a bare(echo a; echo b) | sortat the prompt fails because a leading(classifies the line as Mix, whereecho ais not a valid parenthesized expression (and ordinary expression parentheses do not permit;either); a mid-line group gets word-split. The fix is not a.shfile — hand the whole pipeline to/bin/shas one Mix line:$r = run_rc("(echo b; echo a) | sort") -- {rc, stdout, stderr}; group runs in /bin/sh print($r.stdout) print(run("(cd /tmp; pwd) | tr a-z A-Z"))a b /TMPOn a mix-login-shell node a remote
(…; …)hits the remote classifier the same way. Ship multi-line Mix without nested escaping viassh_mix+ heredoc. -
$(...)command substitution: literal in a double-quoted Mix string, but it passes through in arun/run_rccommand string — it substitutes in the/bin/shthat actually runs the command (local here; the remote shell forssh_run). -
Brace expansion
{a,b}/{1..5}: a shell-dispatch feature, not a/bin/shone — braces in arun/run_rccommand string pass through to POSIX sh, which does no brace expansion. Use a dispatch line or a Mix loop when you need it.
⚠️ The minimal-PATH rule
run, run_rc, and spawn go through /bin/sh with whatever PATH the parent
process had — non-interactively that is minimal, and ~/.mixrc aliases are
not loaded (those only apply to interactive mix -i). The safe habit:
call binaries by full path inside scripts.
-- fragile: bare name may not be on /bin/sh's PATH non-interactively
run("mix --version")
-- robust: absolute path always resolves
print(run("/opt/mixos/bin/mix --version"))
mix 0.21.2
run_stream takes the program name as argv[0] and resolves it against PATH the
same way — full-path that too if you can't guarantee the environment. The rule
of thumb: inside scripts, always full-path (/opt/mixos/bin/...). The one
context where a bare mix does resolve is as the ssh command itself
(ssh host 'mix status') — the login-shell mix self-resolves via its own
executable path, no PATH needed (remote).
Background processes — spawn, kill, process_alive
spawn starts a background process and returns its PID as a number. It does
not wait, reap, or supervise — it owns nothing after it returns (that is
run_argv's / a supervisor's job). It has two forms, chosen by the first
argument's type.
Shell form — spawn(cmd[, stdout_path[, stderr_path]]), a string command
run via /bin/sh -c with stdin from /dev/null. Stdio routing by arity:
- 1 arg — both stdout and stderr →
/dev/null - 2 args — stdout → file (truncated), stderr →
/dev/null - 3 args — stdout → file1, stderr → file2; pass the same path for both to merge them into one combined log (like bash
&>file)
$p = spawn("echo logged-line", "/tmp/spawn.log")
sleep(0.2)
print(run("cat /tmp/spawn.log"))
logged-line
All three shell-form arguments are strings and none is coerced (strict since
v0.52.0) — spawn needs loud validation most, because it is the only runner
with nowhere to put a failure: it returns a PID, not a result map, so a child
that dies on its first line looks exactly like one that worked. A non-string
raises TYPE_MISMATCH at argument validation, before any stdio file is opened
(so a NUL in stderr_path can no longer truncate the stdout_path file on the
way to failing).
Argv form — spawn(argv[, {detach, die_with_parent, exit_event, tag, cwd, env, clear_env, stdout, stderr}])
(v0.89.0), a list of strings run directly, with no shell — so no
word-splitting, glob expansion, or quoting surprises. This is the launcher /
daemon slot: the job that used to force run("setsid app &") through sh.
spawn(["bterm", "--profile", "work"]) -- argv, no shell
spawn(["mydaemon"], {detach: true}) -- new session (setsid),
-- survives the caller,
-- drops the terminal
spawn(["worker"], {cwd: "/srv/app", env: {ROLE: "bg"},
stdout: {file: "/var/log/worker.log", append: true}})
-
detach: true→ the child is put in a new session (setsid): it has no controlling terminal and its own session, so a terminal hangup or the caller exiting does not take it down — the daemon shape, stronger than the shell form's&. (Session separation, not immortality: a service-cgroup teardown or an explicit signal still reaches it.) Since v0.92.0 it is also double-forked: the child is reparented to init (or the nearest subreaper), which reaps it — so a long-lived caller such as amix --servelauncher citizen is never left holding zombies, and "owns nothing after it returns" is literally true. The returned PID is the real (grandchild) process, and it still leads its own session. Because init reaps it, the pid is FREE as soon as the child exits and the kernel may hand it to an unrelated process: a laterkill($pid)orprocess_alive($pid)can hit a stranger. A live pid only says SOME process holds that number, and a pidfile alone says no more — a stale one names whoever holds that pid now. To stop a detached daemon later, check a pidfile together with the process start time (field 22 of/proc/<pid>/stat, recorded when the pid was written), or ask a Bus verb the child itself answers, rather than trusting a bare pid. Defaultfalse(a plain child in the caller's session, which stays the caller's to reap). -
die_with_parent: true(Linux), withoutexit_event, → the opposite slot. The child ends with this mix process, for a helper that must not outlive the script or--servecitizen that started it:$pid = spawn(["goose", "serve", "--port", "7070"], {die_with_parent: true})The child leads its own process group. Two mechanisms end it:
- Graceful exit. This covers the script ending,
exit(), a--servecitizen's QUIT or SIGTERM drain, and a REPL restart. Mix sends SIGTERM to the child's whole process group, waits up to 2 s for the group to empty, then SIGKILLs whatever is left. The child gets its chance to clean up, and its own children go too. --serveRELOAD. The old generation's owned children are ended the same way before the new script's init runs. An init that starts its helper again therefore never races a leftover one for the same port. If the reload then reverts, because the new init failed, the old script resumes without those children. Anything the failed init had already spawned withdie_with_parentis ended too, so a failed reload leaks no helpers.- Hangup of the interactive shell. When the terminal goes away, the job-control shutdown sweeps owned children the same graceful way before it exits.
- Crash, or a signal mix does not handle. If mix is SIGKILLed, panics
or is OOM-killed, the kernel SIGKILLs the child (
PR_SET_PDEATHSIG). The same happens when a plainmix script.mixor a non-interactive mix receives SIGTERM, SIGHUP or SIGQUIT, because those still end mix at once by default. That path reaches the child only, not its descendants, and gives it no chance to clean up. A--servecitizen is different: its SIGTERM is a graceful drain, so the sweep runs. A script that must clean up its helper's own children on SIGTERM should run as a--servecitizen or be stopped with Ctrl-C, which ends the evaluation normally and so reaches the sweep.
detachtogether withdie_with_parentraisesOPTION_INVALID, because they contradict each other. Mix itself is the only thing that reaps an owned child.process_aliveanswers for it without freeing its pid, so the group id cannot be recycled while it is registered. A finished child whose group has emptied is reaped at the nextprocess_aliveorspawn. One whose descendants are still running is kept as a zombie until the sweep ends them. PDEATHSIG is keyed to the thread that calledspawn, and the ownership registry is process-wide. So the option works only on a thread whose host owns it. Themixbinary evaluates on one thread that lives until exit and sweeps before it ends. Elsewhere, for example webd, mixos-mcp or mixos-claud evaluating on pooledspawn_blockingthreads,die_with_parentraisesOPTION_INVALID. An embedder that does own a long-lived evaluation thread opts in by callingbuiltins::owned_spawns::enable()on it. That call returnsfalse, and changes nothing, if another thread enabled first; only the first thread is ever the host. The embedder must also callsweep()on that same thread before it exits. Off Linux the option raisesOPTION_INVALID. Defaultfalse: a plain spawn child is untouched by mix's exit. Withexit_event:true, use the generation lifetime below instead. - Graceful exit. This covers the script ending,
-
cwd/env/clear_envbehave exactly as inrun_argv(clear-then-layer:{clear_env: true, env: {…}}starts from empty). -
stdout/stderrreuserun_argv's routing, minus capture:"null"(default),"inherit", or a{file, append?, mode?}map — andstderr: "stdout"to merge."capture"is refused: capturing means waiting, which isrun_argv's job. A file-open failure means the child is not spawned. -
argv must be a non-empty list of strings, none coerced; a non-string element or an empty list raises
TYPE_MISMATCH.
spawn(["true"], {stdout: "capture"}) -> OPTION_INVALID: stdout cannot be "capture"
spawn(["echo", 42]) -> TYPE_MISMATCH: argv[1] must be a string
spawn(7) -> TYPE_MISMATCH: cmd must be a string
A NUL byte in any string argument is rejected at validation, before any stdio
file is opened. The error code follows where the argument is validated: the
shell form's positional command and path args, and the argv form's argv
elements, are TYPE_MISMATCH; the argv form's option values — cwd, env,
and a file route's {file: …} path — are OPTION_INVALID. std would reject a
NUL at spawn anyway, but catching it early keeps a late failure from truncating
a good log on the way down.
Managed child exit events
spawn(argv, {exit_event:true, tag:"scene:12"}) -> pid starts a managed Linux
child. tag is an optional string of at most 4096 bytes (default ""), copied
unchanged into on proc.exited's $event.args:
{pid,tag,exit_code,signal}. Normal exit supplies an exit code and nil signal;
signal death supplies nil exit code and the signal number. An unexpected reap
failure has both nil and additional {error_code:"PROC_REAP",message} fields.
Use tags to reject exits from stale application generations.
Mix owns the child and is its sole reaper. A pidfd and a cancellation descriptor
wake the monitor; it does not poll process state. Up to 128 managed children and
undelivered exits can be admitted per evaluator. detach:true is incompatible;
die_with_parent:true additionally applies the existing Linux parent-death
protection. The generation lifetime
governs reload, shutdown and descendant cleanup. Managed retirement does not
deliver a terminal event to another evaluator generation.
kill($pid, signal) and process_alive($pid) use the registered pidfd while the
child is retained, without competing for its exit status. After the exit has
been delivered and the slot reclaimed, the numeric PID is no longer an owned
identity: discard it. Negative PID/group operations keep their existing raw
meaning. Do not reap managed children from an embedding application's SIGCHLD
handler or another thread. PROC_LIMIT, PROC_UNSUPPORTED (including kernels
without pidfd support), and PROC_MONITOR are catchable setup refusals; invalid
options use OPTION_INVALID. A failed monitor setup kills and reaps the child.
Teardown waits for the kernel to complete reaping; an uninterruptible kernel I/O
wait can delay it despite SIGKILL.
Signalling and liveness
kill(pid[, signal]) sends signal (default 15 = SIGTERM) and returns a bool
(true if the syscall succeeded). Both arguments are whole numbers and
neither is coerced (strict since v0.52.0) — the same rule as spawn, and for
a sharper reason:
kill(false) -- 0.51.0: to_number(false) is 0, and kill(0, sig) signals
-- EVERY process in the caller's own process group — the
-- script and its siblings — while returning true
-- 0.52.0: raises TYPE_MISMATCH
kill($p, "SIGKILL") -- 0.51.0: the signal silently fell back to SIGTERM, so the
-- caller believed SIGKILL had been sent
-- 0.52.0: raises TYPE_MISMATCH
kill($p, 9.5) -- 0.51.0: truncated to 9; 0.52.0: raises (a typo, not a request)
A pid arriving as false from a failed lookup is exactly how that first line
happens in practice. Signal names are not accepted — pass the number (9 for
SIGKILL); kill(-$pgid, sig) still addresses a process group deliberately.
process_alive(pid) is a liveness probe
(signal-0 test) returning a bool; it first does a non-blocking waitpid(WNOHANG)
to reap a zombie child of this process before checking — so a spawned child
that has already exited reports false, not a stale "alive" from a <defunct>
slot.
$p = spawn("sleep 30")
print("alive: " .. ("" .. process_alive($p)))
print("killed: " .. ("" .. kill($p)))
alive: true
killed: true
A signal to a non-existent PID is harmless and reports the failure honestly:
print("" .. kill(999999))
print("" .. process_alive(999999))
false
false
kill(pid, 9) sends SIGKILL; pass any signal number you need.
Desktop status events — net_watch, audio_watch
A status applet needs to know when the network or the volume changes. Reading
/sys/class/net/*/operstate on a timer, or running wpctl get-volume every
minute, is polling: late when something changes and wasted work when nothing
does. These builtins subscribe instead. They share the
native-event machinery of
fs_watch and managed spawn: opaque evaluator-owned handles, coalesced
batches in $event.args, sticky overflow, cancellation on unwatch, retirement
with the evaluator generation, and no clock anywhere.
$net = net_watch()
$audio = audio_watch({runtime_dir: env("XDG_RUNTIME_DIR")})
fn network_summary()
$up = false
for each $link in net_state().links
if $link.up and not $link.loopback then $up = true end
end
return $up
end
on net.changed
-- The batch says what moved; net_state() says what is true now.
print("network up: " .. to_string(network_summary()))
end
on audio.changed
if type($event.args.closed) == "map" then
print("audio source ended: " .. $event.args.closed.message)
else
$vol = audio_state({runtime_dir: env("XDG_RUNTIME_DIR")})
print($vol.ok ? to_string($vol.level) .. "%" .. ($vol.muted ? " muted" : "") : "no sink")
end
end
Events are hints, state is truth. A batch tells a behaviour that something
changed and roughly what; the model is rebuilt from net_state() /
audio_state(). Always re-read on overflow:true.
Network: net_watch([opts]) -> handle
One rtnetlink socket per handle, subscribed to RTMGRP_LINK,
RTMGRP_IPV4_IFADDR and RTMGRP_IPV6_IFADDR, read by a thread blocked in
poll(2) with no timeout. opts.events narrows it to ["link"] or ["addr"].
Each wake drains every queued datagram into one net.changed batch:
{watch, overflow, closed?, changes: [
{kind: "link", ifname, index, up, operstate, loopback, wireless, removed},
{kind: "addr", ifname, index, up, family: "inet"|"inet6", address, prefix, removed}
]}
upon a link is operationally up: operstateup, or — for drivers that reportunknown, such asloand WireGuard —IFF_UPandIFF_RUNNINGtogether. On an address it means present (falsewithremoved:true).- Records coalesce per link index and per address within a batch: a link that flaps down and up in one burst arrives once, with its last state.
- Announcements that change nothing are dropped — wireless drivers re-announce the link on every scan, IPv6 re-announces addresses on every lifetime refresh.
- A kernel overrun (
ENOBUFS), a truncated or malformed message setsoverflow:true; the next repeat is then delivered rather than suppressed.
net_state() -> {links, addresses} is the same information from a netlink dump
(links[]: ifname, index, up, operstate, loopback, wireless; addresses[]:
ifname, index, family, address, prefix). A dump the kernel marks interrupted is
retried; three in a row raise NET_STATE_INCONSISTENT. The whole call, retries
included, answers within 2 s or raises NET_STATE_IO. Like audio_state() it
blocks the evaluator while it waits, so a serve citizen calls both from an
async handler (a task_start task), not from a sync request path. Only a
dump reply carrying the request's sequence number is read.
If the subscription socket itself fails (a poll error, or a receive error other
than an overrun), the handle delivers one batch with
closed: {error_code: "NET_WATCH_IO", message} and overflow:true, then stays
silent: unwatch it and subscribe again when you choose to.
The socket is raw libc: three fixed headers and a few attributes do not justify
linking a netlink crate into every Mix build. net_* are Env class
(read-only observation of the host).
Audio: audio_watch([opts]) -> handle
PipeWire publishes changes through its pulse-compatible server, and
pactl subscribe prints one line per change and otherwise blocks. Each handle
owns one such child: its stdout is read by a thread blocked in read(2), and each
read becomes one audio.changed batch:
{watch, overflow, closed?, changes: [{facility, kind, index}]}
facility is sink, source, server, card by default (opts.facilities
may name any of sink source sink-input source-output module client sample-cache server card); client is left out by default because every pulse client
connecting would otherwise be an event. kind is new, change or remove;
index is nil for server. Changes coalesce per facility and index.
The volume itself is audio_state([opts]) -> {ok, volume, level, muted, reason?}:
one wpctl get-volume @DEFAULT_AUDIO_SINK@ with a 2 s deadline. level is
round(volume * 100). No default sink, a wpctl that is missing or cannot
start, unreadable output or a timeout is ok:false with a reason, not an
error. The deadline holds on every path,
including a kernel without pidfd_open. Call it once per batch: a burst of
notices costs one read.
Why a child and not a library. No PipeWire client crate is in the
workspace, and libpipewire would bring a C library and its main loop into every
Mix build. A long-lived subscription child is an event stream — the process is
idle until the server has news — so this is not polling. Its lifecycle is owned:
the child runs in its own process group with LC_ALL=C (the parser reads
English) and XDG_RUNTIME_DIR from opts.runtime_dir when given; unwatch or
evaluator retirement SIGKILLs the group and reaps it. If pactl exits by itself
(PipeWire restarted, no server yet), the handle delivers one batch with
closed: {error_code: "AUDIO_SOURCE_EXITED", message, exit_code} and
overflow:true, then stays silent: unwatch it and subscribe again when you
choose to (a behaviour typically retries after a delay from an async handler).
If the Mix process itself is SIGKILLed, the mix binary's children carry
PR_SET_PDEATHSIG(SIGKILL) and die with it; in an embedder that has not made
its evaluator thread the owned-children host, pactl ends at its next write.
AUDIO_UNAVAILABLE (no pactl) will not change by retrying; treat it as final.
audio_* are Process class (they run programs).
Limits and refusals
At most 16 net and audio handles together per evaluator, and 1024 distinct
pending records per handle (beyond that the batch sets overflow). A top-level
sleep() in a plain script dispatches these only when on net.changed /
on audio.changed is registered. All six builtins are denied in expression
mode. Refusals raise with {error_code, message}:
| Code | Meaning |
|---|---|
NET_WATCH_OPTIONS, AUDIO_OPTIONS | Invalid options. |
NET_WATCH_ARGUMENT, AUDIO_WATCH_ARGUMENT | Handle is not a string. |
NET_WATCH_HANDLE, AUDIO_WATCH_HANDLE | Unknown, retired, or other-family handle. |
NET_WATCH_LIMIT, AUDIO_WATCH_LIMIT | 16-handle limit reached. |
NET_WATCH_IO, NET_STATE_IO | Netlink socket or dump failure. |
NET_STATE_INCONSISTENT | Dump interrupted three times running. |
AUDIO_UNAVAILABLE | pactl not on PATH. |
AUDIO_WATCH_IO | Could not start or reap the pactl child. (audio_state never raises: a wpctl that cannot start or be read is ok:false with a reason.) |
*_UNSUPPORTED | Platform is not Linux. |
NATIVE_CLOSED | This evaluator's native registrations have retired. |
Environment, identity & the working directory
env("NAME") environment variable value ("" if unset — never raises)
env("NAME", d) value, or default d when NAME is unset OR empty
args() list of script arguments
pid() this process's PID (number)
uid() this process's EFFECTIVE user id (number)
gid() this process's EFFECTIVE group id (number)
groups() every group id this process is in (sorted list of numbers)
hostname() the system hostname (from /etc/hostname)
cwd() current working directory
chdir(path) change the working directory (raises on failure)
platform() {os, arch} map
which("cmd") the PATH entry joined with cmd if EXECUTABLE, else nil
has_builtin(n) does THIS mix have the named builtin? -> bool (v0.78.0)
mix_version() {major, minor, patch, string} — the runtime version (v0.78.0)
script_version() the entry script's provenance map, nil outside a script (v0.95.0)
script_path() the entry script's absolute path, fixed at start; nil outside a file
exit([code]) unwind finally, then terminate with status code (default 0)
sleep(secs) suspend for secs seconds (fractional ok; async-aware)
has_builtin(name) and mix_version() are the portability pair — for
a script that must run across a mixed-version fleet, or a compat shim.
mix builtins NAME exits 0 for any name and cannot tell you whether a
builtin exists, so has_builtin is the real yes/no:
if not has_builtin("ws_connect") then die("this needs mix >= 0.74") end
$v = mix_version()
if $v.major == 0 and $v.minor < 78 then die("needs mix >= 0.78.0") end
A feature-gated builtin missing from this build still reads true from
has_builtin (the binary knows the name — calling it raises "requires the
X feature"); that is the honest answer, distinct from a name the binary
has never heard of.
script_version() is the script-side twin of mix_version(): the running
entry script's {name, version, sha, sha256, modified, mix: {version, sha, dirty}} — the facts mix SCRIPT --version prints, as data, so a script can
log its own provenance. version comes from the -- version: X.Y.Z header
(nil when absent); the map is nil in the REPL and under -c. See
--version for scripts.
script_path() answers the question $0 leaves open: where the running
script lives on disk. It is realpath($0) — symlinks resolved, absolute —
worked out once, when the script starts, before any of its code runs.
So a relative script name is resolved against the directory the script was
started in: a chdir() later in the script does not move the answer. A
symlinked entry answers with its target, not the link, the same as
realpath($0), so a script finds its siblings in the real tree. The answer
is not re-checked later: a file deleted after start still reports its path.
It is nil under mix -c, in the REPL, for mix - (stdin), and when the
path cannot be resolved at start. It always describes the entry script:
a required module calling it, even during its own top-level init, gets the
entry script's path, not its own. mix --serve resolves its script the same
way, once, at start.
There is no module_path(); a module's own location is not a lexical fact
the evaluator tracks, so a required module must take its directory from
the entry script or from its caller.
-- where am I?
$data = path_join(dirname(script_path()), "data.mix")
env reads an environment variable, returning "" (not nil, no raise) when
unset — distinct from the string-interpolation ${NAME} form, which walks
scope → env → literal-nil. Use env(...) when you want an explicit env read.
A second argument is a default: env("NAME", d) returns d when NAME is
unset or set-but-empty (shell ${VAR:-default} semantics) — the common "env
or fallback" need in config code, where the plain ""-for-unset return would
otherwise defeat a ?? (nil-coalesce) default. The default is returned
verbatim (any value), so env("PORT", 8080) yields the number 8080. The
one-arg form is unchanged.
print(env("FOO"))
print("missing=[" .. env("NOPE_XYZ") .. "]")
print(env("NOPE_XYZ", "fallback"))
print(env("PORT", 8080))
bar
missing=[]
fallback
8080
(run as FOO=bar mix script.mix)
args() is the script's positional arguments — exactly the same list $1,
$2, … are indexed from, so the two can never disagree. It is what the runner
parsed, not a slice off the process command line: flags to mix itself are not
in it, wherever they appear, and under -c the program text is not in it
either. An embedded interpreter that was never given script arguments returns
[]. Reach for getopt when you want flag parsing.
-- script.mix:
print("" .. args())
[alpha, beta]
(run as mix script.mix alpha beta)
uid() / gid() are the effective ids (geteuid/getegid) — normally the
identity filesystem access is checked against, so they are what a stat() map's
uid/gid should be compared with when a script is deciding whether a path is
its own. Strictly, Linux checks the filesystem ids, fsuid/fsgid; they track
the effective ids unless a process deliberately changes them with setfsuid(2),
which a Mix script has no way to do — so for Mix code the two are the same
answer, and for a Mix interpreter embedded in something that does call it, they
are not.
$st = stat("/etc/hostname", {follow_symlinks: false})
print("mine=" .. ("" .. ($st.uid == uid())))
print("root-owned=" .. ("" .. ($st.uid == 0)))
mine=false
root-owned=true
(as a non-root user)
Without these the only way to learn your own uid was to create a file and stat
it — and that probe is a liability, not a measurement: the write follows
symlinks, so another user who wins the race gets a file of their choosing
truncated under your identity, and the uid that comes back is theirs. Ask the
kernel instead. Under sudo these report the target identity, not the invoking
one; there is deliberately no real-uid form, because the effective id — with the
fsuid caveat above — is the one a file access is checked against, and the real
id is not.
groups() is the whole group membership — the getgroups(2) supplementary
set plus the effective gid, sorted, with no duplicates. (POSIX permits
getgroups to leave the effective gid out; this always includes it, so a caller
never has to add it back. The kernel may also report the same gid twice; the
duplicate is removed here, so the result is a set in the ordinary sense.)
getgroups(2) is a measure-then-fill pair, so the set can change underneath it.
Mix cannot call setgroups, which means this needs another thread in an
embedder — which is exactly the case that matters, since the daemons embed this
interpreter. A set that shrinks is truncated to what was returned; a set that
grows makes the fill call fail, and groups() re-measures and retries rather
than reporting "cannot tell", because the answer is available and only the first
measurement was stale. The retry is bounded at four attempts: a group set that
will not hold still for four reads raises instead of returning a guess.
It exists because gid() alone cannot answer the question scripts actually ask
of it. Unix picks one permission class and does not fall through: owner if
the uid matches, else group if you are in the file's group, else other. So for a
file owned by someone else, gid() tells you only whether its group is your
effective one — and a file grouped under any of your other groups gets the
group bits applied while a gid()-only check reaches for the other bits and
gets the answer wrong in either direction. Mode 0701 in a group you belong to
is not executable by you, whatever the other bit says.
$st = stat("/usr/bin/passwd", {follow_symlinks: false})
$mine = false
for each $g in groups()
if $g == $st.gid then
$mine = true
end
end
print("in its group=" .. ("" .. $mine))
print("groups=" .. ("" .. len(groups())))
in its group=false
groups=3
platform() returns a map, not a bare string — os (linux, macos,
windows, …) and arch (x86_64, aarch64, …):
$p = platform()
print("os=" .. $p.os)
print("arch=" .. $p.arch)
os=linux
arch=x86_64
which searches PATH for an executable, returning the matching path or nil
(absolute whenever PATH's entries are, which is the normal case — but it is the
PATH entry joined with cmd, so a relative or empty entry yields a relative
answer):
print(which("sh"))
print("missing: " .. ("" .. which("definitely-not-a-real-binary-xyz")))
/usr/bin/sh
missing: nil
It answers "can I run this?", not "does this exist?" (since v0.52.0). A PATH
entry comes back only when it is a regular file and the kernel says this
process may execute it — asked with the same ACL-aware faccessat2(2) check
access uses, never from stat().perm arithmetic. Before 0.52.0 the
test was is_file(), so a non-executable file on PATH was reported as a
command and the caller's very next run_argv failed to spawn it — a probe
whose job is to prevent that failure causing it instead.
Both halves are load-bearing: X_OK is true for a searchable directory, so a
PATH entry holding a directory named git would come back as the git binary if
the regular-file test were dropped. And cmd is a string, not coerced —
which(["git"]) raises TYPE_MISMATCH rather than searching for a file named
[git].
The consequence worth knowing: on a PATH you do not control, which can now
return nil where it used to return a path. That is the honest answer — the
old one was a spawn failure deferred by one line.
cwd / chdir read and set the working directory; chdir raises a catchable
error if the path doesn't exist:
chdir("/tmp")
print(cwd())
/tmp
sleep suspends for the given seconds (fractional allowed). Under the Bus
--serve runtime it is async-aware — a sleep with registered handlers
yields to the dispatch loop rather than blocking the OS thread:
print("before")
sleep(0.2)
print("after")
before
after
The duration may be a number or numeric string (sleep("0.2")). A supplied
value that cannot be parsed as a number raises TYPE_MISMATCH; it no longer
silently becomes a zero-second sleep.
exit([code]) stops ordinary execution, unwinds every active finally block
innermost-first, then terminates with the given status (default 0):
print("exiting")
exit(3)
exiting
(process exit status is 3)
The optional status may be a number or numeric string. exit() still defaults
to status 0; a supplied non-numeric value raises TYPE_MISMATCH instead of
silently exiting successfully.
Safe interpolation — shell_quote, sql_quote, sanitize
When you must compose a string for an external shell or SQL statement, quote
the untrusted parts. These are the inert-by-construction helpers — but prefer
run_stream's argv list (no shell at all) or a parameterised query whenever you
can.
shell_quote
shell_quote(s) single-quote-wraps s for a POSIX shell (equivalent to PHP
escapeshellarg): the result is inert under shell parsing, with internal '
rendered as '\'' (close, escape, reopen).
print(shell_quote("it's a file"))
'it'\''s a file'
$name = "rm -rf /; echo pwned"
print(run("echo " .. shell_quote($name)))
rm -rf /; echo pwned
sql_quote
sql_quote(s) escapes a string for interpolation inside a SQL string literal:
it doubles every ' and escapes \ → \\ (safe under MySQL/MariaDB's default
sql_mode, the documented target — quote-doubling alone is injectable there), and
strips NUL bytes. It does not add the outer quotes — you compose those.
print(sql_quote("O'Brien"))
O''Brien
It stays safe for SQLite/Postgres standard mode (where \ is literal — the
trade-off is a literal backslash arrives doubled). For exact-byte SQLite
literals, use a real binding (sqlexec() placeholders), not string composition.
sanitize
sanitize(s) makes untrusted bytes safe for one-line diagnostics: line
breaks (including U+2028/U+2029) collapse to spaces, and C0/C1 controls plus
Trojan-Source-class characters (bidi overrides, zero-width spoofing, BOM) become
?. Printable Unicode is kept intact. This is what run's die message uses on
the command excerpt and stderr tail.
$s = "line1\nline2\ttab"
print(sanitize($s))
line1 line2?tab
Reach for it before logging anything that came from a network peer, a filename, a header, or another process's output.
Identity & crypto primitives
uuid() random UUID v4 string
random_password([len]) alphanumeric password (default 16, no O/o, class-diverse)
hash_sha256(s[, {raw:true}]) SHA-256 digest of a string/bytes/buffer value
hash_blake3(s[, {raw:true}]) BLAKE3 digest
hash_md5(s[, {raw:true}]) MD5 digest -- BROKEN hash, legacy interop only
hash_sha1(s[, {raw:true}]) SHA-1 digest -- BROKEN hash, legacy interop only
hmac_sha256(key, msg[, {raw:true}]) HMAC-SHA256 (RFC 2104) — webhook signatures
jwt_rs256_sign(claims_json, private_pem[, header_json]) compact RS256 JWT
constant_time_eq(a, b) timing-safe equality — compare MACs/secrets with this, not ==
hash_file(p[, algo][, {raw:true}]) streaming digest of a FILE
algo: "sha256" (default) "blake3" "md5" "sha1"
(all of the above: lowercase hex, or bytes with raw:true)
base64_encode(v) base64 of a string or bytes buffer
base64_decode(s) decode base64 -> a bytes buffer
password_hash(pw[, cost]) bcrypt "$2b$…" hash; cost 4-31, default 12 (v0.71.0)
password_hash(pw, {scheme: "sha512-crypt"[, rounds]})
"$6$…" SHA512-crypt hash; rounds 1000..999999999,
default 5000 (v0.102.6). The salt is 16 chars; default
rounds emit the implicit glibc form "$6$salt$hash"
password_verify(pw, hash) check against a bcrypt OR SHA-crypt hash -> bool;
accepts $6$/$5$ and the Dovecot {SHA512-CRYPT} prefix
(v0.71.0, sha-crypt v0.102.6)
password_hash/password_verify exist for account passwords written over
the Bus: a raw props.set of maild.accounts.password stores the field
verbatim (only the account add CLI hashed it), so the hashing has to happen
client-side — which used to mean shelling out to PHP (bcrypt) or mkpasswd -m sha512crypt (Dovecot passdbs, whose {SHA512-CRYPT}$6$… records are
what the sha512-crypt scheme and the verify prefixes interop with). Three
deliberate edges: input over 72 bytes raises on the bcrypt side (bcrypt's
own truncation limit, surfaced instead of silently applied — hash a digest of
longer secrets); a malformed hash raises in password_verify rather than
answering false (a corrupt stored hash is a config fault, and "wrong
password" would misdirect the operator), and so does a $6$ hash whose salt
is longer than 16 chars, because glibc and Dovecot can never verify one; and
the general-purpose digests
above are not password hashes — hash_sha256 has no work factor.
Every hash_* call takes the same trailing options map, and the only option is
{raw: true} (v0.66.0): return the digest as raw bytes instead of
lowercase hex. hash_sha256($x, {raw:true}) is 32 bytes, hash_md5 16,
hash_sha1 20, hash_blake3 32, and hmac_sha256 32 — which lets a MAC be
compared with constant_time_eq without a hex round trip:
$mac = hmac_sha256($secret, $payload, {raw: true})
if constant_time_eq($mac, base64_decode($header)) then
print("signature ok")
end
Before 0.66.0 a second argument was silently ignored:
hash_sha256("abc", {raw:true}) returned the hex string and said nothing. That
is fixed, and so is the class of bug behind it — an unknown option key or a
non-map option now raises OPTION_INVALID rather than being discarded.
raw is a strict boolean, unlike bytes_to_string's lossy, which accepts
any truthy value. The difference is deliberate: raw selects the return type,
so {raw: "false"} — a string that a config file or an argument might easily
produce — would hand back bytes and fail somewhere far from the cause.
{raw: "false"} raises; only true and false are accepted.
⚠ MD5 and SHA-1 are broken — legacy interop only
hash_md5 and hash_sha1 exist to talk to formats and tools that already chose
those algorithms: Content-MD5 headers, mail dedup keys, git object ids, older
ETags and vendor APIs, and checksums you need to compare against an existing
md5sum/sha1sum output. Mix computes them so a script does not have to fork
coreutils, not because they are fit for anything new.
They must never carry a security decision. MD5 has had practical collisions
since 2004 and SHA-1 since 2017 (SHAttered), so neither can establish that two
inputs are the same, that a document is unmodified by an adversary, or that a
signature is valid. For anything of that kind use hash_sha256 or hash_blake3,
and for authentication hmac_sha256 with constant_time_eq. Note that Mix
classifies both as capability-Pure — that means "touches no host authority",
which is not the same as "safe"; the sandbox has no opinion about your choice of
hash.
The digests are the standard ones and interoperate exactly:
$ printf 'The quick brown fox jumps over the lazy dog' > /tmp/fox
$ mix -c 'print(hash_md5(read_file("/tmp/fox")))'
9e107d9d372bb6826bd81d3542a419d6
$ md5sum /tmp/fox
9e107d9d372bb6826bd81d3542a419d6 /tmp/fox
hash_file streams with a fixed 64 KiB working set whatever the file's size, so
it is the right call for anything large — hash_sha256(read_file_bytes($p))
holds the whole file in memory to compute the same answer.
uuid() is a fresh random v4 each call:
print(uuid())
0a5fcc5b-ad60-4e8e-9e61-b52f14a067e7
random_password([len]) generates an alphanumeric password from OS entropy
(OsRng), defaulting to length 16. It guarantees at least one upper, one
lower, and one digit, and excludes the confusable O/o. len must be an
integer in 3..=1024; out of range raises.
print(random_password())
print(length(random_password()))
print(random_password(8))
qALPecswZfH3YkP0
16
ZDv4WBFl
hash_sha256 / hash_blake3 return the lowercase hex digest of the input
(string or bytes buffer):
print(hash_sha256("hello"))
print(hash_blake3("hello"))
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f
hmac_sha256(key, msg) is the keyed twin (RFC 2104): the hex HMAC-SHA256 of
msg under key (both accept string/bytes/buffer). Its everyday job is
verifying webhook signatures — e.g. Stripe's Stripe-Signature v1 value is
hmac_sha256(endpoint_secret, timestamp .. "." .. payload):
print(hmac_sha256("Jefe", "what do ya want for nothing?"))
5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
Compare a computed MAC against a received signature with
jwt_rs256_sign(claims_json, private_pem[, header_json]) signs a compact
JWT with an RSA private key in PKCS8 or PKCS1 PEM form (crypto feature).
The RSA modulus must be at least 2048 bits. The first PEM item must be the
private key; certificate-first bundles are refused.
Pass JSON objects as strings, typically from json_encode. The default
header is {"alg":"RS256","typ":"JWT"}. A supplied header keeps its
extra fields such as kid; a missing alg is filled with RS256, and
any other algorithm is refused. Explicit nil means the default header.
Input JSON is parsed and re-encoded, so duplicate object keys collapse to
the final value. Each input is limited to 256 KiB. Malformed or non-object
JSON raises JWT_CLAIMS_INVALID / JWT_HEADER_INVALID; unsupported or
invalid keys raise JWT_KEY_INVALID, and signing failure raises
JWT_SIGN_FAILED. These errors do not echo input values.
The primitive signs only: scripts choose claims, expiry and audience, exchange tokens and call any provider. Keep keys in files or bindings, rather than source literals. For example:
$claims = json_encode({iss: $issuer, aud: $audience, iat: time(), exp: time() + 3600})
$jwt = jwt_rs256_sign($claims, read_file($key_file))
Compare MACs with constant_time_eq(a, b), never == — plain equality short-circuits on the
first differing byte, a timing oracle. constant_time_eq scans the full
length unconditionally (a length mismatch returns false immediately; MAC
lengths are public):
print(constant_time_eq(hmac_sha256("Jefe", "payload"), hmac_sha256("Jefe", "payload")))
print(constant_time_eq("deadbeef", "deadbee5"))
true
false
hash_file(path[, algo]) hashes a file the same way, but reads it as a
64 KiB-chunked stream — so a multi-hundred-MB artifact (a release image, a
rootfs tarball) is digested with bounded memory instead of
hash_sha256(read_file(path)) slurping the whole file into a string (which
also rejects non-UTF-8). algo defaults to "sha256"; "blake3", "md5" and
"sha1" are the others (the last two added in v0.66.0 — read the warning
above before reaching for either). It takes the same trailing {raw: true}
as the in-memory family. The sha256 output is byte-identical to hash_sha256 over the
same bytes, so it verifies against any sha256sum. Capability: FsRead (it
opens a path). This is the factory's "name every release artifact + its
digest, then sign the manifest" primitive.
print(hash_file("/srv/mixos-factory/releases/2026-07-06-001/rootfs.tar.gz"))
print(hash_file("./image.raw", "blake3"))
base64_encode accepts a string or a Value::Bytes buffer (it encodes the
raw bytes, not a placeholder). base64_decode returns raw bytes — since
v0.64.0 those can be indexed, sliced, iterated and searched directly (see
io), or wrap in
bytes_to_string (strict UTF-8; pass {lossy:true} to tolerate non-UTF-8) to
read it back as text. The pair round-trips:
$enc = base64_encode("hello mix")
print($enc)
print(bytes_to_string(base64_decode($enc)))
aGVsbG8gbWl4
hello mix
These (and base64_*, uuid) are behind the crypto feature, which the mix
binary always enables.
DNS queries — dns_query (v0.117.0)
dns_query(name, type[, {server, port, timeout, tcp, recurse}]) -> result map
dns_lookup(host) asks the system resolver for addresses and nothing else.
dns_query asks one server one question and returns the whole answer, so
a script can tell an authoritative answer (aa: true) from a cached one, or
an out-of-zone REFUSED from NXDOMAIN, without dig. The wire format is
hickory-proto (pure Rust, the dns feature); no libresolv.
typeis a record type name, case-insensitive:A,AAAA,MX,TXT,SRV,NS,SOA,PTR,CNAME,CAAand the rest.PTRof an IP address asks its reverse name, asdig -xdoes.nameis always fully qualified: there is no search list.serveris an address or a host name (default: the firstnameserverin/etc/resolv.conf, else127.0.0.1),portdefaults to 53,timeoutto 5 seconds for the whole query (resolving a server host name, a TCP retry and a slowly delivered reply all count against it), andrecurse(defaulttrue) sets the RD flag.tcp: trueforces TCP; a truncated UDP answer is retried over TCP on its own.
A reply, whatever its rcode, is ok: true:
{ok: true, rcode: "NOERROR", aa: true, ra: false, tc: false,
answers: [{name: "mail.example.com.", type: "AAAA", ttl: 300, data: "2001:db8::25"}],
authority: [], additional: [], server: "192.0.2.53", port: 53,
transport: "udp", duration_ms: 3}
rcode is dig's name (NOERROR, NXDOMAIN, SERVFAIL, REFUSED, ...).
Each record's data is its zone-file text (10 mail.example.com. for an MX;
a TXT record's strings joined). Names end in a dot.
No reply is an operational failure, returned rather than raised, as
ssh_exec does: {ok: false, error_code, error, server, duration_ms} with
error_code one of DNS_TIMEOUT, DNS_UNREACHABLE (ICMP port unreachable,
connection refused), DNS_PROTOCOL (only malformed replies), DNS_IO or
DNS_SERVER (the server name did not resolve). A reply with another id is
ignored, not taken as the answer. A bad argument (an unknown type or option,
an invalid name) raises.
-- Which servers answer for mail.example.com with authority?
for each $ns in ["192.0.2.53", "198.51.100.53"]
$r = dns_query("mail.example.com", "AAAA", {server: $ns, recurse: false})
if not $r.ok then
print($ns .. ": " .. $r.error_code)
else
print($ns .. ": " .. $r.rcode .. " aa=" .. $r.aa)
end
end
UDP datagrams — udp_send, udp_recv (v0.71.0)
udp_send(host, port, payload) -> bytes sent; payload string/bytes/buffer, verbatim
udp_recv(port[, opts]) -> {bytes, text, from_host, from_port} | nil on timeout
opts: timeout (secs, default 30, 0 = forever),
host (bind addr, default "0.0.0.0"),
max (read cap, default 65535)
One datagram in, one out — deliberately not a socket API: there is no
handle to hold, leak, or close. Before these, a unicast SSDP NOTIFY meant
run_argv(["nc", "-u", …], {stdin: $pkt}). udp_send resolves the host and
reports the byte count the kernel accepted (UDP being UDP, that is sent,
never delivered). udp_recv binds, waits for one datagram, and
returns nil on timeout — an ordinary answer for a datagram wait, not an
error. text is the payload decoded as UTF-8 or nil; bytes always
carries the truth. A datagram longer than max is truncated to it —
recvfrom(2)'s own contract — and the default max of 65535 can never
truncate, because no UDP payload exceeds it.
$notify = "NOTIFY * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n\r\n"
udp_send("192.0.2.20", 1900, $notify) -- unicast SSDP to the TV
$got = udp_recv(9999, {timeout: 5})
if $got != nil then
print("from " .. $got["from_host"] .. ": " .. ($got["text"] or "<binary>"))
end
WebSocket client — ws_connect, ws_send, ws_recv, ws_close (v0.74.0)
ws_connect(url[, opts]) -> numeric handle; opts: insecure (skip TLS verify),
headers (map), timeout (secs, default 30, connect+handshake)
ws_send(h, payload) -> nil; string -> text frame, bytes/buffer -> binary
ws_recv(h[, timeout]) -> string | bytes | nil on timeout (default 30, 0 = forever)
ws_close(h) -> true when live, false when unknown (never raises for that)
A synchronous client with recv-driven control flow — the shape every
stateful request/response protocol needs: send register, wait for the
registered frame, then issue requests and parse each response (an
alertId echoed back in a closeAlert, a pairing prompt to wait out).
A blind (cat msgs; sleep) | websocat pipeline only handles
fire-and-forget; this is the difference that turned the LG SSAP TV driver
from a bash FIFO coprocess into a plain Mix loop. Home-Assistant-style
device control and Grafana live are the same shape.
The contracts worth knowing:
insecure: trueskips TLS certificate verification — for the self-signed device endpoint (wss://tv:3001/), never for services with real certificates.ws_recvreturnsnilon timeout — an ordinary answer; poll again. Ping/pong never surface (answered internally). A peer close RAISES (catchable) and retires the handle: "no more frames, ever" must not be confusable with "no frame yet".- A closed/raised handle is retired — further calls say
unknown ws handlerather than repeating the close error. - Handles are process-global numbers (the
sqlopenpattern); the kernel reaps the sockets at exit,ws_closeis the tidy path. One user per handle: a handle is checked out of the registry for the duration of a call, so a concurrent call on the SAME handle answersunknown ws handlerather than queueing. ws_sendwrites under the connect timeout: a very large frame on a slow link can raise mid-flush with bytes still buffered — the next send on that handle continues the partial frame (no framing corruption).
$h = ws_connect("wss://192.0.2.20:3001/", {insecure: true})
ws_send($h, json_encode({type: "register", payload: $manifest}))
loop
$frame = ws_recv($h, 5)
if $frame == nil then continue end -- quiet: keep waiting
$msg = json_parse($frame)
if $msg["type"] == "registered" then break end
end
ws_send($h, json_encode({type: "request", uri: "ssap://api/getServiceList"}))
print(ws_recv($h, 10))
ws_close($h)
Raw TCP client — tcp_connect, tcp_send, tcp_recv, tcp_recv_line, tcp_close (v0.78.0)
tcp_connect(host, port[, {timeout, tls, insecure}]) -> handle
tcp_send(h, payload[, {timeout}]) -> bytes sent (string/bytes/buffer)
tcp_recv(h[, {timeout, max}]) -> bytes | nil on timeout
tcp_recv_line(h[, {timeout, max}]) -> string (LF+CR stripped) | nil
tcp_close(h) -> bool
The stream-socket primitive for a line-or-binary protocol that UDP, WS
and HTTP don't cover — an SMTP/IMAP probe, a redis PING, memcached
stats, a banner grab. tls: true wraps the connection (ring-pinned
rustls, webpki roots); insecure: true skips certificate verification
for a self-signed endpoint. Handles are process-global numbers like
sqlopen/ws_*, one user per handle, reaped by the kernel on drop.
tcp_recv returns whatever bytes are available (up to max, buffered
bytes first) — bytes, not a string, because a stream has no message
boundary; nil on timeout is an ordinary answer (poll again), and a peer
close RAISES and retires the handle. tcp_recv_line is the
line-protocol form: it buffers across reads and returns one line with its
\n (and a trailing \r) stripped, so a caller speaking SMTP or redis
never hand-rolls a \r\n scanner over accumulated bytes. max caps the
line length (refused past it, so a peer that never sends a newline can't
grow the buffer without bound).
$h = tcp_connect("mx.example.com", 25, {timeout: 10})
print(tcp_recv_line($h, {timeout: 10})) -- 220 banner
tcp_send($h, "EHLO probe.local\r\n")
loop
$line = tcp_recv_line($h, {timeout: 5})
if $line == nil then break end
print($line)
if not starts_with($line, "250-") then break end -- 250<space> = last line
end
tcp_close($h)
Send deadline (v0.112.1). Without options, tcp_send writes the
whole payload with each write bounded by the socket's 30 s write timeout,
so a reader that drains a few bytes at a time can stretch one send far
past 30 s. tcp_send(h, payload, {timeout: 5}) instead bounds the WHOLE
send by one deadline, computed once (seconds; 0 waits forever, like
tcp_recv): writes are non-blocking and the wait for room happens on the
async runtime, so SIGTERM and Ctrl-C end it too. The deadline is checked
before every write, so a peer that keeps draining without ever filling
the socket cannot carry the send past it. On expiry it raises
TCP_SEND_TIMEOUT with details: {written, total} and retires the
handle, because a partly written message leaves the stream unusable.
Bytes already written stay written; the peer may have received part of
the payload. Ctrl-C in a plain script raises TCP_SEND_INTERRUPTED the
same way. On a tcp_on handle the timeout becomes the send's receipt
deadline (0 keeps the source's 30 s bound), and expiry raises
TCP_SEND_TIMEOUT with details: {total} (the reader thread does not
report how much it wrote).
try
tcp_send($h, $frame, {timeout: 2})
catch $msg, $err
if $err.code == "TCP_SEND_TIMEOUT" then
print("peer stalled after " .. $err.details.written .. " bytes")
end
end
On unix a numeric tcp_recv/tcp_recv_line (and ws_recv) waits on the
async runtime rather than in a blocking read, the way sleep() does, so
SIGTERM and Ctrl-C end the wait at once (v0.112.1; before, a SIGTERM
waited for the read's timeout). Data already buffered is returned first,
however short the timeout, while traffic that delivers nothing (WebSocket
control frames, part of a line) still counts against it: a timed-out
tcp_recv_line returns nil and keeps the partial line for the next call.
Options, results and errors are those of
the blocking form. A pending read in a Class S handler still holds the
handler's turn for its duration, so {timeout: 0} in a --serve
citizen can still stall other handlers.
Raw TCP server — tcp_listen, tcp_accept, tcp_local_addr (v0.112.0)
tcp_listen(host, port[, {backlog}]) -> listener handle
tcp_accept(listener[, {timeout}]) -> connected handle | nil on timeout
tcp_local_addr(handle) -> {host, port}
tcp_close(listener) -> bool
The listening half of the client above, so a script can be a small TCP
service (a fake line-JSON app for a test, a local control port) without
socat. tcp_listen binds and listens. host is required and there is
no default: "127.0.0.1" is the normal choice, and a non-loopback
address ("0.0.0.0", "::", a LAN IP) works only when you name it.
Port 0 takes an ephemeral port, and tcp_local_addr says which one.
backlog (default 128, 1 to 4096) bounds the connections the kernel
queues before tcp_accept takes them.
tcp_accept waits for one connection and returns an ordinary connected
handle: tcp_send, tcp_recv, tcp_recv_line, tcp_on and tcp_close
all work on it. nil on timeout (default 30 s, 0 waits forever) is an
ordinary answer and the listener stays usable. Listeners take their
numbers from tcp_connect's counter, so a number is never both: a
connection verb given a listener raises TCP_LISTENER, and tcp_accept
given a connection raises TCP_ACCEPT_HANDLE. tcp_close on a listener
stops listening at once and returns true; false means it was already
closed.
-- A line-JSON echo server: one client at a time, plain blocking style.
$l = tcp_listen("127.0.0.1", 0)
$addr = tcp_local_addr($l)
print("listening on " .. $addr.host .. ":" .. $addr.port)
loop
$c = tcp_accept($l, {timeout: 0})
loop
try
$line = tcp_recv_line($c, {timeout: 30})
catch $msg, $err
break -- the client hung up
end
if $line == nil then break end -- idle client: drop it
$req = json_parse($line)
tcp_send($c, json_encode({ok: true, echo: $req}) .. "\n")
end
tcp_close($c)
end
Errors are structured; read $err.code from catch $msg, $err:
| Code | Raised when |
|---|---|
TCP_LISTEN_ADDR_IN_USE | another socket already listens on the address |
TCP_LISTEN_PERMISSION | the bind needs privilege (a port below 1024) |
TCP_LISTEN_ADDR_UNAVAILABLE | host is not an address of this machine |
TCP_LISTEN_ADDRESS | host does not resolve |
TCP_LISTEN_ARGUMENT | a bad host, port or option |
TCP_LISTEN_FAILED | any other bind or listen failure |
TCP_ACCEPT_HANDLE | an unknown listener, or a connection handle |
TCP_ACCEPT_ARGUMENT | a bad option |
TCP_ACCEPT_FAILED | accept(2) failed on the listener |
TCP_HANDLE_LIMIT | 1024 live server-side sockets: every listener and accepted connection holds a slot until it closes. The limit is server-side only; tcp_connect is not counted or limited |
TCP_LISTENER | a connection verb was given a listener |
Serve mode and Class C. On unix every numeric tcp_accept waits on
the async runtime, like sleep(): SIGTERM and Ctrl-C end it at once, and
plain and Class S waits also recheck the interrupt flag every 250 ms. A
Class S handler still holds its turn while it waits. In a Class C async
body the numeric form waits on native readiness with the read permit
released, so other handlers keep running. For a --serve citizen the
event form is usually the better fit: tcp_on($listener, "app.conn")
moves the listener into a reader thread and delivers one event per
accepted connection (see Socket subscriptions below). A subscribed
listener refuses numeric tcp_accept (SOCKET_SUBSCRIBED) and
tcp_close; end it with tcp_unwatch.
Ownership. A listener, and every connection tcp_accept or a
subscribed listener hands out, belongs to the evaluator generation that
made it: retiring the generation (a --serve reload or shutdown, or the
evaluator going away) closes them, including one that is out in a Class C
tcp_recv at that moment (it closes when the read returns). tcp_connect handles keep their
process-wide lifetime. A reload's candidate runs while the old
generation still holds its listeners, so it cannot bind the same fixed
port (TCP_LISTEN_ADDR_IN_USE): a served listener should bind port 0
and publish its port. Handing a fixed port across a reload is not
supported yet.
Unix sockets — unix_connect (v0.115.0)
unix_connect(path[, {timeout, frame}]) -> handle (tcp_connect's handle space)
A client for a local unix-domain stream socket: systemd's Varlink
services (/run/systemd/resolve/io.systemd.Resolve, networkd's
io.systemd.Network) and other local services that speak a framed
protocol. The handle is an ordinary connected handle, so tcp_send,
tcp_recv, tcp_recv_line, tcp_on and tcp_close work on it unchanged.
tcp_local_addr refuses (TCP_LOCAL_ADDR): a unix socket has no TCP
address.
pathis an absolute filesystem path of at most 107 bytes. Abstract sockets are not offered.timeout(seconds, default 30) bounds the connect and each write. A listener whose backlog is full fails at once (UNIX_CONNECT_BUSY) instead of blocking the evaluator.frame: "nul"makes a "line" a NUL-terminated frame.tcp_recv_linereturns the next frame without its NUL (no CR is stripped), andtcp_on(h, cmd, {frame: "line"})delivers one event per frame. Sends are verbatim: end each request withchr(0)yourself. The default,frame: "line", keeps LF lines astcp_connectdoes.
-- One Varlink call to systemd-resolved: NUL-framed JSON.
$h = unix_connect("/run/systemd/resolve/io.systemd.Resolve", {frame: "nul", timeout: 5})
tcp_send($h, json_encode({method: "io.systemd.Resolve.ResolveHostname",
parameters: {name: "example.org"}}) .. chr(0))
$reply = json_parse(tcp_recv_line($h, {timeout: 5}))
tcp_close($h)
print(json_encode($reply.parameters.addresses))
| Code | Raised when |
|---|---|
UNIX_CONNECT_NOT_FOUND | no socket file at path |
UNIX_CONNECT_PERMISSION | the socket file's mode refuses this user |
UNIX_CONNECT_REFUSED | a socket file with nothing listening behind it |
UNIX_CONNECT_BUSY | the listener's backlog is full |
UNIX_CONNECT_TIMEOUT | the connect did not finish within timeout |
UNIX_CONNECT_ARGUMENT | a bad path or option |
UNIX_CONNECT | any other connect failure |
A frame longer than max raises and retires the handle, whether or not its
NUL has arrived; from 0.115.0 an over-long LF line does the same. Ownership is tcp_connect's: the handle lives for
the process until tcp_close.
Both UDP builtins above, all four ws_*, and the tcp_* family are Network-class (below), like http_* and dns_lookup —
and like those, the wait happens inline on the evaluator's thread. In a
--serve citizen that means a pending udp_recv blocks the whole event pump
for its duration; never pass {timeout: 0} there — unlike an HTTP call,
where the wait rides an in-flight request, a datagram wait with no sender is
unbounded and nothing can interrupt it.
Socket subscriptions — ws_on, tcp_on, ws_unwatch, tcp_unwatch (v0.107.0)
ws_on(handle, command) -> source id ("ws:N")
tcp_on(handle, command[, opts]) -> source id ("tcp:N")
opts: {frame: "line" | "bytes" (default), max: line length (default 65536)}
ws_recv(source[, timeout]) -> string | bytes | nil (Class C)
tcp_recv(source[, {timeout, max}]) -> bytes | nil (Class C)
tcp_recv_line(source[, {timeout}]) -> string | nil (Class C)
tcp_on(listener, command) -> source id ("tcp:N") (v0.112.0)
tcp_accept(source[, {timeout}]) -> handle | nil (Class C)
ws_unwatch(source) / tcp_unwatch(source) -> nil
The event-stream shape of the clients above. ws_on/tcp_on move an
existing ws_connect/tcp_connect handle into a reader thread owned by
the evaluator generation, and return a string source id. The reader is
the connection's single owner (tungstenite's TLS state is never
shared or raced), so numeric ws_recv/ws_close/tcp_recv/
tcp_recv_line/tcp_close on the subscribed handle refuse
deterministically (handle N is a ws_on subscription — …). Numeric
ws_send/tcp_send keep working: they route through the owner
thread's bounded command endpoint (a Send-safe channel + wake socketpair
registered at subscribe time, removed when the source retires), and
await a completion receipt. In a Class C body the send yields through
the class-C yield while waiting; in Class S / plain evaluation it blocks
bounded by the owner's send deadline. The owner writes non-blocking with
partial state retained (nothing is re-sent or dropped), arms POLLOUT
only while bytes are owed, and the wake socketpair is a control wakeup —
a send never cancels the subscription. Admission is bounded across a
send's entire lifetime — 64 ops and 64 MiB of payload per source,
counting queued + in-flight + parked behind a cancelled caller (the
reservation releases only when the receipt is answered or the command
is retired): a full queue raises SOCKET_SEND_BUSY at admission, and a
retiring source answers every outstanding receipt with
SOCKET_SEND_CLOSED. The 30 s receipt deadline is stamped at
admission (never restamped per queued hop) and checked absolutely
every poll loop — continuous incoming data or wake traffic cannot
stretch or starve it. A send that cannot finish in time fails its
receipt; if any byte may have reached the wire the source hard-closes
with one terminal event (SOCKET_SEND_TIMEOUT) — bytes are never
silently dropped, duplicated or re-sent.
Events. Frames arrive as events under the caller-chosen command
(1–64 chars of [a-zA-Z0-9._:-], e.g. ticker.frame):
{"watch": "ws:1", "frame": {"kind": "text", "data": "…"}}
{"watch": "ws:1", "frame": {"kind": "binary", "data": {"hex": "…"}}}
{"watch": "tcp:2", "frame": {"kind": "bytes", "data": {"hex": "…"}}}
{"watch": "tcp:2", "frame": {"kind": "line", "data": "…"}}
{"watch": "ws:1", "closed": {"reason": "connection closed by peer"}}
{"watch": "tcp:3", "accepted": {"handle": 7, "peer": {"host": "127.0.0.1", "port": 50122}}}
WS message boundaries and text/binary kinds survive. tcp_on defaults to
frame: "bytes" — ordered raw chunks of at most 64 KiB; frame: "line"
splits on LF (one trailing CR stripped) with max bounding a line.
Listeners (v0.112.0). tcp_on on a tcp_listen listener takes no
options. The reader parks in poll(2) on the listener and accepts each
connection as it arrives: the connection becomes a plain connected
handle, owned by the generation, and arrives as one accepted event in
order. The handler then reads it, or subscribes it with its own tcp_on:
$l = tcp_listen("127.0.0.1", 0)
tcp_on($l, "app.conn")
on app.conn
if $event.args.accepted != nil then
tcp_on($event.args.accepted.handle, "app.line", {frame: "line"})
end
end
on app.line
if $event.args.frame != nil then
print($event.args.frame.data)
end
end
Outside serve mode, tcp_accept("tcp:3") takes the next accepted handle
from the source (Class C, like the recv forms). Accepted connections that
are never delivered, because the source was unwatched or retired with
records still queued, are closed rather than left open with no name. At
1024 live server-side sockets the reader stops accepting and closes the listener
with one terminal event (closed: {"reason": "handle_limit"}); a queue
overflow closes it with reason: "overflow", as for a connection.
Ordering and overflow. Records are an ordered, per-source FIFO bounded
by 4096 frames and 64 MiB of payload per evaluator (across all its
sources). When the bound is hit the reader hard-closes the socket and
publishes exactly one terminal event (closed: {"reason": "overflow"})
— a frame is never silently dropped. A peer close (or a line over max
with no newline) also publishes exactly one terminal; afterwards the
source retires and a further recv raises SOCKET_WATCH_HANDLE.
Class C recv. ws_recv("ws:1"[, timeout]) (and the tcp forms) park on
the source's FIFO through the evaluator's class-C yield — the read permit
is released while waiting, so other verbs keep their latency targets.
nil on timeout keeps the source usable (poll again); the terminal event
raises SOCKET_CLOSED and retires it. At most one recv parks per source;
a second refuses (SOCKET_BUSY). A mismatched verb refuses
(SOCKET_KIND). Because this form reads the FIFO the event pump also
consumes, recv on a source is refused in serve mode
(SOCKET_RECV_SERVE) — use on <command> there.
Numeric Class C pull. In a Class C async body (a read permit is
held), ws_recv(handle[, timeout]) / tcp_recv / tcp_recv_line on a
plain numeric handle run as a pull instead of the sync builtin's
blocking read: the connection leaves the registry for the duration of
one call (at most one waiter per handle — ws and tcp handles count ids
independently, so the same number is two different connections; a
second concurrent recv on the same one refuses SOCKET_BUSY, a
subscribed handle SOCKET_SUBSCRIBED),
the socket goes non-blocking, and readiness/read WouldBlock are
awaited in a cancel-safe loop under the call's deadline — never via
periodic polls. Read state (TLS/frame buffers, tcp_recv_line's
read-ahead) stays inside the connection across WouldBlock. nil on
timeout and a cancelled wait both return the connection to the registry
with its blocking mode and timeouts restored — the source stays usable
and a failed generation's resource survives for the next one; a peer
close or error raises (SOCKET_CLOSED) and retires the handle, exactly
like the sync path. Pull frames never enter the subscription FIFO, so
the event pump cannot steal them, and the numeric pull therefore also
works in serve mode. Plain and Class S numeric recv use the same pull
(v0.112.1), keeping the blocking form's options and error messages and
adding an interrupt-flag recheck every 250 ms, so SIGTERM and Ctrl-C end
the wait as they end sleep(). The pull's AsyncFd readiness await
is unix-only: on other targets the Class C interception falls through
to the sync builtin (honest Class S blocking semantics), never a
silently claimed yield.
Ownership. Sources belong to the evaluator generation:
close_native_events/generation teardown cancels, joins and retires every
source, and a failed --serve reload leaves the old generation's
subscriptions intact. ws_unwatch/tcp_unwatch cancel and join the
reader, drop queued frames, wake a parked recv with SOCKET_WATCH_HANDLE,
and emit no terminal event (an explicit close is not a peer close);
queued-but-unrun sends are answered with SOCKET_SEND_CLOSED, never
silently dropped. The reader parks in poll(2) on (socket, cancellation
pipe, command pipe) with an infinite deadline — no timer, so an idle
subscription costs ~zero context switches; a poll timeout exists only
while a send is pending. The socket is non-blocking, so cancellation
wins at the next poll wake (mid-frame TLS state survives in the stream
buffers).
Limits and acceptance. 16 sources per evaluator; max WS message 16 MiB
(connect-time cap); subscriptions Linux only (SOCKET_UNSUPPORTED
elsewhere, matching the pidfd-owned children). The numeric Class C pull
needs the unix AsyncFd await: off-unix the interception falls through
to the sync builtin — honest Class S blocking, never a silently claimed
yield. A busy subscription's in-flight read drains
in bounded read/write batches before rechecking cancellation and absolute
deadlines. Automatic WebSocket control writes also have a 30-second bound;
a peer that leaves a pong blocked cannot retain an owed write indefinitely.
The loopback acceptance tests execute the builtins
end-to-end through the real evaluator: the ~2 s delayed numeric recv with
a concurrent handler tick (the pull yields the read permit — the tick
lands while the recv is parked), same-connection send after subscribing
with the FIFO intact, timeout-then-reuse, a second same-handle waiter
refusing, close-while-parked retiring, and a cancelled pull leaving the
handle intact for the next generation. The strict "other verbs stay
under 1 ms while a recv is parked" acceptance and the 5 s idle
context-switch measurement remain explicit external acceptance, measured
by the coordinator's fixture rather than asserted here; they are never
weakened by these changes. Binary frames in event bodies are hex-encoded
— from_hex() on the way back.
Capability classes
Each system builtin carries a capability class used by the
--serve sandbox's check_capability gate: env / pid /
hostname / cwd / platform / which / net_watch / net_unwatch /
net_state are Env (read-only inspection);
run / run_rc / run_stream / spawn / kill / process_alive / chdir /
audio_watch / audio_unwatch / audio_state / exit (and panic — see errors) are Process (they touch the
OS process table or filesystem CWD); shell_quote / sql_quote / sanitize /
random_password / uuid / hashes / base64_* are Pure. An embedding
daemon can deny the Process class to run untrusted Mix without it spawning
subprocesses.
See also
- strings — concat, interpolation, the byte/codepoint/grapheme split
- errors —
try/catch, thedieraised byrun,panic - Bus messaging —
send/emit/on … end; prefer mesh IPC over shelling out - ssh —
ssh_run/ssh_must/ssh_mixfor remote commands - shell mode — the dispatch layer: chains, pipes, redirects, brace expansion
- capabilities — the
--servesandbox classes in full - builtins index —
getopt,read_file,stat,chmod, the full set
mix builtins system list every system builtin with its one-line description
mix what NAME one-line description of a single builtin (e.g. mix what run_rc)
mix help the full categorized builtin reference