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), 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.stdin:nilor{null: true}closes stdin; string/bytes/buffer supplies those bytes;{file: path}opens a local file for the child to read. There is deliberately nostdin: "inherit"route: run_argv puts its child in a new process group, so it is not the terminal's foreground group and a terminal read can receiveSIGTTIN. The string"inherit"is ordinary stdin data. Userun_streamwhen a child must own the terminal and inherited stdin. 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), andclear_env(bool — start from an empty environment) retain their existing meanings.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.
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. Stage stderr is untrimmed. accepted_signal records the
SIGPIPE policy below; it is false for ordinary exits and rejected signals.
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.
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; a Ctrl-C sends SIGTERM, waits a 2-second
grace, then SIGKILLs. 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))
$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. The killer use is ssh_mix fan-out: a fleet sweep
that walked N nodes serially becomes one run_parallel of N ssh jobs. A job's
stream flag is ignored (a parallel live tee would interleave into garbage).
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/cosmix/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/cosmix/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, 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(["cosmix-term", "--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.) Defaultfalse(a plain child in the caller's session).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.
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.
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)
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.
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
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_verify(pw, hash) check against a bcrypt hash -> bool (v0.71.0)
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 bcrypt has to happen
client-side — which used to mean shelling out to PHP. Three deliberate
edges: input over 72 bytes raises (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 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
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/cosmix-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.
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.168.1.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) -> 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)
Same inline-blocking caveat as the others (below): a pending tcp_recv
holds the evaluator's thread, so {timeout: 0} in a --serve citizen
can wedge the pump.
Both UDP builtins above, all four ws_*, and the five tcp_* 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.
Capability classes
Each system builtin carries a capability class used by the
--serve sandbox's check_capability gate: env / pid /
hostname / cwd / platform / which are Env (read-only inspection);
run / run_rc / run_stream / spawn / kill / process_alive / chdir /
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