The scua binary runs a script, scaffolds a new project,
formats source, runs tests, and steps a program under a debugger. Run it
with no arguments (or --help) to see the authoritative
usage:
$ scua
usage: scua [options] <file.scua> [args...]
(options precede the file; everything after the file — or after `--` — is passed to the
script and read with `sys.args()`)
scua init [name] (scaffold a new project in ./name, or the current dir)
scua init compact [name] (bare-bones: just a runnable main.scua)
scua init game [name] (a small game that saves the player's progress)
scua init zed-debug|vscode-debug (add debug config to the current project)
scua fmt [--check] [--fix] <file.scua> (indentation + trailing whitespace; --fix: as(…) → parse(…))
scua test [dir|file] (run *_test.scua files, or just the one named)
scua res build DIR -o FILE | list FILE | check FILE (resource packs: res: paths)
scua user path ID [ID…] (where --allow-user=ID keeps a program's saves; creates nothing)
scua user adopt ID [--yes] (use an existing folder scua did not make as ID's)
scua schema <file.scua> --type T (emit JSON Schema for a record/enum type)
scua blob-info [--keys FILE] <file|dir> (what a saved actor or session holds; runs nothing)
scua pack [--tier=…] [--repo=DIR] (teach-a-model in-context pack)
scua cluster dump <file.scdb> (inspect a durable shared-config snapshot)
scua debug <file.scua> (run under the terminal stepper)
scua add|publish|vendor|… (package verbs; needs scua-pkg on PATH — `scua-pkg --help`)
scua --version | --help | --license
options:
--fast fast-forward the clock (instant; for demos/sims)
--dump-bytecode[=verbose] print the compiled bytecode (annotated) and exit; don't run
--log=<trace|debug|info|warn|severe>
drop log statements below this level at compile time
--log-time prefix log lines with an ISO-8601 timestamp
--log-stdout send log lines to stdout, mixed in with the script's own
output (they go to stderr by default, as diagnostics)
--output=<text|json> text (default): stdout is what the script prints. json: stdout
is ONLY the `sys.emit` result; print/log go to stderr
--jit[=on|always|off] the JIT: ON by default; --jit=off runs the interpreter only
--jit-stats print JIT telemetry (compiles/entries/deopts) to stderr on exit
-D name[=value] set a `build` flag for `comptime if` (bare = true)
--profile <name> select a build profile from scua.toml (else its `default`)
--mod-path DIR add a module search directory (repeatable; tried after the entry dir)
--allow-file=PATH read capability grants from a TOML file (a [capabilities] table, or a
bare table): fs/net/env/serve/cluster/udp/tty/sandbox, each `true`
for the bare form, a string/list for the constrained one, or
`false`/`[]` to refuse it outright; `exec` (a list of commands, such
as [["git", "status"]]) and `exec_any`; `user`/`user-dir` for the
per-user folder; `mount`. A --allow-* flag below always outranks
it. Its exec list and user keys are refused when the file sits in
a folder the program may write.
--allow-all grant EVERY capability, each in its broadest (bare-flag) form —
fs at the cwd, any host, any bind, any peer, every env var, the
terminal. For a script you already trust; a narrower --allow-* flag
still wins. Never the per-user folder, which needs --allow-user=ID.
--allow-fs[=DIR] grant `fs` (read/write/delete) rooted at DIR (default: cwd)
--allow-user=ID grant the per-user folder for app ID (its saves and settings, as
user: paths): Roaming, Application Support or XDG_DATA_HOME. ID is
three or more lowercase parts, such as dev.example.mytool
--user-dir=PATH grant the folder at PATH as the per-user folder (relative to the
program's folder), e.g. --user-dir=save while developing
--user-adopt for a shipped launcher: use an existing folder for the ID that
scua did not make (a cloud restore on a second machine)
--mount=PATH add a read-only res: layer: a pack, a directory, or DIR/*.pak
(repeatable; a later one wins; relative to the program's folder;
PACK#sha256=HEX pins it). Above the project's res/ folder
--res=PATH|none the bottom res: layer in place of the project's res/ folder
--show-res print the resource view to stderr, then run
--res-missing=allow revive a save although a res: layer it ran with is not mounted
--strict-res refuse to revive a save whose res: layers changed since it was made
--allow-net[=hosts] grant the `net` capability (HTTP); comma-separated host allowlist
--allow-env[=NAMES] grant the `env` capability; comma-separated name allowlist (bare = all)
--env-file=PATH load a `.env` file and grant `env` for exactly the names in it
(repeatable, applied in order). A real environment variable wins
over a file one, and an earlier file over a later one. Without
--allow-env the process environment stays hidden.
--allow-serve[=ADDR:PORT] grant the `serve` capability (HTTP/TCP LISTEN) constrained to that bind;
bare --allow-serve = any bind (trusted hosts only). Never implied by --allow-net.
--allow-cluster[=SECRET] grant the `cluster` capability (the `shared` config table); SECRET is
the cluster's shared MAC secret. Bare = empty (demo/trusted).
--allow-udp[=PEERS] grant the `udp` capability (DATAGRAMS); comma-separated allowlist of
ADDR[:PORT] or CIDR[:PORT] (e.g. 1.1.1.1:53, 10.0.0.0/8:7946).
bare --allow-udp = any peer (trusted hosts only). `udp.bind` also needs
--allow-serve. Never implied by --allow-net: datagrams can amplify.
--udp-max-payload=N raise the datagram payload cap from the 1200-byte portable-safe default
--allow-tty grant the `tty` capability (prompt the person at the keyboard):
sys.readline and sys.readpassword. Separate from piped stdin, which
sys.stdin() reads with no grant at all.
--allow-exec grant `exec` for the commands listed under [exec] in the project's
scua.toml: exec.run and exec.spawn run those and nothing else
--allow-exec-any grant `exec` in its open tier: run any command (exec.run_any,
exec.spawn_any, and exec.run). A child runs as you, so this is
delegation, not containment; --allow-all does not grant it
--io=async let capability I/O (fs, http, exec, net) park instead of waiting in
place, so other tasks and partitions run meanwhile; default: blocking
--net-max-parked=N --io=async: net calls one partition may have waiting (default 1024)
--net-max-queued-write=BYTES --io=async: unsent bytes parked writes may hold per connection
(default 16777216)
--net-max-registrations=N sockets watched at once: served connections and waiting net/http calls (default 16384)
--http-max-connections=N --io=async: connections http calls may hold open to one host and
port, kept between calls; past it a call waits (default 256)
--blob-keys=FILE sign every actor snapshot with this deployment's key and revive
only blobs that verify (HMAC-SHA256). FILE holds one key per line
as 64 hex digits, the one to sign with followed by `sign`; the
others still verify, so keys rotate. Refused when FILE is inside
a directory --allow-fs grants, or beside --allow-exec-any: no
script may read it
--accept-unkeyed-blobs with --blob-keys, also revive blobs saved without a key
--allow-fork let the program fork actors: `actors.revive(blob, { fork = true })`
starts a second live copy of a saved partition under a new
identity. Off by default; --allow-all does not grant it
--strict-constants refuse a revive whose actor holds a value from a constant that
is gone or changed shape, rather than keep its stored copy
--serve-debug put located fault text in http.serve 500 bodies (local dev only)
--serve-check run to the first http.serve, apply its start-up checks, exit unbound
--serve-timeout=DUR deadline for one served request, from its first byte (default 30s)
--serve-header-timeout=DUR request head within this of its first byte (default 10s)
--serve-body-timeout=DUR request body within this of the head (default 30s; 1 KiB/s after 10s)
--serve-write-timeout=DUR close a response the client stops taking (default 30s)
--serve-idle-timeout=DUR close an idle kept-alive connection (default 75s)
--serve-max-conns=N connections at once (default 10000, or the descriptor limit)
--serve-max-buffered=BYTES part-received request bytes across connections (default 64M)
--serve-drain-delay=DUR on a stop, keep answering this long first (default 0)
--serve-grace=DUR then wait this long for requests in flight (default 25s)
--serve-stats=DUR a line of request counts and latency every DUR
--serve-stats-every=N every N requests, arena bytes per request and GC rate
--serve-log one JSON line per request on stderr
`--http-timeout=MS` — the whole-exchange deadline for `http` requests, in milliseconds (default `120000`; `0` = no deadline). A per-call `timeout_ms` may tighten this but never loosen it, so an operator can bound a script without editing it. Under `--io=async` the deadline also covers the wait for a free connection.
`--http-max-connections=N` — under `--io=async`, how many connections `http` calls may hold open to one host and port (default `256`). They are kept between calls and reused; a call that finds all of them busy waits for one, within its own deadline. Raise it for a program that keeps more calls than that in flight against one fast service; lower it to cap what a program can ask of a service.
--locale=<tag|host> set sys.locale() (BCP-47, e.g. de-DE; `host` reads the device)
--tz=<±HH:MM|host> set sys.timezone() offset (e.g. +02:00, +05:45; `host` reads the device)
--max-ops=N stop a turn at its Nth step — one function call or one loop
iteration each (suffix k/m/g); kills on exhaustion (without it
there is no CPU limit — the built-in ceiling refills)
--max-total-ops=N stop the run once it has taken N steps in all, across every turn
and every `wait` (suffix k/m/g), or `none`
--max-mem=BYTES cap the script's extra live memory (suffix k/m/g, e.g. 64M)
--mem-stats print partition memory telemetry to stderr on exit (diagnostic)
--gc-threshold=BYTES set the GC pacing floor (default 8M; suffix k/m/g) — diagnostic
scua init, scua fmt,
scua test, and scua debug are subcommands
(they take the place of <file.scua>);
--version (or -V) prints the version and
exits. Each subcommand and option is detailed below.
A released build prints just the version — scua 0.26.0.
A build that was not compiled for speed names itself,
because a number measured on one is a number about the build:
$ scua --version
scua 0.26.0-dev.abc1234 (debug — unoptimised; do not benchmark)
You will only see that if you built from source without
-Doptimize=ReleaseFast. A debug build is correct — it runs
the same programs and gives the same answers — but it is roughly a
hundred times slower on work that allocates, so timings and memory
figures taken from one describe the compiler's settings rather than your
code.
scua --help includes
Copyright © 2026 Unabated Games Ltd. All rights reserved.
scua --license prints the full pre-release evaluation
licence and confidentiality agreement to stdout and exits successfully.
The terms are embedded in the binary, so no separate licence file is
needed to display them. These notices are not printed during normal
script execution. Like --help and --version,
--license after the script path (or after --)
is passed to the script.
#Running a file
$ scua file.scua
This compiles and runs the script. With no flags, timers run in real time: a script that waits five seconds takes five seconds. The options below change that and a few other things.
Before anything runs, the checker reports every problem it finds, one
line each on stderr: the errors in file and line order, then the
warnings. A warning doesn't stop the run; an error does, with status
3. After 50 lines, one more line counts the rest:
$ scua report.scua
scua: report.scua:4: type error: value is string, but the binding is declared int
scua: report.scua:9: type error: argument 1 to 'restore' is string, expected number
scua: report.scua:6: warning: `tonumber` is given an array ({ int }), which is never a number, so this always gives nil — pass it a string or a number
A warning that one of your names hides a built-in is the exception to
"before anything runs". It can never stop a run, and a new SCUA release
that adds a built-in can raise one in a program nobody changed, so it's
printed when the program has finished: after its output, its error or
its sys.fail reason. The first line on stderr is then
always about what your program did. When the program is refused, it
follows the errors with the other warnings.
--deny-warnings and a
file's --!strict make warnings stop the run too, and the
warnings of the packages you depend on are quiet unless you pass --dep-warnings. --no-warnings, or
warnings = false under [run] in a project's scua.toml, keeps
them off stderr.
If the file can't be read, scua exits with status 2 (a
bad argument — see Exit status below) and an
error like:
scua: missing.scua: cannot read file
A word with no . or / in it that names no
file is taken for a command that doesn't exist. The error lists the
commands, and names the nearest one when you're a letter or two
away:
$ scua tset
scua: `tset` is not a command, and there is no file of that name. Did you mean `scua test`? The commands are init, fmt, test, debug, schema, pack, cluster, serve, blob-info, res and user, and the package verbs `scua --help` lists
A script whose file really is called check still runs as
scua check.
An option scua does not know is reported as one, with the nearest
option it does, and exits 2:
$ scua --sandbox agent.scua
scua: unknown option `--sandbox`. Did you mean `--allow-sandbox`? `scua --help` lists the options; scua's own options go before the script file
#Passing arguments to the script
$ scua [scua-options] <file.scua> [program-arguments...]
scua's own options come before the
file. Everything after the file name is passed to the program, verbatim
— including tokens that look like flags — and read with
sys.args():
$ scua --fast game.scua level3 --hard # sys.args() == ["level3", "--hard"]
Use a bare -- before the file to stop
scua's option parsing early (e.g. for a file or argument
that begins with -). A program argument that isn't valid
UTF-8 fails the launch (a SCUA string must be UTF-8). See Read command-line
arguments.
#Running an application bundle
$ scua [scua-options] <app.scuapp> [program-arguments...]
An application bundle is a whole tool in one file: its own
.scua files, the packages its lock resolved, and a digest
for each, written by scua-pkg bundle --app.
scua recognises one by its first bytes, checks all of it
before anything compiles, and runs its entry script. Options and
arguments work as for a .scua file, with these
differences:
- Capabilities still come only from this command line. A run missing a
grant is refused before it starts, as for a
.scua, and the message names every grant the bundle says it needs (status4). - A bundle that fails a check is refused with an
E-APP-FORMATorE-APP-CORRUPTcode, or the lock and record codesdeps/uses, and nothing runs (status3). The first check is a SHA-256 over the whole file. Bundles fromscua-pkg0.3.19 are an older format and are refused: rebuild them with a newerscua-pkg bundle --app. --mod-path,--mod-override,--profileanddebugare refused with a bundle (status2): each would bring in files or settings from beside it.
#A project that needs a newer scua
A project can name the oldest scua it runs on, with
scua-version under [package] in its
scua.toml, and so can each package in its lock (see Requiring a minimum
scua). Running a file, scua debug,
scua test and an application bundle all check it first,
before the script is even parsed. A scua older than any of those refuses
with status 3:
scua: @scua/jwt 0.7.0 needs scua 0.31 or later; this is 0.30.2. Upgrade scua, or pin @scua/jwt to an earlier version.
scua: this project needs scua 0.31 or later (scua-version in its scua.toml); this is 0.30.2. Upgrade scua.
scua: tool.scuapp needs scua 0.31 or later; this is 0.30.2. Upgrade scua.
A scua built from source is let through for a requirement up to its
next minor version, with a note on stderr. A script with no
scua.toml above it is never checked.
#Exit status
A program driving scua can tell what happened from the
status alone, without reading stderr.
| Status | Meaning | Who chose it |
|---|---|---|
0 |
Success | your script (or a clean finish) |
1 |
Your script stopped on an Error it chose to pass up:
often a clean negative ("the thing you asked about isn't there"), but it
can be any failure, a missing file, a corrupt config, or a bug.
{"error": …} on stderr says which |
your script, via sys.fail(reason) or
sys.exit(1) (at the top level, or in a handler, where the
run finishes first), or ? or return Error(e)
at its top level |
2 |
Bad arguments, including a capability file scua refuses
and a per-user folder the run cannot use (an E-USER-… code
first on the line, except E-USER-LOCKED, which is
9) |
scua, or your script via
sys.fail(reason, 2) |
3 |
Nothing ran: the script didn't compile (a syntax, type or checker
error), a compile warning in your own files stopped it under
--deny-warnings or in a --!strict file, or the
project needs a newer scua |
scua |
4 |
The script asked for a capability this run wasn't granted. Nothing
ran, except for a user: path: a program that uses one runs
without the per-user folder and stops at the first user:
call it reaches |
scua |
5 |
The script crashed — an uncaught fault, in the main body or an actor | scua |
6 |
A kill-switch stopped the run — --max-ops,
--max-total-ops or --max-mem |
scua |
7 |
The runtime itself could not continue — you should not see this one | scua |
8 |
Under --serve-check, the
program ended without starting a server: it never called
http.serve, or carried on past an Error it
returned |
scua |
9 |
The per-user folder is in use by another run
(E-USER-LOCKED). Nothing ran; the launch was right, so
trying again later, or launching every run with
--user-shared, is the fix |
scua |
75 |
A server was stopped (Ctrl-C or SIGTERM) and its grace
period ran out with requests still in flight; they were abandoned. See
--serve-grace |
scua |
The split that matters for a tool: 1 means the script
ran and reported a failure it chose to report,
3–8 mean it never produced an answer at all (a
4 from a user: call may have printed some
output first). The error in sys.fail's output
is any JSON value the script gave it, so read it rather than assuming
1 means "not found". A standard-library Error
written out this way is {"kind": …, "message": …}, and a
kind of "misuse" means a bug in the script,
not a "no". Every way to exit 3 is decided before the first
line of the script runs. 4 in particular is worth handling
separately — the script is fine and the launch is what has to
change, so the fix is a --allow-… grant rather than an
edit.
0, 1 and 2 are yours
to choose. They're the statuses sys.exit and
sys.fail are meant to choose between (see Write tools for AI agents).
scua itself uses 2 only for a launch it
refuses before any of the script runs (bad arguments, a capability file
it refuses, a per-user folder the run cannot use, such as
E-USER-ID or E-USER-MISMATCH), the usual
meaning of 2 for a command-line tool. It reserves
3–9 for its other failures. Keep your own
choices in 0–2, and the only overlap is a
2 that means "this launch was wrong", whoever said so.
sys.exit accepts any value 0–255,
so nothing stops a script picking 5 — it just makes its own
result indistinguishable from a crash.
scua test and scua fmt --check are
separate: both exit 1 when they find something (a failing
test, an unformatted file). Under scua test, a test file
that doesn't compile, or that warns under --deny-warnings,
fails like a failing test.
#scua init [name]
Scaffold a new project. With a name,
scua init creates that directory and fills it; with no
name, it scaffolds into the current directory:
$ scua init myproj
Created a new SCUA project in ./myproj:
myproj/main.scua
myproj/greet.scua
myproj/main_test.scua
myproj/scua.toml
myproj/README.md
myproj/.gitignore
Next steps:
cd myproj
scua main.scua # -> hello, world
scua test
The generated main.scua reads command-line arguments
with sys.args() and prints a greeting, so the project runs
straight away:
$ cd myproj
$ scua main.scua # -> hello, world
$ scua main.scua Ada # -> hello, Ada
$ scua test # 2 passed, 0 failed (1 file)
The greeting itself lives in a small greet.scua module, which is what
main_test.scua imports and tests — main.scua
just wires arguments to it. scua.toml is a starter project
manifest with debug and release build profiles
(see the --profile option below).
scua init never overwrites anything: if the target
directory already exists, or any file it would create is already
present, it stops with an error and writes nothing.
scua init compact [name]
The bare-bones cut — a single runnable main.scua and
nothing else (no module split, test, manifest, or README). Use it when
you just want one file to start from:
$ scua init compact myproj
Created a bare-bones SCUA project in ./myproj:
myproj/main.scua
Next steps:
cd myproj
scua main.scua # -> hello, world
The generated main.scua is a one-line program that
prints hello, world. Like scua init, it
accepts an optional name (creates that directory) or
scaffolds into the current directory, and never overwrites an existing
file.
#scua init game [name]
A small game that keeps the player's progress, set up the safe way from the start:
$ scua init game tinykeep
Created a new SCUA game in ./tinykeep:
tinykeep/main.scua
tinykeep/progress.scua
tinykeep/progress_test.scua
tinykeep/scua.toml
tinykeep/README.md
tinykeep/.gitignore
Next steps:
cd tinykeep
scua --user-dir=saves main.scua # play a round; run it again and the gold carries on
scua test
Before shipping, change the app id in scua.toml (dev.example.tinykeep) to a reversed domain of your own.
progress.scua holds the save: a Save
record, load() with json.decode(text, Save)
(so a damaged or mistyped save is an Error, and a new game
only when there is no save), and save(s) with
json.encode(s, Save), which checks the whole value before
anything is written, then a temporary file renamed over the old one.
progress_test.scua saves a representative state the way the
game does, so a change that lets a field hold something with no saved
form fails a test, not a player's save. scua.toml declares
the app id, dev.example. and the project name; the game's
folder is named after it (see Save the player's
progress). Like scua init, it never overwrites a
file.
scua init zed-debug
/ scua init vscode-debug
Add a debugger configuration to an existing project
so you can step through a .scua file in your editor —
zed-debug writes .zed/debug.json,
vscode-debug writes .vscode/launch.json. Both
target the scua debug adapter the SCUA editor extensions
register, which needs the scua-dap binary on your
PATH.
$ scua init zed-debug
Wrote .zed/debug.json — SCUA debug configurations for Zed.
Debugging needs `scua-dap` on your PATH — build it with `zig build dap`.
Each writes two launch configurations: one that always debugs
main.scua, and one that debugs the currently open file.
They operate on the current directory by default, or a project directory
passed as an argument (which must already exist).
Unlike the scaffolding commands, these update a config file that's already there rather than refusing: the SCUA configurations are inserted into your existing list, leaving your other debug configurations — and any comments or formatting — untouched. Running the command again when a SCUA configuration is already present does nothing.
scua fmt [--check] <file>
Reformat a file in place. The formatter is deliberately conservative: it fixes each line's leading indentation (two spaces per block level) and trims trailing whitespace. It does not reorder tokens, change spacing inside a line, or touch the contents of strings and comments. A reformatted file always means exactly what it did before.
Given this file with a missing indent and some trailing spaces:
fn greet(who)
return `hi {who}`
end
print(greet("Sam"))
Running scua fmt rewrites it to:
fn greet(who)
return `hi {who}`
end
print(greet("Sam"))
Add --check to verify without writing. It changes
nothing on disk and exits 0 if the file is already formatted, or
non-zero with a message if it isn't, which is what you want in CI:
$ scua fmt --check greet.scua
scua: greet.scua: not formatted (run `scua fmt greet.scua`)
Once the file is formatted, the same command exits 0 and prints nothing.
What counts as a level. Every block opener
(fn, then, do,
match, try, and the statement-position
partition/migrate/on/ask)
adds one level until its end, and every open bracket
( [ { adds one until it closes.
Brackets count individually, so a table or list that opens on the same
line as the call it is passed to sits two levels in,
and its closer sits one level in, because the call's ( is
still open when the } closes:
let m = machine.new({
initial = "cold",
transitions = [
{ event = "warm", from = "cold", to = "warm" },
],
})
The one exception is a bracket that immediately wraps a block opener,
each(items, fn(x) … end): the fn
provides the level and the ( adds none, so the body sits
one level in and end) returns to the call's column. A name
that happens to be spelled like a keyword (on = 2 in a
table, end: int in a record, spec.then) is a
name and opens nothing.
If the formatter reaches the end of a file with a block or bracket
still open, it refuses with a message and writes nothing, and
--check reports that as a failure. That is deliberate: an
opener with no closer would have pushed every following line one level
in, and no test can catch a wrong indentation because indentation is not
meaningful to the language.
Because the formatter only touches indentation and trailing
whitespace, spacing inside a line is left exactly as you wrote it.
a+b stays a+b, and
let x = 1 keeps its inner spaces. So
scua fmt and --check are no-ops on a file
whose indentation is already correct, even if its operator spacing isn't
to your taste. Normalizing intra-line spacing is not something the
formatter does today.
scua fmt --fix file.scua also renames each call of the
built-in as(…) (and std.as(…)) to
parse(…), its new name, and says how many it changed. It
formats the file as scua fmt does too. A file that declares
its own as (fn as, a let as, a
parameter of that name) is left alone, and told why.
scua schema <file> --type Name
Emit a JSON Schema draft 2020-12 document for a
record or enum declared in
<file>. Used by agent harnesses and MCP as
inputSchema / outputSchema. Only the decidable
subset of where refinements maps; SCUA contracts stay
authoritative. See Write
tools for AI agents.
$ scua schema examples/agent_tool.scua --type Args
Like a run, it checks the project's scua-version first
(exit 3 when this scua is too old), and a syntax error in the file is
reported with its line, in the same words a run uses (exit 3).
scua blob-info <file>
Prints what a saved actor (the bytes actors.snapshot
returns) or a saved session holds, without running
anything: its formats beside the ones this build reads, its
generation, its identity as the locator logs use, whether a turn was
parked, whether it was signed with a deployment key, the parts it is
made of, how many functions it refers to, and the record types
it holds, by name and field list. A revive looks each of those
up by name in the program doing the reviving, so this is the question to
ask before a deploy: which saves will a change to Player
refuse?
$ scua blob-info session.bin
actor blob, actor format 8 (this build reads it)
generation 3
identity actor:3f1c09a2b7d45e60
parked turn none
integrity unkeyed: a checksum, which catches damage, not changes
section 1 partition, 2926 bytes
section 2 grants, 21 bytes
partition image, format 14 (this build reads it)
payload 2840 bytes
fingerprint 91d0f47800f98b20
functions 1 (1 named, 0 anonymous)
record types 1 held, 0 named only by the method table
held partition Holder {held;}
A save from an older version of SCUA says so and stops after its header: this build can't load it. Bytes that are not a SCUA blob at all exit with status 1.
Signed saves. A blob written under --blob-keys, or a session an
embedding host saved with its deployment keys
(scua_set_blob_keys), names its key by an id, and
blob-info checks it against the keys you give it with
--keys:
$ scua blob-info --keys deploy.keys session.bin
…
integrity keyed (84622b2ba9758d4e), verifies
The line says verifies, DOES NOT VERIFY
(the bytes changed after it was written), or that the key is not among
the ones given. Give a directory instead of a file to check every save
in it at once, each under its file name.
scua pack [--tier=compact|full|both] [--repo=DIR] [--out=PATH|-]
Generate a teach-a-model markdown pack (grammar, I/O
model, stdlib cheatsheet, few-shots from examples/,
reserved words, pitfalls). Token estimates print on stderr. Default
--tier=both writes scua-pack-compact.md and
scua-pack-full.md. Prefer compact in a system
prompt. See Write tools
for AI agents.
$ scua pack --repo=. --tier=compact --out=-
scua res build | list | check
Make and inspect the packs a program reads as res:
paths. See Ship a program's
data with it.
scua res build DIR -o FILE [--deflate=GLOB,…] [--exclude=GLOB,…] [--nfc]
scua res list FILE|APP.scuapp
scua res check FILE|APP.scuapp
buildpacksDIRintoFILE, writing the same bytes for the same input. Files are stored uncompressed unless--deflatematches them.--excludeleaves files out.--nfcwrites every name in NFC, the composed form a program types.SOURCE_DATE_EPOCHsets the timestamp it records. It refuses a symlink and a name that would not unpack on every OS, and warns about a name that is not in NFC.listprints every entry, with its method, size and CRC, and the pack's digests.checkruns every check a mount runs, reads every entry against its CRC, and exits 1 naming each damaged entry.
list and check take a .scuapp
too, and check its files against the SHA-256 the bundle records.
#scua user path ID
Print where a program's per-user folder is, one line per id, whether or not it exists yet. It creates nothing, and says on stderr when the folder does not exist or was not made by scua.
$ scua user path com.unabated.tinykeep dev.acme.lint
/Users/ed/Library/Application Support/com.unabated.tinykeep
/Users/ed/Library/Application Support/dev.acme.lint
A bad id stops it with status 2 and
E-USER-ID, and so does a platform where it cannot find the
folder (E-USER-NOBASE).
scua user adopt ID [--yes]
Use an existing folder that scua did not make as ID's per-user
folder: one copied from another machine by hand, or one Steam Cloud
downloaded before the game first ran. It prints the folder and what is
in it, with sizes, and changes nothing, exit 1. With
--yes it writes the folder's .scua-user
marker, exit 0. It never moves or deletes anything.
$ scua user adopt com.unabated.tinykeep
/Users/ed/Library/Application Support/com.unabated.tinykeep
saves/
scua user adopt: nothing was changed — to use this folder for com.unabated.tinykeep, run `scua user adopt com.unabated.tinykeep --yes`
$ scua user adopt com.unabated.tinykeep --yes
A folder whose marker holds another id
(E-USER-MISMATCH), whose name differs from the id only in
case, or whose marker is not an id (E-USER-FOREIGN), is
refused with status 2, and so is one that fails the
ownership rule below (E-USER-OWNER).
scua cluster dump <file.scdb>
/ scua cluster keys <file.scdb>
Inspects a durable shared-config snapshot — the file
a table written with cluster.open(name, { durable = "…" })
leaves on disk. It reads the file offline; it does not
join the cluster, so it's safe to run against a snapshot on any machine
without perturbing a live mesh.
dump prints every key with its value, in sorted order,
with deleted keys marked and the writing node and clock shown:
$ scua cluster dump game_config.scdb
economy/gems = 5 [node 1 @ 1784071308990]
economy/gold = 100 [node 1 @ 1784071308990]
flags/new_shop = true [node 1 @ 1784071308990]
graphics/res = (deleted) [node 1 @ 1784071308990]
# 3 live, 1 tombstone(s)
keys prints just the live key names, one per line —
handy for piping into another tool. A missing or corrupt file is a clear
error and a non-zero exit, not a crash.
#scua test [dir|file]
Find and run tests. It discovers every *_test.scua file
under dir (the current directory if you don't give one),
runs each, and reports pass/fail. A test fails when it faults or when it
returns an Error, including through ?. It
exits non-zero if anything failed, so it drops straight into CI.
Name a file instead and it runs just that one — what you want constantly while working on a single test:
$ scua test tests/parser_test.scua
PASS tests/parser_test.scua test_parses_a_call
1 passed, 0 failed (1 file)
The *_test.scua suffix is how files are
discovered in a directory; a file you name yourself runs
whatever it is called. Discovery follows symlinks, so a test directory
assembled out of links works the same as one of real files.
A test file can work two ways. If it declares
fn test_*() functions, each one runs on its own: a fault in
one (a failed assert, say) fails just that test, and the
others still run. If it has no test_* functions, the whole
file is a single test that passes if it runs without an uncaught
fault.
-- math_test.scua
fn test_addition()
assert_eq(1 + 1, 2)
end
fn test_division()
assert_eq(7 // 2, 3)
end
$ scua test
PASS ./math_test.scua test_addition
PASS ./math_test.scua test_division
2 passed, 0 failed (1 file)
Write assertions with assert(cond, msg?) or the
comparison helpers assert_eq(got, want),
assert_ne(a, b), and
assert_near(got, want, eps) (float-tolerant). A failing
comparison reports both values, in full: a test run shows what an
Ok holds, which an ordinary run's fault messages leave out
(see --debug-values). See Test your code for the full
walkthrough.
A compile warning is printed once per scua test run,
however many test files import the module it's in.
scua test takes --mod-path DIR, --deny-warnings, --dep-warnings, --no-warnings and --warnings, which work as they do
for a run, and it follows warnings in the project's
scua.toml as a run does without --profile.
With --deny-warnings, a file whose compile warnings would
stop a run fails, every diagnostic listed in its result, and its tests
don't run. It also accepts --debug-values and ignores it,
since a test run shows full values already.
Tests read res: as the program does: from the project's
res/ folder, with no flag. scua test also
takes --mount=PATH
and --res=PATH, relative to the project root rather
than to each test file. A test run grants no host files, so a test that
reads one fails. See Testing
code that reads the program's data.
#debug
$ scua debug file.scua
Runs the script under a terminal stepper instead of straight through,
so you can step line by line and inspect state as it executes. This is
an interactive session in your terminal, not a flag you combine with
normal output. Fault messages under the debugger show full values,
including what an Ok holds.
#Package verbs
$ scua add @acme/json
$ scua publish --registry /srv/acme-registry
$ scua vendor
These are handled by scua-pkg, a
separate binary on its own release stream, which scua
locates on PATH and executes — the same model as
git's and cargo's external subcommands. The
dispatched verbs are:
add remove update
upgrade fetch vendor
bundle verify why
explain diff sbom
trust doctor publish
yank search audit
registry
scua-pkg --help is the authoritative list and the place
their flags are documented.
Two things this arrangement guarantees, both deliberate:
- Running programs never needs it.
scua file.scua,scua test,scua fmt, andscua debugall work withscua-pkgabsent. Nothing on the script path touches the package manager. - No network code is in this binary. All fetching,
TLS, registry, transparency-log, and trust-policy code lives in
scua-pkg, which is what makes a network-free build flavour ofscuapossible at all.
If the tool is not installed:
$ scua add @acme/json
scua: E-PKG-TOOL-MISSING: `scua add` needs scua-pkg, and it is not on PATH.
…
The toolchain passes its own version to scua-pkg on
every dispatch (--toolchain 0.13.0). scua-pkg
supports the current and previous two toolchain minors, and refuses
outside that window with E-PKG-TOOL-SKEW rather than
emitting a lock or vendored tree this toolchain might half-read.
#--fast
Fast-forward the virtual clock. Timer-based waits resolve instantly instead of taking real time, while producing the same output and the same reported timestamps. Use it for demos, simulations, and tests where you don't want to sit through the waits.
A scheduling script that describes five seconds of game time runs in milliseconds:
$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A (t+0ms)
[regen] +10 hp (t+1000ms)
[regen] +10 hp (t+2000ms)
[patrol] move to B (t+3000ms)
[patrol] back to A (t+5000ms)
The t+...ms values are the in-program clock, so they
still read 1000, 2000, and so on even though no real time passed.
--dump-bytecode[=verbose]
Compile the script, print the bytecode the compiler produced, and
exit without running it. It's a diagnostic window into how your code
lowers, handy when you're curious what a construct compiles to or filing
a bug. --dump-bytecode=verbose prints more detail per
instruction.
$ scua --dump-bytecode hello.scua
The format is meant for a human reading it, not as a stable interface, so it can change between releases.
It is also the way to check that a whole tree still compiles, which is worth knowing when you upgrade. Because it stops before running, you can point it at every file you have and let the compiler answer, rather than searching for the constructs you think changed:
$ for f in $(find . -name '*.scua'); do scua --allow-all --dump-bytecode "$f" >/dev/null || echo "$f"; done
Two things make that better than a search. The compiler knows the exact set of escapes, keywords and forms it accepts and you are working from memory, so a grep encodes what you believe the language does. And the first error in a file hides the rest, so a file that failed for one reason may still hold another: after fixing one, compile again rather than assuming the file is clean.
--allow-all is there because resolving an
import of a capability module checks the grant, so without
it you get refusals for files that are fine. Files that reference a
host-provided global, or a declarations file, will still be reported,
and are the expected exceptions rather than problems.
#--log=<level>
Set the minimum log level. SCUA's logging has five levels, lowest to
highest: trace, debug, info,
warn, severe. Anything below the level you
pass is removed at compile time, so those statements and their arguments
never run. The default prints everything.
Given a script that logs at several levels, the default shows them all:
$ scua logging.scua
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...
With --log=info, the trace and
debug lines are gone (and any work done only to build them
is skipped):
$ scua --log=info logging.scua
[info] player Mara joined
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...
An unknown level is rejected:
$ scua --log=bogus file.scua
usage: --log=<trace|debug|info|warn|severe>
See Logging for the levels and how log statements are written.
#--log-time
Prefix each log line with an ISO-8601 timestamp. Useful when you want logs you can correlate with other systems.
$ scua --log-time logging.scua
2026-06-18T23:29:55.478Z [info] player Mara joined
2026-06-18T23:29:55.478Z [debug] hp is 30/100
...
#--log-stdout
Log lines go to standard error by default, because a
log line is a diagnostic rather than the program's output.
print and sys.write go to
standard output; info, warn and
severe do not. That means a caller can parse a script's
stdout without a log line turning up in the middle of it, and without
either side having to arrange anything:
$ scua tool.scua 2>/dev/null
{"ok":true,"total":"19.99 USD"}
$ scua tool.scua 1>/dev/null
[warn] two rows had no price
--log-stdout puts them back together, for when you want
a single merged stream to read yourself and don't care which is which.
(scua tool.scua 2>&1 also merges them, and keeps
them in the order the script wrote them — that ordering holds either
way.)
It combines with --log and --log-time; the
level floor and the timestamp work exactly as before.
#--output=<text|json>
Declare what this run's standard output is.
text is the default and the ordinary
case: stdout is whatever the script prints, and sys.emit's result
— if the script calls it — is appended to the same stream.
json says stdout is the tool's
result channel. sys.emit writes there and
nothing else does: print, sys.write and log
lines all go to standard error instead. So a caller gets exactly one
JSON value on stdout, whatever the script prints along the way:
$ scua --output=json tool.scua --a=1 --b=2
{"sum":3}
$ scua --output=json tool.scua --a=1 --b=2 2>&1 1>/dev/null
computing...
[warn] a diagnostic
Nothing is discarded — the script's own output is still there, on the other stream, and a script doesn't have to be written differently to be run this way. Use it whenever something is parsing the result; leave it off when a person is reading the output.
It's a flag on the run rather than on the script, because the caller parsing stdout as JSON is already the one who knows it wants JSON — so saying so costs it nothing, and a source file never has to describe how the process that runs it will be read.
#--jit[=on|always|off]
Choose how the JIT runs. It is on by default: SCUA compiles the hot loops, the ones that run enough times to be worth it, to native code, which makes long-running numeric and table-heavy work much faster. The result is identical to the interpreter: the JIT changes speed, not behaviour. The one thing it can change is timing between tasks or partitions that are busy at the same time: compiled code may hand over to the others at slightly different points, so their output can interleave differently. That order is not something to rely on either way.
fn sum_to(n)
let sum = 0
for i in 1:n do
sum = sum + i
end
return sum
end
print(sum_to(1000000))
$ scua --jit=on sum.scua
499999500000
The three values:
--jit=onis the default: compile hot loops as they warm up.--jit=offruns everything on the interpreter.--jit=alwayscompiles eagerly instead of waiting for a loop to warm up. It's slower to start and mainly useful for testing that the JIT and the interpreter agree.
The JIT runs on ARM64 (Apple Silicon and ARM Linux) and x86-64 (macOS and Linux). On any other platform the flag is accepted and ignored, and the script runs on the interpreter, so a script and its output are the same everywhere, with or without the flag. See Make scripts run faster with the JIT.
#--jit-stats
Print a one-line summary of what the JIT did to stderr when the run
ends: how many functions it compiled, how many times native code was
entered, and how many times it fell back to the interpreter (a "deopt").
Use it to confirm the JIT is engaging on your hot path. Passing
--jit-stats also turns the JIT on, so you can use it on its
own.
$ scua --jit-stats game.scua
[jit] mode=on compiled=2 native-entries=5 deopts=3 ...
The exact counters are for diagnostics and vary from run to run and
release to release. A compiled= above zero with a healthy
native-entries= means your hot loop is running as native
code; all zeros means nothing ran long enough to compile. In a program
with partitions, shared-compiled= counts the functions
compiled once for all of them: each partition runs that one copy rather
than compiling its own.
#-D name[=value]
Set a build flag that comptime if reads from the
build config. comptime if chooses a branch at
compile time, and only the chosen branch is compiled into the program. A
bare -D name sets the flag to true;
-D name=value gives it a value. You can pass
-D more than once.
Given a script that gates output on build.debug and
build.tier, with no flags both gates take their default
branch:
$ scua conditional.scua
free edition
game starting
Setting the flags selects the other branches:
$ scua -D debug -D tier=pro conditional.scua
[info] debug diagnostics enabled
pro edition: all features unlocked
game starting
See Conditional
compilation for how comptime if and build
work.
#--profile <name>
Select a named build profile from a scua.toml project
manifest. A profile is a bundle of build flags, so you don't have to
type the same -D flags every time. The manifest names a
default profile that's used when you don't pass
--profile.
For example, a scua.toml like this:
default = "debug"
[build.debug]
debug = true
tier = "free"
[build.release]
debug = false
tier = "pro"
A plain run uses the default (here debug)
profile:
$ scua app.scua
[info] debug diagnostics on
free edition
Asking for the release profile applies its flags
instead:
$ scua --profile release app.scua
pro edition
The flags set by a profile are the same build flags
-D sets, so the same comptime if gates respond
to both.
A profile can also carry run settings, which your program can't see,
in a [run.<name>] table beside
[build.<name>]; a profile may have either table or
both. See scua.toml.
#--mod-path DIR
Add a directory to search when resolving an import. By
default import name looks only next to the entry file
(<entry-dir>/name.scua); each
--mod-path DIR adds another directory, tried in
order, after the entry directory. Repeat the flag for several
directories. This is how you keep shared modules in a separate folder,
or split a project across directories.
$ scua --mod-path libs app.scua
$ scua --mod-path libs --mod-path ../shared app.scua
With app.scua containing import greet, the
runner tries ./greet.scua, then
libs/greet.scua, then ../shared/greet.scua. If
more than one of them exists the run is refused with both named, never a
silent pick; --mod-override greet=DIR chooses one
deliberately. A module's own imports look beside the module first
(libs/greet.scua importing util finds
libs/util.scua), then walk the same list. The built-in
standard-library modules (math, list, …)
always resolve first, and a file of yours carrying one of their names is
refused when that name is imported rather than silently unused. If no
directory has the module, the error names how many it tried:
$ scua app.scua
scua: app.scua:1: cannot resolve module 'greet' (tried 1 resolver)
(The directories form an ordered resolver chain. Today the chain is set on the command line; a future release adds project-manifest and in-script control.)
#--allow-file=PATH
Grant capabilities from a file instead of a row of flags. This:
$ scua --allow-fs=./data --allow-net=api.internal,cdn.example --allow-env=PORT,HOME \
--allow-serve=127.0.0.1:8080 app.scua
becomes this:
# permissions.toml
[capabilities]
fs = "./data"
net = ["api.internal", "cdn.example"]
env = ["PORT", "HOME"]
serve = "127.0.0.1:8080"
$ scua --allow-file=permissions.toml app.scua
The keys are the flag names, so there is nothing new
to learn: true means the bare flag (fs = true
is --allow-fs, rooted at the working directory), a string
or list is the constrained form, and false grants nothing —
useful when you want a policy to say "not this" out loud rather than by
leaving it out.
Two rules worth knowing. An empty list grants
nothing: net = [] means no hosts, not any host.
And an unknown key is an error, not a warning — a typo
like nett would otherwise silently grant nothing and show
up much later as undefined name inside your script, with
the file still looking right.
The [capabilities] header is optional. A file that is
nothing but the table works too, so a dedicated permissions file needs
no ceremony.
false and [] are refusals, and they
stick. That is what lets you say "everything except one thing"
in a single command:
$ scua --allow-all --allow-file=no-net.toml app.scua # no-net.toml is just: net = false
Any key can be the one you refuse — net = false,
tty = false, fs = false — and the refusal
holds against --allow-all on the same command line.
mount lists read-only res:
layers, as --mount does, with the same paths,
globs and pins:
mount = ["base.pak", "patches/*.pak", "extra.pak#sha256=9f86d0…"]
It is not a capability, since a mount grants nothing on the host. Any
--mount on the command line replaces the whole list, and
--show-res names the entries it ignored. See Ship a program's data with
it.
user = "ID" and
user-dir = "PATH" grant the per-user folder, as --allow-user
and --user-dir do. They are honoured only from a file
outside every folder the run can write, and with one hard link unless no
link can be written through (owned by you or root, with no write
permission for anyone), because a program could otherwise rewrite the
file to choose its own folder next time; anywhere else the run stops
with E-USER-POLICY. user = true is refused,
since there is no folder without an id.
A file scua cannot use (unreadable, not TOML, an unknown
key, a value of the wrong kind) stops the run with status
2.
Only a file you name applies. A scua.toml beside your
code grants nothing — capabilities come from what you type, so a script
cannot arrange to be trusted more next time.
#--allow-all
Grant every capability, each in its broadest form — fs
at the working directory, any host, any bind, any peer, every
environment variable, the empty cluster secret, the terminal. It never
grants the per-user folder: that needs an id, which only --allow-user
names.
$ scua --allow-all app.scua
This is for a script you already trust — usually your own. It is the honest answer to "just run it", which otherwise tempts people into not sandboxing at all; it is not something to reach for with code you have not read.
A narrower --allow-* flag still wins, which makes
"everything, except this one thing is restricted" a single readable
command:
$ scua --allow-all --allow-net=api.internal app.scua # everything, but net only reaches api.internal
#Which grant wins
Three places can grant a capability. They are resolved one capability at a time, most specific first:
- a
--allow-*flag on this command line --allow-file=PATH--allow-all
A flag is the most specific thing you can say, so it is never overwritten and never widened. Everything else fills in the capabilities you did not name.
All three are things you type. A capability file only applies when
you name it, so nothing a script can reach — including a
scua.toml beside your code — grants anything on its own.
The [exec] list in scua.toml is the same: it
names commands, and only --allow-exec
on the command line grants them.
#--allow-fs[=DIR]
Grant the script the fs capability — the ability to
read, write, and delete files and directories — rooted
at a directory. Without this flag a script has no filesystem access at
all: import fs is a compile error, because the
fs module isn't installed (this is the default-deny
capability model). With it, fs is available and every path
the script uses is resolved within the granted directory;
absolute paths and .. are rejected, so the script can't
reach outside the root. That root is also the whole blast radius of
fs.remove/fs.remove_all, which cannot delete
the root itself and unlink a symlink rather than following it — so
grant the narrowest directory that works.
--allow-fs alone roots at the current working directory;
--allow-fs=DIR roots at DIR.
The folder must exist: a missing one, or a file, is refused before
the program runs
(--allow-fs=gen: no such folder; create it first, exit 2).
The same goes for the fs key of a policy file.
import fs
match fs.read("notes.txt")
Ok(text) -> print(text)
Error(why) -> print(why)
end
$ scua notes.scua
notes.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant it with `--allow-fs` on the CLI, or the host's capability API when embedding
$ scua --allow-fs notes.scua
(the contents of notes.txt)
See Read and write
files for the full surface
(fs.read/fs.read_text/fs.write/fs.rename/fs.mkdir/fs.remove/fs.remove_all/fs.list/fs.exists/fs.stat/fs.lstat/fs.find/fs.grep)
and the rationale.
--allow-user=ID,
--user-dir=PATH and --user-adopt
Grant the program a per-user folder for its saves and settings. The
program writes user: paths, such as
user:saves/slot1.json, and every fs function
takes them. It is a second writable folder beside
--allow-fs, so a tool can hold both: the user's project,
and its own settings.
$ scua --allow-user=com.unabated.tinykeep game.scua
$ scua --allow-fs=. --allow-user=dev.acme.lint lint.scua
$ scua --user-dir=save game.scua # while developing: a folder beside the game
--allow-user=IDgrants the folder the platform keeps forID:%APPDATA%\ID(Roaming) on Windows,~/Library/Application Support/IDon macOS, and$XDG_DATA_HOME/ID(by default~/.local/share/ID) on Linux.scua user path IDprints it. An id is three or more lowercase parts separated by., each a letter followed by letters and digits, 7 to 128 bytes: a reverse-DNS name you own, such asdev.example.mytool. A bad one stops the run withE-USER-IDand the fix.--user-dir=PATHgrants the folder atPATHitself; nothing is appended. A relativePATHresolves against the program file's folder, as--mountdoes, so a launcher works from any working directory. On its own it is a grant; with--allow-userit is where that id's folder lives.--user-adoptis for a shipped launcher: it lets the run take over a folder for its id that exists but was not made by scua, such as one Steam Cloud downloaded on a second machine.
A project may declare its id in scua.toml, as a check,
not a grant:
[app]
id = "com.unabated.tinykeep"
A launch that names a different id then stops with
E-USER-MISMATCH, since a different id is a different, empty
save folder.
fs.mounts().user is nil without the folder,
and otherwise { id, path, created }. path is
the folder's absolute path when the run also holds
--allow-fs, and "" otherwise.
fs.mounts().state names the folders the run can reach,
joined by + in the order host, res, user:
"host+res", "res", "user",
"res+user", "host+res+user", or
"neither".
A program that uses user: paths still runs without the
folder: a one-line note on stderr says so before it starts, and a
user: call it reaches stops the run with status
4, naming the flag to add. So a program that saves only
when it can tests first:
import fs
if fs.mounts().user != nil then
fs.write("user:settings.toml", "volume = 3")?
end
When the program's scua.toml declares no
[app] id, or there is no scua.toml, nothing
checks the id --allow-user=ID names, so a mistyped one
would get a new, empty folder. The run goes ahead and says so once, on
stderr, suggesting the [app] id line to add
(--no-warnings hides it).
The run stops with status 2 and an E-USER-…
code first on the line, before any of the program runs, when the launch
cannot work as written: --allow-fs=user:ID
(E-USER-FLAG: use --allow-user=ID), a bare
--allow-user (E-USER-ID), no HOME
to find the platform folder from (E-USER-NOBASE: pass
--user-dir), or a folder that is, or contains, the
--allow-fs folder (E-USER-OVERLAP, because one
file would have two names). A folder inside a wider
--allow-fs (--allow-fs=$HOME) is allowed, and
--show-res
says so. Nothing with authority is read from inside the folder: the
program file and its scua.toml, --allow-file,
--env-file, --mod-path,
--blob-keys and --mount are refused there,
since the program could rewrite them for the next run. The program file,
its scua.toml, --allow-file and
--env-file are also refused with a second hard link, which
could be inside the folder, unless no link can be written through: the
file is owned by you or root, no one has write permission, and the run
is not root. A Nix store's files pass; copy a writable file out.
Holding the folder. Before the program runs:
- A missing folder is created, mode
0700, with any missing folders above it for--allow-user, and the run says so:scua: created the per-user folder for com.unabated.tinykeep: ….--user-dircreates only its last folder, so a missing parent (an unplugged drive) stops the run withE-USER-NOPARENT. - The marker. A folder scua creates gets a
.scua-userfile holding the id (hidden on Windows). An existing platform folder without one is not scua's: the run stops withE-USER-FOREIGN, namingscua user adoptand--user-adopt, and with--user-adoptit writes the marker and says so (scua: adopted the existing folder for …). A folder whose name differs from the id only in case is refused, since adopting would not help. A folder named with--user-dirgets a missing marker written. A marker holding another id isE-USER-MISMATCH. Nofscall, through any folder the run holds, reads, writes, creates or removes.scua-user,fs.list,fs.findandfs.grepskip it, andfs.remove_allrefuses a folder that holds it. - One run per folder. A second run of the same folder
waits up to about 5 seconds, printing
scua: waiting for another run using user: com.unabated.tinykeep…after one, then stops withE-USER-LOCKED: user: com.unabated.tinykeep is in use by another running scua …, exit status 9. The wait covers an overlap, not a queue: a run holds the folder until it exits, so many short runs in parallel (xargs -P 8) over one id stop withE-USER-LOCKED; run them one after another. Where the file system has no locks (some network shares), the run goes ahead with a warning. --user-sharedlets several runs use the folder at once, each launched with it: two windows of one tool server, or a command-line helper beside the running app. A shared run and one without the flag still exclude each other, so a game keeps its folder to itself. When the runs holding the folder share it and this launch has no--user-shared, theE-USER-LOCKEDmessage says that this launch is the one missing the flag. Runs that share a folder coordinate their writes withfs.lock.- Ownership. The folder must be owned by you, not
writable by other accounts, and group-writable only when the group is
your own primary group; every folder above it must be owned by you or
root and not writable by others (unless sticky, as
/tmpis). Otherwise the run stops withE-USER-OWNER, naming the folder, its owner and its mode, and thechmodthat fixes it. On a file system that stores no modes (FAT, exFAT, a Mac drive that ignores ownership) only the owner is checked, and--show-ressays so. Windows relies on the profile's permissions. - A link at the folder's place is followed, so the
saves can live on another disk; a link to a folder that does not exist
is
E-USER-LINK, and is never replaced.
--no-warnings silences the creation and adoption notices
and the no-lock warning.
#--mount=PATH,
--res=PATH and --show-res
A program reads its own data from res: paths: the
project's res/ folder, and packs above it. These flags
change what res: holds. They grant nothing on the host, so
they need no --allow-fs.
--mount=PATHadds a read-only layer above the others: a pack, a directory, orDIR/*.pakfor every pack there in natural order. It is repeatable, and a later one wins.PACK#sha256=HEXrefuses to run unless the pack's SHA-256 matches.--res=PATHuses a pack or a directory in place of the project'sres/folder.--res=noneturns that bottom layer off.--show-resprints the layers to stderr, top first, and then runs the program. With a per-user folder it prints one more line, its id and path, and another when the folder sits inside--allow-fs.
A relative path resolves against the program file's folder, so a
launcher works from any working directory. A pack that fails a check
stops the run with exit status 2 and an E-PACK-… code.
A save records the layers it ran with, and two flags decide what a revive does when they differ. See Saves and the packs they ran with.
--res-missing=allowrevives a save although a layer it had is not mounted now.--strict-resrefuses to revive a save whose layers changed since it was made.
--allow-file's
mount key lists mounts in a policy file, and
scua test takes --mount and --res
too. See Ship a program's data
with it.
#--allow-net[=hosts]
Grant the script the net capability — the ability to
make HTTP requests via the http module. Without it,
import http is a compile error (default-deny). With it, the
http module is available and the CLI backs it with a
built-in client (Zig's std.http and its pure-Zig TLS, so
HTTPS works with no system dependency).
A bare --allow-net allows any host.
--allow-net=host1,host2 restricts the script to a
comma-separated allowlist — a request to any other host returns an
Error before a packet is sent.
import http
match http.get("https://example.com")
Ok(resp) -> print(resp.status)
Error(why) -> print(why)
end
$ scua fetch.scua
fetch.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI (a program that only serves needs `--allow-serve` alone), or the host's capability API when embedding
$ scua --allow-net=example.com fetch.scua
200
See Fetch data over HTTP
for http.get/http.post, the response shape,
and the allowlist.
--allow-serve[=ADDR:PORT]
Grant the script the serve capability — the ability to
listen and answer requests, via http.serve and the
raw net.listen/net.accept. This is a
different authority from --allow-net:
making outbound requests never lets a script open a port, so listening
is its own grant. Without it, http.serve and
net.listen don't exist to call (default-deny), and
--allow-net, however broad, never confers it.
The grant names the address the script may bind:
--allow-serve=127.0.0.1:8080 allows binding exactly that
address and port, and nothing else. A bare --allow-serve
allows any bind — reserve it for a host you trust.
$ scua --allow-serve=127.0.0.1:8080 serve.scua
See Serve HTTP requests for the handler shape, the request/response records, and raw TCP.
#--serve-debug
Put the fault text into an http.serve 500 body. By
default, when a handler faults or returns an Error, the
client gets a 500 whose JSON body holds only a reference
({"error":"internal server error","ref":"r-…"}), which the
server's log line for that request also names, and the located message
is withheld: production bodies don't leak an internal
detail to whoever is calling. --serve-debug puts that
message in the response body so you can see it while developing locally.
Leave it off in anything real.
Either way, the message goes to stderr as one line naming the
request, such as
scua: app.scua:12: http.serve GET /orders/7 answered 500: …,
rate-limited to 20 lines at once and then one a second. See Faults a
running program survives.
$ scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua
#--serve-check
Run the program up to its first http.serve call, apply
every check http.serve makes before it starts serving, and
exit instead of opening the port. It is for CI: a test suite that drives
a handler through a stub never reaches the call, so it cannot see the
refusals that happen there, such as a handler that reaches a
let, a function in context that does, or
module state written before serving.
- Every check passes: one line on stderr naming the handler and the
bind, and status
0. Nothing is bound, and the lines after the call do not run. - A check refuses: the same message and the same status as without the
flag (
5for a fault). - The program ends, or calls
sys.exit(0), without starting a server: status8and a line saying so, so the step cannot pass by accident. That includes a program that carries on past anErrorthathttp.servehanded back instead of starting (under--io=blocking, for example).
$ scua --allow-serve=127.0.0.1:8080 --serve-check app.scua
scua: --serve-check: http.serve would serve `handle` on 127.0.0.1:8080; every startup check passed, and nothing was bound
The startup code before the call runs for real, so pass the same grants and flags as the real run: the checks read them. Because the port is never opened, it cannot tell you whether the port is free where the server will run. See Check a server in CI without starting it.
#--blob-keys=FILE
Sign every actor snapshot this run writes with a key of your
deployment, and revive only snapshots that verify against your keys. A
snapshot carries everything about its actor, including what it is
allowed to do, so on a server where saves pass through storage or a
network you don't fully control, sign them: a blob changed after it was
written, or written by anyone without your key, is refused with
kind = "integrity".
FILE holds the keys, one per line, each 64 hex digits (32 random
bytes). The one to sign with is followed by the word sign;
the others only verify. # starts a comment:
# deploy.keys — keep this file out of every directory a script may read
4f1d…e2 sign # current
9a07…c3 # previous: still verifies the saves it signed
To rotate, add a new key marked sign and drop the mark
from the old one: saves signed under the old key still revive. When
actors.stats().loads_by_key stops counting revives under
the old key's id, remove it.
The keys never reach your script: no function reads them, and the run
refuses to start if FILE is inside the directory --allow-fs
grants or the per-user folder, has a second hard link while either is
granted (which could be inside it), is inside a folder mounted as a
res: layer, or is itself a mounted pack, or if
--allow-exec-any is given (a script could run a command
that reads it). They are never taken from the command line itself or the
environment.
A keyed run refuses a snapshot saved without a key. While you move a
deployment over, pass --accept-unkeyed-blobs to revive
those too.
#--allow-fork
actors.revive(blob, { fork = true }) starts a second
live copy of a saved partition, with its state, its grants and every
reference it holds, under an identity of its own. That is more than
reviving, which brings back the one copy, so it needs this flag: without
it a fork returns Error({ kind = "not_granted" }), and a
plain revive works as before. The partitions your program creates
inherit it from the code that created them; a sandbox never has it.
--allow-all does not include it. A host embedding SCUA
grants it with scua_grant_fork.
#--strict-constants
A revived actor that stored a value it took from a top-level constant
gets the current program's value (see what
a revived partition sees). When the constant is gone, or the place
the value came from has changed, the actor keeps the value it stored,
and the load report counts it. --strict-constants refuses
such a revive instead (kind = "constant_gone" or
"shape_changed"), for a deployment where a removed constant
should stop things loudly.
#--debug-values
Show full values in fault messages. A fault message names the value
that caused it, but never shows what an Ok holds, because
that could be a decrypted secret and stderr often goes to a logging
service: it says got an Ok instead. When you need the value
to understand a fault and can't run the debugger, pass
--debug-values:
$ scua --debug-values secret.scua
scua: --debug-values is on: fault messages and logs will show the values they would otherwise hide (decrypted data and secrets included). Never use it in production.
scua: secret.scua:4: match: no arm matched (got Ok(hunter2))
It covers every text that hides an Ok's contents: a
match with no matching arm, assert_eq and
assert_ne, a condition given a Result, a Result refused
where text is built (a template, .., str.join
and the like), the value of a fault from a typed parameter,
an Ok inside sys.fail's reason, and the stderr
line for a partition handler whose turn ended with an
Error. With --serve-debug too, an
http.serve 500 body shows the same text.
The first line is always printed, before anything runs, so a log that
holds values says why. The flag is read from the command line and
nowhere else: scua.toml, a build profile, an environment
variable and an application bundle can't turn it on, so a deployed
configuration can't reveal values by accident. scua test
and scua debug show full values without it. A host
embedding SCUA never shows them.
#--check=deep
Every type annotation checks all the way down, as parse
does, without converting anything: nested records, collection elements
and map values, and their where rules. Without it an
annotation checks one level. It only adds faults, so a program that
passes with it passes without it; it costs a walk of each value, so use
it for tests and CI rather than for shipping.
SCUA_CHECK=deep in the environment does the same, and
scua test --check=deep runs tests this way. See Checking
typed data all the way down.
#--deny-warnings
Make a compile warning stop the run. Every diagnostic is printed as
usual, and if any warning is in your own files, scua exits
with status 3 before anything runs:
$ scua --deny-warnings report.scua
scua: report.scua:6: warning: `tonumber` is given an array ({ int }), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: --deny-warnings makes a warning fail the run
It is meant for CI and agent harnesses, which control the command
line rather than the source. It applies only to warnings the checker
gives before the run: a warning scua test or the debugger
gives while a program runs never stops it. A warning in a package you
depend on doesn't count unless you also pass --dep-warnings.
A file can ask for the same thing itself: when its first line is
--!strict, a compile warning in that file stops the run,
whatever the command line says.
#--dep-warnings
Show the compile warnings of the packages you depend on. A module
that comes from your project's lock (deps/), or from a
package inside an application bundle, is a dependency: you can't fix its
warnings except by upgrading it, so they are quiet by default, and
neither --deny-warnings nor the package's own
--!strict fails your run on them. Files beside your script
and on --mod-path are yours.
--dep-warnings prints them. With
--deny-warnings as well, they count too, so a deploy gate
can refuse a dependency that warns:
$ scua --dep-warnings --deny-warnings main.scua
scua: mathx.scua:2: warning: `tonumber` is given an array ({ any }), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: --deny-warnings makes a warning fail the run
A package's own CI runs its tests with the package as the root, where its files are its own and its warnings show.
#--no-warnings
Print no compile warnings for this run. Errors still print and still
stop the run with status 3; only the warnings go quiet. It
is for a tool or an agent that reads every line of stderr, where a
warning about code it isn't changing is noise:
$ scua --no-warnings report.scua
It also hides the note a run prints before it starts when the program
uses user: paths and has no per-user
folder.
Two things still print:
- A warning in a file whose first line is
--!strict. That file made its warnings errors, so the run stops on it, and the line says why. - A notice that a package you depend on was written before a breaking change and needs updating. It is one line per package, and it is never hidden, because it is often the only sign that a package will fail when it runs.
It also quiets the warnings scua test and
scua debug give while a program runs. Your editor keeps
showing every warning, so you still see them while you write the
code.
--no-warnings asks for the opposite of --deny-warnings and --dep-warnings, so passing it
with either one is a usage error (status 2) rather than a
guess at which you meant:
$ scua --no-warnings --deny-warnings report.scua
scua: --no-warnings and --deny-warnings ask for opposite things (one hides compile warnings, the other makes them stop the run); pass one or the other
A project can turn warnings off for everyone who runs it, with
warnings = false under [run] in its
scua.toml, or under [run.<profile>] for
one profile (see scua.toml). The command line
wins over the file in both directions, without an error:
--warnings shows the warnings the file turns off, and
--deny-warnings or --dep-warnings show them
too, so a quiet project's CI can still run
scua --deny-warnings. A script with no
scua.toml warns as usual.
#--warnings
Print compile warnings even where the project's
scua.toml turns them off (warnings = false
under [run]). Without such a file it changes nothing, since
warnings print by default. It can't be combined with
--no-warnings.
$ scua --warnings report.scua
#--allow-exec and
--allow-exec-any
--allow-exec lets the program run the commands listed
under [exec] in the
project's scua.toml, through exec.run and
exec.spawn, and nothing else:
$ scua --allow-exec build.scua
The list is read from the project's own scua.toml; for a
script inside a locked package's folder, that is the project whose lock
vendored the package. The flag takes no value
(--allow-exec=git stops with status 2, naming
the [exec] table), and a launch whose
scua.toml has no list, an empty one or a malformed template
stops with status 2 before any of the program runs. An --allow-file
exec = [ … ] list is the same grant from a file outside the
project; with --allow-exec, the file's is not read.
A list of commands is honoured only from a file the program cannot
rewrite. If it could, a run could add a command and the next run would
execute it. So the launch stops with status 2 and
E-EXEC-POLICY when the file holding the list is inside the
--allow-fs folder (compared by
real path, so --allow-fs=.., a home folder or a symlink to
the project all count), when that folder holds the program's own folder
(where a scua.toml the program wrote would be found first),
when it is inside the per-user folder, or when it has a second hard
link:
E-EXEC-POLICY: --allow-exec: the commands under [exec] in /home/me/tool/scua.toml are refused because /home/me/tool/scua.toml is inside --allow-fs=. — the program could add a command to the list and the next run would execute it. Grant a folder that holds neither the program nor its scua.toml, such as --allow-fs=./data
Grant the program a folder of its own for the files it writes, such
as --allow-fs=./data. A packaged app's list is inside the
app file, which no grant reaches, so it is never refused this way.
--allow-exec-any lets the program run any command:
exec.run_any, exec.spawn_any, and
exec.run and exec.spawn too, which still check
a list when one is declared. A command runs as you, so this is
delegation rather than containment, and --allow-all never grants it. See Run commands.
A program refused for a missing exec grant is told which
flag it needs: --allow-exec when it calls only
exec.run or exec.spawn,
--allow-exec-any when it calls an _any
verb.
#--allow-env[=NAMES]
Grant the script the env capability — the ability to
read environment variables via the env module. Without it,
import env is a compile error (default-deny). With it, the
env module is available and env.get(name)
reads from a snapshot of the environment taken when the run started.
A per-name allowlist is the safe default:
--allow-env=NAME1,NAME2 exposes only those variables, and
env.get returns nil for anything else, so a
script can't read a secret you didn't name. A bare
--allow-env exposes every variable.
import env
let home = env.get("HOME") ?? "(not set or not allowed)"
print(home)
$ scua whoami.scua
whoami.scua:1: module `env` needs the `env` capability, which this run was not granted — grant it with `--allow-env` on the CLI, or the host's capability API when embedding
$ scua --allow-env=HOME whoami.scua
/Users/sam
See Read
environment variables for env.get, the snapshot
semantics, and the allowlist.
#--env-file=PATH
Load a .env file and grant env for exactly
the names in it:
$ scua --env-file=.env app.scua
Naming the file is the grant — no separate
--allow-env, and no writing the file's key list twice. With
--env-file alone the script sees the file's names and
nothing else: the real process environment stays hidden, so a stray
secret in your shell can't reach it. Add
--allow-env=NAME,... for specific real variables too.
Precedence is one rule — nothing overwrites what is already set — applied in this order:
- the granted slice of the real process environment
- the first
--env-file - each later
--env-file
So a real variable beats the file (which is what makes
.env a defaults file), and an earlier file beats a
later one:
$ DB_HOST=prod.internal scua --env-file=.env app.scua # the real DB_HOST wins
$ scua --env-file=.env.local --env-file=.env app.scua # .env.local wins; .env fills the gaps
The format is the usual one: KEY=value,
# comments, an optional export prefix,
'literal' and "basic" quoting (the latter
processes \n, \r, \t,
\\, \"), either able to span lines.
KEY= is the empty string — set, not absent — and a repeated
key keeps its last value (while across several
--env-files, the first file wins).
Two things worth knowing: a Windows path needs single
quotes ("C:\temp" contains \t, so it
reads as a tab; 'C:\temp' is verbatim), and
${VAR} is not interpolated — it stays
literal, because a value that could name another variable would be a way
around the allowlist you just set.
A file the parser can't read stops the run and says why. See Read environment
variables, and dotenv.parse in the builtins reference for reading a
.env file as ordinary data with no grant.
--allow-cluster[=SECRET]
Grant the script the cluster capability — a small
cluster-wide config table that converges across nodes, via the
cluster module. Without it, import cluster is
a compile error (default-deny). SECRET is the cluster's
shared MAC secret: every node meshing the same table must present the
same one, and a mismatch is refused rather than trusted. A bare
--allow-cluster uses the empty secret, which is fine for
local dev and demos but not for anything real. The actors a script
creates hold the grant too, with the same secret, so a handler opens and
writes the same tables as the top-level code.
$ scua --allow-cluster=hunter2 config.scua
$ scua --allow-cluster config.scua # empty secret (local/demo)
See Share config across a cluster for the table surface and the multi-node story.
#--allow-udp[=PEERS]
Grant the script the udp capability — sending and
receiving UDP datagrams via the udp module. Without it,
import udp is a compile error (default-deny). This is a
separate grant from --allow-net, never
implied by it: a datagram can be aimed at a third party, so unscoped UDP
is its own authority.
The grant names peers, not just "yes".
--allow-udp=1.1.1.1:53,10.0.0.0/8:7946 is a comma-separated
allowlist of ADDR[:PORT] or CIDR[:PORT]. A
host is resolved and then the resulting address is checked, so a name
that resolves outside the allowlist is refused. A bare
--allow-udp allows any peer — keep it to a host you
trust.
Receiving from anyone (udp.bind) additionally needs
--allow-serve, since hearing from strangers is listen
authority on top of datagram authority.
$ scua --allow-udp=1.1.1.1:53 dns_query.scua
See Send datagrams with UDP for the socket surface and the guardrails.
#--udp-max-payload=N
Raise the datagram payload cap above its 1200-byte default. That
default is the portable-safe floor: a fragmented datagram is
all-or-nothing, so fragmenting multiplies your loss. Raise it only when
you know the path can carry larger packets. A send over the cap is a
loud Error, never a silent truncation.
#--allow-tty
Grant the script the tty capability — asking the person
at the keyboard a question, via sys.readline() and
sys.readpassword(). Without it those two don't exist to
call (default-deny), because a terminal is where a human types
passwords, so reading one is authority like fs or
net rather than something every script gets for free. An
actor never inherits it.
$ scua --allow-tty greet.scua
This is a different thing from piped input:
sys.stdin() reads a payload piped into the script and needs
no grant, and neither flag nor grant turns one into the other. In a
capability file the key is tty = true (or
tty = false to refuse it outright, which holds even under
--allow-all).
See Ask the user a question for the prompt shapes, the end-of-input rule, and why not to mix the two ways of reading input.
#--allow-sandbox
Grant the sandbox capability: the program may compile
and run SCUA source strings in isolated,
capability-free partitions (import sandbox).
This grants the authority to create a sandbox. The sandboxed code itself receives nothing — not this capability, and not any other one the run holds, so a sandbox cannot reach the filesystem, the network, the environment or the clock, and cannot create sandboxes of its own. There is no flag that relaxes that. The actors a script creates hold the grant too, so a handler can create a sandbox as the top-level code can; a program that embeds SCUA decides for itself whether its actors do.
Use it when your program runs code that came from somewhere else: a player's mod, a rule someone typed in, a tool a model generated. See Run code in a sandbox.
#--io=<async|blocking>
Choose how the script's capability I/O (fs,
http, exec and net calls) runs.
The default is --io=blocking: each call blocks until it
returns, which is exactly right for a straight-line script.
--io=async lets a call that has to wait
park instead, so the rest of the program runs
meanwhile: other tasks started with spawn, other
partitions, and the other arms of a wait_all/map_all.
fs and exec calls, https://
requests and http.open/http.read run on an
offload pool; net.dial, net.accept,
net.read and net.write wait for their sockets
on a single network poller thread and do the I/O themselves when the
socket is ready, and a host name is looked up on one of two shared
resolver threads (a dial returns
Error("net.dial: the resolver is busy") when 64 lookups are
already waiting). A TLS handshake still runs in place; reads and writes
on the encrypted connection park like any other.
An http.get, http.post or
http.request to a plain http:// URL waits on
the network poller the same way, so a thousand calls waiting on a slow
service hold a thousand sockets and no threads. Its connections are kept
open between calls and reused, up to
--http-max-connections=N per host and port (default 256); a
call that finds them all busy waits for one to come free, within its own
time limit. A connection is reused only after its whole response has
been read and only when the response allows it; one left idle for 4
seconds is closed. When a call gives up on a server that has not
answered yet, the connection stays open long enough to read and discard
the late answer (up to 10 seconds), then goes back to be reused, so a
slow server costs calls waiting rather than new connections.
This is an optimization flag, not a requirement. The
values are identical either way — for a program that
finishes in both modes and whose calls finish within their time limits —
and so is the all-settled behaviour of wait_all. What may
differ is the order of lines printed by tasks that run at the same time,
because they resume in the order their I/O finishes. Two things stay as
they are under the flag: an http.serve handler still waits
in place for its calls, and calls on one net connection in
the same direction still complete in the order they were made. Write the
code once and add the flag when overlap pays off.
Three limits keep parked net calls bounded, each with a
flag that raises it and an error that names the flag:
--net-max-parked=N (calls one partition may have waiting,
default 1024), --net-max-queued-write=BYTES (unsent bytes
that parked writes may hold on one connection, default 16 MiB), and
--net-max-registrations=N (sockets watched at once, default
16384). That last one is a single limit for the whole process: the
connections an http.serve holds open count against it as
well as the sockets parked calls wait on, so a server near it accepts
fewer connections and a parked call past it is refused. With
--mem-stats, a [net] line reports the
high-water mark of each, and of the watched sockets both together and
split into the two kinds. Calls still waiting when the program exits, or
when it is stopped with Ctrl-C or SIGTERM, are named on
stderr:
scua: 2 net calls still waiting at exit (net.read db.internal:5432 …).
--log=debug adds a line each time a net call
parks, resumes or queues behind another; a call that has waited 30
seconds says so at info, which is on by default.
$ scua --io=async --allow-net=api.example.com fetch_all.scua
#--workers=N
Run partitions on N threads at once. The default is
--workers=auto: one thread per full-speed core, at most 8.
auto leaves out the efficiency cores on Apple silicon and
respects a container's CPU limit on Linux, so a program in a two-CPU
container gets two threads however many the machine has. Partitions that
are waiting on the network or busy computing don't hold each other up:
three partitions that each make a 300 ms network round trip finish in
about 0.3 s rather than 0.9 s.
The price is a repeatable order. Each partition still handles one
message at a time, and messages from one sender still arrive in the
order they were sent, but lines printed by different partitions
can come out in a different order from run to run.
--workers=0 gives that order back: the partitions take
their turns one at a time on the main thread, and the same program
prints the same lines in the same order every time. Today
--workers=1 does the same; a later release gives
1 a thread of its own beside the main script, so write
0 when the order is what you want. See Several
cores by default; one core is repeatable.
It spreads partitions across cores, not the tasks inside
one. Tasks started with spawn in a single partition still
take turns, so to run several network calls side by side, give each its
own partition. A program with no partitions is unaffected.
$ scua --workers=4 --allow-net=db.internal pool.scua
$ scua --workers=0 report.scua # the same lines in the same order, every run
To choose without changing the command line, set
SCUA_WORKERS to a number or auto. The flag
wins when both are given. scua test,
scua debug and --fast always run the
partitions one at a time: they ignore SCUA_WORKERS,
scua debug and --fast refuse
--workers=N for any N but 0, and
scua test doesn't take the flag.
Any count is accepted. A count above the cores the process can run at full speed prints one line when the program starts: the extra threads cost memory and add no speed.
$ SCUA_WORKERS=0 scua server.scua
--locale=<tag|host>
and --tz=<offset|host>
Set the system locale and timezone the script reads via
sys.locale() and sys.timezone(). Both are
neutral by default — with no flag,
sys.locale() is "und" (undetermined) and
sys.timezone() is 0 (UTC) — so a run is
reproducible across machines unless you say otherwise.
--locale=de-DEsets the locale tag (any BCP-47 string; stored verbatim).--tz=+02:00sets the UTC offset; accepts±HH:MM,±HH, andUTC/Z. Non-hour offsets work:--tz=+05:45. IANA zone names (Europe/Berlin) are not supported — use an offset.--locale=host/--tz=hostread the real device setting (fromLC_ALL/LC_CTYPE/LANGandTZ). This is the one explicit, opt-in source of nondeterminism — like--fastfor the clock.--tz=hostneeds a numericTZoffset; if the host only has a zone name, pass--tz=±HH:MMinstead.
$ scua app.scua # locale=und, tz=0 (UTC)
$ scua --locale=de-DE --tz=+02:00 app.scua # pin both (great for tests)
$ scua --locale=host --tz=host app.scua # read the device
The timezone offset feeds the date layer: time.now() and
time.of(...) default to it. See Set the locale and
timezone and Dates and
times.
#--mem-stats
and --gc-threshold=BYTES
Two diagnostics. They exist to answer "where is my
memory going", not to tune a deployment, and neither is a stability
promise — treat them the way you would --jit-stats.
--mem-stats prints one line to stderr as the script
exits:
$ scua --mem-stats build.scua
[mem] live=25291648 peak-heap=25291648 live-after-gc=8514416 peak-live-after-gc=8514416 \
final-live=16903024 peak-gc-transient=17087088 external=0 alloc-total=25349896 \
gc=1 gc-copied=8514416 demotions=0 partitions=1 all-live=25291648 gc-pause-max-us=2210 \
trims=0 trim-pause-max-us=0
| field | meaning |
|---|---|
live |
arena bytes plus external buffer bytes held right now — including garbage that hasn't been collected yet |
peak-heap |
the run's high-water for that figure; again, occupancy, not live data |
live-after-gc |
what was actually live immediately after the most recent collection
(0 if none ran) |
peak-live-after-gc |
the largest such live set over the run |
final-live |
the live set after one collection forced once your script has finished — the honest "what did this program end up holding" |
peak-gc-transient |
the most memory a single collection needed for both spaces at once |
external |
bytes in large element-typed buffers, which live outside the arena |
alloc-total |
every byte ever allocated, garbage included — the churn, which
live can't show |
gc / gc-copied |
collections, and the live bytes they copied |
demotions |
typed arrays that fell back to boxed storage |
partitions / all-live |
how many partitions exist, and their summed current occupancy |
gc-pause-max-us |
the longest single collection, in microseconds, of any partition this run collected |
trims / trim-pause-max-us |
collections of partitions that had sat idle with garbage while others kept working, and the longest of them |
The distinction that matters most: live and
peak-heap include garbage. SCUA allocates by
bumping a pointer, so until a collection runs it genuinely cannot tell
live data from dead. If you want "how big is my data", read
final-live. If you want "how much did the process have to
hold", read peak-heap. Every field except
partitions/all-live,
gc-pause-max-us and the two trim fields describes the main
partition only — each actor has its own.
A program stopped by a signal prints no [mem]
line, and no --jit-stats lines either. The line is
written as the program finishes, whether it ends normally or with an
error; Ctrl-C, or kill sending TERM, ends the process
before that point. A server stopped that way finishes its requests and
its program carries on to its end, so the line is printed then; to watch
a server while it runs, use --serve-stats-every=N, which
prints the arena bytes each request allocated and the collection rate
every N requests.
--gc-threshold=BYTES sets how much a program may
allocate on top of what it is already holding before the
collector runs (default 8 MiB). Lower it and a script collects sooner
and holds less; raise it and it runs longer between collections and
holds more. The tradeoff is monotone in the direction you'd expect, so
it is a reasonable dial to turn as well as to look at:
$ scua --mem-stats --gc-threshold=256K build.scua
Note this is headroom above the live data, not a total. A program holding 200 MB with the default 8 MiB floor collects at about 208 MB, not at 8 MiB — otherwise a program whose data is simply large would be collected continuously and reclaim nothing each time.
The 8 MiB floor is the main program's. Each partition collects on its
own, from a floor of 128 KiB (less while its memory is still small), or
--gc-threshold if that is lower: a program can have
thousands of partitions, and 8 MiB of garbage in each one would add up
to gigabytes. Above the floor a partition collects when it has allocated
as much again as it was holding, the same rule as the main program, so a
partition holding a lot of data is not collected any more often than
before.
#Serving: limits, timeouts and stopping
These shape how http.serve treats clients and how it
stops. All of them are only meaningful with --allow-serve. A duration
is written 10s, 500ms or 2m; a
bare number is milliseconds, and 0 turns that limit off. Serve HTTP requests shows them at
work.
| Flag | Default | Meaning |
|---|---|---|
--serve-timeout=DUR |
30s |
How long one request may take, from its first byte to the end of the handler. A handler still running then gets a 503 and the connection closes; the server carries on |
--serve-header-timeout=DUR |
10s |
The request line and headers must arrive within this of their first byte; a connection that sends nothing is closed after the same time |
--serve-body-timeout=DUR |
30s |
The body must arrive within this of the headers, and after its first 10 seconds at 1 KiB a second or more on average |
--serve-write-timeout=DUR |
30s |
A response the client stops taking for this long is given up on and the connection closed |
--serve-idle-timeout=DUR |
75s |
A kept-alive connection with nothing to do is closed |
--serve-max-conns=N |
10000 |
Connections held at once, fewer when the open-file limit is lower; more wait in the operating system's queue |
--serve-max-buffered=BYTES |
64M |
Request bytes received but not yet a whole request, across every connection (an eighth of the container's memory limit when it has one); past it the slowest connections are closed first |
--serve-drain-delay=DUR |
0 |
After Ctrl-C or SIGTERM, keep accepting and answering
this long before stopping |
--serve-grace=DUR |
25s |
Then wait this long for requests in flight; whatever is left is
abandoned and the run exits 75 |
--serve-stats=DUR |
off | A line of request counts, latency and connections every
DUR |
--serve-stats-every=N |
off | Every N requests, the arena bytes each one allocated
and the collection rate (a diagnostic) |
--serve-log |
off | One JSON line per request on stderr |
A request that runs out of time while it is still arriving gets
408 Request Timeout. A request whose handler runs past
--serve-timeout gets 503:
$ scua --allow-serve=127.0.0.1:8080 --serve-timeout=2s --serve-debug slow.scua
HTTP/1.1 503 Service Unavailable
handler exceeded the request deadline (--serve-timeout)
The explanation in the body only appears with
--serve-debug; without it the 503 is bare, like every other
error a client sees. Granting serve arms the request
deadline — you turn it off rather than on, because a server runs work
chosen by someone else over and over. It is measured against the real
clock, so it is not armed under --fast or
scua test, where the clock fast-forwards.
--serve-stats needs a unit: a bare number used to mean
"every N requests", which is --serve-stats-every=N now, and
is refused rather than read as milliseconds.
The first stop signal drains; a second one ends the process at once.
SIGHUP does not stop a server. The run exits 0
after a clean stop and 75 when the grace ran out with
requests abandoned, unless a fault or sys.exit chose the
status first.
#--max-ops=N and
--max-mem=BYTES
Bound what an untrusted script may consume. Both take a count with an
optional k/m/g suffix
(--max-ops counts in 1000s, --max-mem in
1024-byte units), so --max-ops=5m is five million and
--max-mem=64M is 64 MiB. These are the CLI equivalents of
the op_budget and mem_cap arguments an
embedding host passes to scua_eval.
--max-ops caps the steps any single
turn may take, and stops the turn at its Nth. When a script reaches it,
an uncatchable kill-switch stops the run, naming the budget it used and
how to change it. A loop that never yields is bounded in CPU, so it
can't spin forever:
$ scua --max-ops=100k spin.scua
spin.scua:2: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`
What counts as a step. Exactly two things, one step each, added into one total:
- a function call — to a function in your script, in
a module you imported, or in a package; to a built-in or host function;
a method call; a
pcall. Where the function is does not matter. - a loop iteration — each time a
while,foror comprehension goes round again.
Nothing else counts. Straight-line code between calls is free, and so
is the work a built-in does inside its one call: hashing a megabyte and
adding two numbers are each one call. (A regex search is the exception:
it counts its own matching steps, so a pathological pattern is bounded
too.) A few built-ins are compiled into single instructions rather than
calls, and cost nothing: len, push,
pop, remove, contains,
slice, to_int, and the vector and quaternion
operations. The count is the same with the JIT on or off.
It is a safety budget, not a cost model. It exists
so that runaway work (an infinite loop, a runaway recursion) is always
stopped, and it does that whichever path the work takes. It does not
weigh work, so don't use it to compare the cost of two code paths or as
a stand-in for time: two paths that make the same calls and loop the
same number of times cost the same steps however differently long they
take. A run that takes S steps finishes under
--max-ops=S+1; that is the number to raise a budget from if
a genuine workload hits it.
Without the flag there is no CPU limit. The rule is
one sentence: a budget you set kills; the built-in one does
not. There is still a built-in ceiling of 100,000,000 steps, but
reaching it now refills it and carries on, so a long computation simply
finishes — a loop of a few hundred million iterations used to be stopped
a third of the way through and no longer is. Type
--max-ops=N and you get a real kill-switch, exactly as
before.
That split is deliberate. Writing a tool at a command line, you are
not the person the limit was for; running someone else's script, you
are, and then you say so. The same rule holds everywhere a budget is
chosen for you: a sandbox mod's, and an embedding host's
op_budget, both still kill.
--max-ops=0 is refused rather than obeyed: a budget of
zero would stop the script on its first step, which nobody means when
they type it. For no limit, write none.
The line in the message is approximate under the
JIT. With --jit=on the reported line is the
enclosing call rather than necessarily the exact step that ran the
budget out, because the JIT charges a loop iteration's cost up front and
refunds what it skips, where the interpreter charges call by call.
Whether the budget trips, and what the run prints, are identical either
way; only the line can differ. Re-run with --jit=off if you
need it pinned to the exact step.
The budget is per turn, so it also caps
each actor or coroutine turn the script spawns — and it is refilled at
the top of each one. That means it bounds how much work any single turn
may do, not how long a program may run: a loop that waits or polls
yields between turns and is not bounded by this at all. Use a timeout on
whatever the loop is waiting for, or --max-total-ops below.
Creating an actor is one call like any other, so the budget limits how
many actors one turn can create but not how many a program creates over
many turns. --max-mem is what limits that, because every
actor's memory counts against it.
To bound the whole run, use
--max-total-ops=N. It counts the same steps as
--max-ops, but for the run as a whole: every turn of the
main script and of its partitions adds to one total, and a
wait does not start it again. A tool that loops with a
sleep between rounds, which --max-ops never stops, stops
once it has taken N steps in all, with the same exit status (6):
$ scua --max-total-ops=600k poll.scua
poll.scua:4: the run used its whole budget of 600000 steps, set by `--max-total-ops`. Raise it, or remove the budget with `--max-total-ops=none`
The two flags combine: --max-ops still stops a single
turn that runs away, and --max-total-ops stops a run that
does too much over many turns. A sandbox's code runs under the limits
its host gave it and is not counted. The count is checked every few
thousand steps, so a run may go a little past N before it stops.
Compiled code checks the total that often too, which makes a heavily
recursive program somewhat slower while the flag is set.
The kill-switch is uncatchable on purpose:
try/rescue and pcall don't see
it, because a handler would resume with the budget still exhausted and
trip again immediately — a script could then loop forever catching its
own limit. The call-depth limit is different and
is catchable, because unwinding to the handler frees
the call frames that breached it; see Running out of
stack.
--max-mem caps the extra live memory
the script may hold beyond what it starts with. Over the cap, the run
stops with memory limit exceeded instead of growing without
bound:
$ scua --max-mem=64K hog.scua
scua: hog.scua: memory limit exceeded
The cap is on the live set (it's checked after garbage is reclaimed), so ordinary churn that frees as it goes won't trip it — only genuinely holding more than the cap does.
The process can use up to about twice the live set.
The collector moves what is still live into fresh space and then frees
the old, so for the moment of a collection both copies exist, and
garbage waiting for the next collection takes room too. Measured: 36 MB
of live data with steady churn peaked at 78 MB of resident memory, and
72 MB at 150 MB. So --max-mem bounds what the script holds,
not the process's peak: if a container or a host gives the process a
hard limit, leave about twice the --max-mem value.
--mem-stats reports both (peak-live-after-gc
against peak-heap).
The cap is one total for the whole program, actors
included. Every actor is a partition with memory of its own,
and that memory counts against the same cap as the top-level code's,
from the moment the actor is created. Creating an actor that would take
the program over the cap stops the run there, with the same
memory limit exceeded, so a script cannot get round the cap
by spreading its data across many actors, or by creating actors until
the machine runs out:
$ scua --max-mem=1M spawn-many.scua
scua: spawn-many.scua: memory limit exceeded
Each actor starts out holding some memory before it has done
anything, so a program that creates actors needs a cap with room for
them. Check with --mem-stats, whose all-live
figure is the total across every partition. With --workers
above 1, two actors running at the same moment may each grow a little
before either sees the other's growth, so the total can briefly go
slightly past the cap before the run stops.