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, 0 disables, fractional ok. The clock starts before file routes are opened, so it bounds setup as well as the child and capture drains. With timeout: 0, Mix waits for every captured stream to reach EOF; it does not abandon a reader merely because the direct child has exited. Note run/run_rc default to no deadline.

  • grace: seconds, default 0, fractional ok — what happens AT the deadline. 0 SIGKILLs 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 or grace runs out, then SIGKILLs whatever is left. The grace covers the group, not only the child. In sh -c "pg_dump app | gzip > f", sh may die at once, but pg_dump and gzip still 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 to timeout + grace. The result still reports timed_out: true, and signal says how the child ended: 15 if it obeyed SIGTERM, 9 if it had to be killed. grace with timeout: 0 raises OPTION_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: nil or {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} raises STDIN_TERMINAL before 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 with SIGTTIN. 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 bytes inherit. Use run_stream when a child must own the terminal and its stdin. The same rule applies to stage 0 of run_pipeline and to run_parallel jobs. At most one run_parallel job may inherit stdin. Inheriting stdin makes sense for a script run as mix script.mix or mix -, 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 raise OPTION_INVALID before any job runs, because concurrent readers of one pipe would race for its bytes. With timeout: 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" (the 2>&1 merge), 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, the env -u form), and clear_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 to run_argv_must, run_parallel jobs and each run_pipeline stage, including captured and routed output.

  • max_output: bytes per captured stream, default 8 MiB, 0 disables. 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, default false; 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 after max_output truncates capture. stream: true with stdout: "inherit" is rejected as OPTION_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_code and signal describe the last stage. They do not alone decide overall success: a middle-stage failure makes .ok false even when the last stage exits 0.
  • stdout is the last stage's captured stdout, untrimmed. It is "" when the last stage routes stdout elsewhere.
  • stderr concatenates captured stage stderr in stage order. Routed/merged stderr is absent from this aggregate.
  • stderr_truncated and utf8_lossy are aggregate flags. stdout_truncated applies to final stdout. A truncation flag also records a capture abandoned at the deadline before EOF, not only bytes discarded by max_output.
  • error_code / error are normally nil. Setup/lifecycle failures use PIPELINE_STDIO, PIPELINE_SPAWN, PIPELINE_IO, or PIPELINE_INTERNAL and are returned as ok: false; ordinary non-zero exits and signals remain data. A later-stage PIPELINE_SPAWN is the unavoidable partial-run case: .stages contains 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 statusmeaning
okexited 0
timeout / interruptedstill running when the deadline / Ctrl-C made Mix signal its group — the death is Mix's, not the stage's
broken_pipekilled by SIGPIPE: its reader closed (also set when allow_signal accepted it — .ok carries the acceptance, status the fact)
signalkilled by any other signal (.signal names it)
exit_nonzeroexited with a non-zero code (.exit_code)
setup_errorstarted, 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, 0 disables, fractional values are accepted. A route open that reaches the deadline returns PIPELINE_STDIO before any stage runs. Process enforcement polls at 50 ms, exactly as run_argv does, 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, 0 disables. It caps final stdout and each captured stage stderr independently; excess is drained and flagged, not allowed to block a child.

  • allow_signal: bool, default false. By default every stage must exit normally with code 0, so any signal death — SIGPIPE included — makes .ok false. Set allow_signal: true to 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 false because 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 .ok would have been true. 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 matches set -o pipefail, which likewise reports 141 for yes | head -1.

    Per-stage .accepted_signal records 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:

Outcomerunrun_rc
normal exitstdout string / die on non-zerorc = exit code
deadline fireddies (catchable): timed out after Nsrc: -1, timed_out: true
Ctrl-C during the waitraises run: interruptedrc: -2, interrupted: true
child signal-killeddies: 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
  • msg needs to (a string or a list of strings), from, subject and body. headers is an optional map of extra headers. Any other key raises. There is no cc or bcc key: put Cc and Bcc in headers.
  • The message is built for you: From, To, Subject, Date (UTC), Message-ID, MIME-Version: 1.0 and Content-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 as quoted-printable, so no MTA can split or mangle it.
  • A non-ASCII subject is RFC 2047-encoded (as rfc2047_encode does 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.
  • headers adds headers, or replaces a generated Date, Message-ID or MIME-Version. From, To and Subject come only from msg, and Content-Type and Content-Transfer-Encoding only from send_mail, which encodes the body itself. Naming any of those five in headers raises. A label of your own would describe other bytes than the ones sent: a quoted-printable body labelled 8bit reads as literal =C3=BC. The body is always text/plain; charset=utf-8.
  • Delivery is sendmail -t -i -f <envelope> with the message on stdin, never a network SMTP client. -t takes the recipients from the headers, so a Bcc header works and is stripped by the MTA. -i keeps a line holding a lone . as body text.
  • from is exactly one mailbox: a@example.com or Display 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 as root) is sent as it is, and the Message-ID domain then falls back to localhost.
  • Which sendmail: the first sendmail on PATH, then /usr/sbin/sendmail, then /usr/lib/sendmail. The sendmail option names one explicitly.
  • host sends from another host — the one with a working MTA — through ssh_exec. The message is still built locally; only the sendmail run is remote, with /usr/sbin/sendmail as the default there. The result carries host as ssh_exec's does, and only then. mix_bin (with host only) names the remote mix binary the hop runs, resolved as for ssh_mix: mix_bin, else local MIX_REMOTE_BIN, else /opt/mixos/bin/mix.
  • The result is run_argv's process_result plus message_id. ok means 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: false with exit_code, stderr and error_code.
  • Bad input raises before anything runs: OPTION_INVALID for a bad msg field or option, TYPE_MISMATCH for a msg that is not a map or the wrong number of arguments, and with host, ssh_exec's own validation errors.
  • timeout is in seconds, as for run_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?

NeedUse
stdout string, abort on failurerun
inspect a non-zero exit code as datarun_rc
live output / interactive child / inject-safe argvrun_stream
run Mix source on another nodessh_mix + heredoc
run a shell snippet on another nodessh_run / ssh_must
talk to a local/mesh Bus brokersend / 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 -c shell branch) does not support POSIX command grouping — a bare (echo a; echo b) | sort at the prompt fails because a leading ( classifies the line as Mix, where echo a is 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 .sh file — hand the whole pipeline to /bin/sh as 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
    /TMP
    

    On a mix-login-shell node a remote (…; …) hits the remote classifier the same way. Ship multi-line Mix without nested escaping via ssh_mix + heredoc.

  • $(...) command substitution: literal in a double-quoted Mix string, but it passes through in a run/run_rc command string — it substitutes in the /bin/sh that actually runs the command (local here; the remote shell for ssh_run).

  • Brace expansion {a,b} / {1..5}: a shell-dispatch feature, not a /bin/sh one — braces in a run/run_rc command 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 a mix --serve launcher 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 later kill($pid) or process_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. Default false (a plain child in the caller's session, which stays the caller's to reap).

  • die_with_parent: true (Linux), without exit_event, → the opposite slot. The child ends with this mix process, for a helper that must not outlive the script or --serve citizen 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 --serve citizen'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.
    • --serve RELOAD. 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 with die_with_parent is 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 plain mix script.mix or 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 --serve citizen 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 --serve citizen or be stopped with Ctrl-C, which ends the evaluation normally and so reaches the sweep.

    detach together with die_with_parent raises OPTION_INVALID, because they contradict each other. Mix itself is the only thing that reaps an owned child. process_alive answers 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 next process_alive or spawn. One whose descendants are still running is kept as a zombie until the sweep ends them. PDEATHSIG is keyed to the thread that called spawn, and the ownership registry is process-wide. So the option works only on a thread whose host owns it. The mix binary evaluates on one thread that lives until exit and sweeps before it ends. Elsewhere, for example webd, mixos-mcp or mixos-claud evaluating on pooled spawn_blocking threads, die_with_parent raises OPTION_INVALID. An embedder that does own a long-lived evaluation thread opts in by calling builtins::owned_spawns::enable() on it. That call returns false, and changes nothing, if another thread enabled first; only the first thread is ever the host. The embedder must also call sweep() on that same thread before it exits. Off Linux the option raises OPTION_INVALID. Default false: a plain spawn child is untouched by mix's exit. With exit_event:true, use the generation lifetime below instead.

  • cwd / env / clear_env behave exactly as in run_argv (clear-then-layer: {clear_env: true, env: {…}} starts from empty).

  • stdout / stderr reuse run_argv's routing, minus capture: "null" (default), "inherit", or a {file, append?, mode?} map — and stderr: "stdout" to merge. "capture" is refused: capturing means waiting, which is run_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}
]}
  • up on a link is operationally up: operstate up, or — for drivers that report unknown, such as lo and WireGuard — IFF_UP and IFF_RUNNING together. On an address it means present (false with removed: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 sets overflow: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}:

CodeMeaning
NET_WATCH_OPTIONS, AUDIO_OPTIONSInvalid options.
NET_WATCH_ARGUMENT, AUDIO_WATCH_ARGUMENTHandle is not a string.
NET_WATCH_HANDLE, AUDIO_WATCH_HANDLEUnknown, retired, or other-family handle.
NET_WATCH_LIMIT, AUDIO_WATCH_LIMIT16-handle limit reached.
NET_WATCH_IO, NET_STATE_IONetlink socket or dump failure.
NET_STATE_INCONSISTENTDump interrupted three times running.
AUDIO_UNAVAILABLEpactl not on PATH.
AUDIO_WATCH_IOCould 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.)
*_UNSUPPORTEDPlatform is not Linux.
NATIVE_CLOSEDThis 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.

  • type is a record type name, case-insensitive: A, AAAA, MX, TXT, SRV, NS, SOA, PTR, CNAME, CAA and the rest. PTR of an IP address asks its reverse name, as dig -x does.
  • name is always fully qualified: there is no search list.
  • server is an address or a host name (default: the first nameserver in /etc/resolv.conf, else 127.0.0.1), port defaults to 53, timeout to 5 seconds for the whole query (resolving a server host name, a TCP retry and a slowly delivered reply all count against it), and recurse (default true) sets the RD flag. tcp: true forces 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: true skips TLS certificate verification — for the self-signed device endpoint (wss://tv:3001/), never for services with real certificates.
  • ws_recv returns nil on 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 handle rather than repeating the close error.
  • Handles are process-global numbers (the sqlopen pattern); the kernel reaps the sockets at exit, ws_close is 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 answers unknown ws handle rather than queueing.
  • ws_send writes 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:

CodeRaised when
TCP_LISTEN_ADDR_IN_USEanother socket already listens on the address
TCP_LISTEN_PERMISSIONthe bind needs privilege (a port below 1024)
TCP_LISTEN_ADDR_UNAVAILABLEhost is not an address of this machine
TCP_LISTEN_ADDRESShost does not resolve
TCP_LISTEN_ARGUMENTa bad host, port or option
TCP_LISTEN_FAILEDany other bind or listen failure
TCP_ACCEPT_HANDLEan unknown listener, or a connection handle
TCP_ACCEPT_ARGUMENTa bad option
TCP_ACCEPT_FAILEDaccept(2) failed on the listener
TCP_HANDLE_LIMIT1024 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_LISTENERa 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.

  • path is 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_line returns the next frame without its NUL (no CR is stripped), and tcp_on(h, cmd, {frame: "line"}) delivers one event per frame. Sends are verbatim: end each request with chr(0) yourself. The default, frame: "line", keeps LF lines as tcp_connect does.
-- 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))
CodeRaised when
UNIX_CONNECT_NOT_FOUNDno socket file at path
UNIX_CONNECT_PERMISSIONthe socket file's mode refuses this user
UNIX_CONNECT_REFUSEDa socket file with nothing listening behind it
UNIX_CONNECT_BUSYthe listener's backlog is full
UNIX_CONNECT_TIMEOUTthe connect did not finish within timeout
UNIX_CONNECT_ARGUMENTa bad path or option
UNIX_CONNECTany 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, the die raised by run, panic
  • Bus messaging — send / emit / on … end; prefer mesh IPC over shelling out
  • ssh — ssh_run / ssh_must / ssh_mix for remote commands
  • shell mode — the dispatch layer: chains, pipes, redirects, brace expansion
  • capabilities — the --serve sandbox 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

This page as Markdown · This page describes the current main branch of MixOS.