Reference
Single-page view · as markdown
provium global
Provium / Reference
The provium global is installed onto every test file's Lua state. It is the root Lab and the only API surface a test author needs to import — there is no require.
provium: -- create a VM in the root lab
provium: -- create a bridge in the root lab
provium: -- create a sub-lab
provium: -- restore a fixture into a VM
provium: -- restore a fixture into a sub-lab
provium: -- file-level resource reservation
provium: -- batch-boot every VM in the lab
provium: -- batch-shutdown every VM in the lab
provium: -- whole-lab snapshot
provium: -- whole-lab restore
provium: / provium: -- Lua 5.4 string.pack/unpack
See Lab reference for the canonical enumeration of these methods. This page documents only the things that are unique to the top-level provium global.
File-scope configuration globals #
Two top-level Lua globals on the file's provium table change harness behaviour for every test in the file:
provium.reset_between_tests #
provium. = true
test
test -- v was reset
When set to true at file scope, Provium takes a baseline snapshot of the root lab right after the file's top-level chunk finishes (and before the first test runs). After every test's body returns, the lab is restored to that baseline. Each test() runs against an identical starting state.
Mutually exclusive with file-scope open streams. If the top-level chunk opens a stream (vm:tail_file, vm:console():read(), bridge:capture(), etc.) and reset_between_tests = true, the file errors at chunk-load time with an explicit "would auto-snapshot a live stream" message naming the stream type and creation site.
provium.timeout #
provium. = "30s"
test -- per-test deadline = 30s
test -- per-test wins; 5m
File-default per-test timeout. Per-test meta.timeout wins. Accepts numeric seconds or a duration string with ms/s/m/h suffix. When a per-test timeout fires, the watchdog tears the entire root lab down (see time and timeouts for the scope-limitation note).
Dot-sugar lookup #
provium.<name> is shorthand for whichever named resource matches <name> in the root lab. The lookup order is:
| Reserved keys | Resolves to |
|---|---|
provium.pack, provium.unpack | Lua's standard string.pack / string.unpack. Reserved against shadowing. |
provium.vm_fixture, provium.lab_fixture | Functions equivalent to the :vm_fixture(...) / :lab_fixture(...) methods. |
After reserved keys, the lookup tries:
- A VM declared by
provium:vm("<name>", ...)— returns the VM userdata. - A bridge declared by
provium:bridge("<name>")— returns the Bridge userdata. - A sub-lab declared by
provium:lab("<name>")— returns the LabUd userdata. nilif nothing matches.
Examples:
local lan = provium:
local a = provium:
local b = provium:
-- Later in the test:
provium.:
provium.:
if provium.
The dot form is a strict lookup. It does not create new resources; calling provium.unknown returns nil. Use the method form (provium:vm("name", "profile")) to create.
Fixture sugar #
provium.vm_fixture and provium.lab_fixture are reserved keys that resolve to the same callable surface as the :vm_fixture(...) / :lab_fixture(...) methods. Both call shapes work:
local vm = provium: -- method form
local vm2 = provium. -- function form (same effect)
Use whichever reads better in context. The function form is convenient inside a wait_until predicate or a higher-order helper that captures the function rather than the lab.
Binary helpers (pack / unpack) #
provium:pack(fmt, ...) and provium:unpack(fmt, s) are pass-throughs to Lua 5.4's string.pack / string.unpack. They exist on the provium global so test code that uses binary protocols doesn't have to reach into string.*:
local frame = provium:
local id, seq, payload = provium:
The full Lua 5.4 format-string grammar applies. See the Lua reference manual on string.pack for the format specifiers.
See also #
- Lab reference — every method
proviumcarries through inheritance. - VM reference — what
provium:vm(...)returns. - test framework reference —
test(),t,wait_until,todo.
json
Provium / Reference
json is a top-level Lua global. Two methods, both pure host-side functions (no VM round-trip).
json.encode(value) #
Serialise a Lua value to a JSON string. Tables, numbers, booleans, strings, and nil round-trip naturally.
json. -- '{"a":1,"b":"x"}'
json. -- '[10,20,30]'
json. -- 'true'
json. -- 'null'
json. -- '{"a":1}' (b isn't in the table — Lua semantics)
json. -- '[1,null,3]' (array padded to max int key)
Array vs object detection: a table whose keys are positive integers (1..=N, possibly with gaps) encodes as a JSON array of length max(key); gaps emit null. Anything else encodes as an object. Mixed tables ({1, 2, name = "x"}) fall into the object case, with integer keys stringified.
Encoding errors (cycles, function/userdata values, non-string-coercible keys) raise a Lua error.
json.decode(string) #
Parse a JSON string into a Lua value.
local t = json.
print -- 42
local t = json.
print -- "d"
Number precision #
JSON integers in [i64::MIN, i64::MAX] decode to Lua integers exactly — full 64-bit precision, no rounding through f64. Integers above i64::MAX (i.e. u64-only values, up to 0xFFFFFFFFFFFFFFFF) preserve their bit pattern via a wrap-cast: 0xFFFFFFFFFFFFFFFF decodes to -1, exactly matching Lua 5.4's own tonumber("0xFFFFFFFFFFFFFFFF"). Bitwise ops still work correctly on the result (the bits are the bits).
Non-integer JSON (3.14, 1e10) decodes to Lua's float type as expected.
You can drop the "emit as hex string and tonumber() it" workaround that older code used — large unsigned values now round-trip through json.decode directly.
Null handling #
Lua tables can't hold nil as a value — assigning nil to a key removes it. We follow the standard Lua JSON-library convention (dkjson, lua-cjson):
| Input | Lua result | Notes |
|---|---|---|
null | nil | Top-level scalar. |
{"k": null} | A table with no k key. | t.k == nil, next(t) doesn't yield k. |
[1, null, 3] | {1, nil, 3} — array-with-hole. | t[2] == nil; #t is implementation-defined per Lua. |
This is lossy versus the original "key exists but is null": round-tripping decode → encode drops null fields. Tests that need to distinguish null from absent should keep the source string and re-check.
Decoding errors raise a Lua error with the parse position.
Typical use #
Reading config the guest emitted:
local body = vm:
local state = json.
t:
Building a request body for a test client:
local body = json.
vm::
Performance #
encode and decode are pure-host serde_json calls — microseconds for typical config-sized inputs. No VM round-trip, no agent involvement. Safe to call from inside tight test loops.
VM
Provium / Reference
A VM userdata wraps one guest. You get one from lab:vm("name", "profile") (or the dot-sugar lab.<name> after creation), and every operation against the guest dispatches through it.
VM handles are cheap to clone — passing them around or storing them in tables doesn't duplicate state.
Constructing #
| Source | Returns |
|---|---|
lab:vm("name", "profile") | New VM in lab, in the Created state. |
lab:vm("name", "profile", opts) | Same, with sizing opts (memory, cpus). See boot opts. |
lab:vm("name") | Lookup the VM previously declared with that name. Errors if absent. |
lab.name | Same lookup as lab:vm("name"), returns nil on miss. |
provium:vm_fixture("path") | Restored from a cached fixture; already booted. |
State machine #
Created ──:boot()──> Booted ──:pause()──> Paused
│ │
├─:reset()→Booted ├─:resume()→Booted
├─:power_button()──> Shutdown
└─:shutdown()─────> Shutdown
vm:state() returns "created", "booted", "paused", "shutdown", or "dead". Operations against a VM in the wrong state error cleanly with the offending state in the message.
Boot opts #
Boot opts split between the two call sites. The sizing keys go in the third arg to lab:vm(name, profile, opts) — the scheduler and QEMU need them when the VM is declared. The boot-shaping keys go to vm:boot(opts) and merge per field into the VM's pending boot options.
| Key | Where | Type | Description |
|---|---|---|---|
memory | lab:vm opts | int (bytes) or string "512M"/"2G" | VM memory cap. Hands to QEMU as -m <size>. |
cpus | lab:vm opts | int | vCPU count. Hands to QEMU as -smp <n>. |
kernel_cmdline | vm:boot opts | string | Replaces the profile's cmdline. |
rng_seed | vm:boot opts | int (u64) | Seeds the guest's virtio-rng. Use for determinism. |
initial_time | vm:boot opts | int or float (seconds since epoch) | Sets the guest's wall clock at boot. |
files | vm:boot opts | array of {path=string, content=string} | Files to inject into the guest's filesystem before init runs. |
Example:
local vm = provium:
vm:
Lifecycle methods #
vm:boot(opts?) #
Boots the guest. Returns self so calls chain (provium:vm("a", "peios"):boot()). The optional opts table (kernel_cmdline, rng_seed, initial_time, files) merges per field into the VM's pending boot options. Errors if already booted.
Emits a vm_spawned event after the guest is up and the agent has handshaken. The event payload carries {file, vm_name, profile, memory_bytes, cid}.
vm:pause() #
Pauses the guest's vCPUs. Returns nothing. Errors if not in Booted.
vm:resume() #
Resumes a paused guest. Returns nothing. Errors if not in Paused.
vm:shutdown() #
Tears down the guest. Idempotent in the sense that Created, Booted, Paused, and Dead all transition to Shutdown. Emits a vm_shutdown event with {file, vm_name, duration_ns}.
vm:reset() #
Warm reboot — guest re-runs its boot path. Errors if not in Booted. The post-reset state is still Booted.
vm:power_button() #
Sends ACPI power-button to the guest. Triggers a graceful shutdown sequence. Errors if not in Booted. Post-call state is Shutdown.
vm:close() #
Auto-close hook used by the resource walker. Idempotent. Calls :shutdown() if not already in Shutdown. Test code rarely calls this directly — the harness fires it for every VM at file end (or per-test if provium.reset_between_tests = true).
Snapshot and restore #
vm:snapshot(path?) #
Take a snapshot. With an explicit path, writes there; without, writes to a tempfile. Returns a Snapshot userdata wrapping the path.
local s = vm:
local s2 = vm:
vm:restore(snap_or_path) #
Restore from a Snapshot userdata (preferred) or a bare path string. Errors if not in Created or Shutdown.
vm:
vm:
Layer-1 ops #
Layer-1 ops are the high-level "do it like SSH" primitives. They dispatch through the agent's exec / file / stat handlers.
vm:run(cmd_or_args, opts?) #
Run a command. Two forms:
vm:run("echo hi")— runs through/bin/sh -c "echo hi". Shell metacharacters work.vm:run("echo", {"hi"})— direct exec. No shell. The second arg is a positional-arg array.vm:run("echo", {args={"hi"}, env={K="v"}, cwd="/tmp", stdin="…", env_clear=true, timeout="5s"})— opts form. Auto-detected by the presence of a recognised opts key (env,env_clear,cwd,stdin,timeout,timeout_ms,args); a table with none of them is treated as the legacy array. Mixed array entries withargs=are not allowed; use one form or the other.
Returns a RunResult userdata.
| Opts key | Type | Description |
|---|---|---|
args | array of strings | Direct positional args (only used with the table-as-opts form). |
env | string→string map | Environment variables. |
env_clear | bool | When true, the guest sees only env; otherwise env merges with the agent's environment. |
cwd | string | Working directory inside the guest. |
stdin | string | Bytes piped to the process's stdin. |
timeout_ms | int | Hard wall-clock timeout in milliseconds. |
timeout | int / float / string | Same, expressed as seconds or "5s" / "500ms" / "2m" / "1h". Negative or NaN errors. |
vm:run_async(cmd, opts?) #
Like :run, but returns a Process userdata immediately. The agent does not auto-kill; you control the lifetime via proc:kill() / proc:wait(). Passing timeout here is rejected — use proc:wait(timeout) instead.
vm:read_file(path) #
Returns the file contents as a Lua string. Errors on agent-side read failure (ENOENT, EACCES, etc.).
vm:write_file(path, data) #
Replaces the file's contents with data. Creates the file if absent. Returns nothing.
vm:push_file(host_path, guest_path, opts?) #
Read a file from the host filesystem and write its bytes to guest_path in the guest. Inside a fixture, the host file is folded into the fixture's cache key automatically — rebuilding the host file (typical case: a binary under test) invalidates the snapshot. Relative host_path is resolved against the directory of the file containing the call. Pass {auto_dep = false} to skip the auto-fold for a single call. See files and handles and fixtures and dependencies.
vm:stat(path) #
Returns a table:
| Field | Type | Description |
|---|---|---|
size | int (bytes) | File size. |
mtime | float (seconds since epoch) | Modification time. |
mtime_ns | int (ns since epoch) | Same, full precision. |
perm | int | POSIX mode bits (≤ 4095, i.e. 0o7777). |
entry_type | string | One of "file", "directory", "symlink", "fifo", "socket", "block_device", "char_device", "other". |
vm:listdir(path) #
Returns an array of {name=string, entry_type=string} tables.
vm:mkdir(path, opts?) #
Create a directory. Opts: {parents=bool, perm=int}. With parents=true, intermediate directories are created (mkdir -p). perm is the POSIX mode for the new directory.
vm:unlink(path) #
Remove a file or empty directory.
vm:rename(from, to) #
Atomic rename within the guest filesystem.
vm:open_file(path, mode_table) #
Returns a File userdata.
The mode table accepts:
| Key | Type | Effect |
|---|---|---|
read | bool | Open for reading. |
write | bool | Open for writing. |
create | bool | Create if absent. |
truncate | bool | Truncate to zero bytes on open. |
append | bool | Append-only writes. |
exclusive | bool | Combine with create=true to require the file not already exist (O_EXCL). |
perm | int | POSIX mode for newly-created files. |
At least one of read, write, or append must be true; an empty mode table errors at open time with a pointer.
vm:tail_file(path, opts?) #
Open a streaming subscription to a file. Returns a Tail userdata. Opts: {start = "beginning" | "end" | <offset>} ("start" is accepted as an alias for "beginning"). Default is "end" — only bytes appended after the call are streamed. <offset> may be a non-negative integer (absolute byte offset), a negative integer (N bytes before EOF — provium stats and resolves), or a finite float (truncated toward zero, same sign semantics).
vm:fd_stream(fd_or_file) #
Open a streaming subscription to an existing file handle. Accepts either an integer handle id (from file:fd()) or a File userdata directly. Returns a Tail.
Layer-0 ops #
Layer-0 ops are direct kernel-level primitives — syscall(2) and ioctl(2). Use these to exercise driver paths or to test syscall semantics directly.
vm:syscall(nr, ...) #
Two call shapes:
- Integer-only:
vm:syscall(nr, a1, a2, a3, a4, a5, a6)— up to 6 integer arguments. - Table form:
vm:syscall(nr, {args={a1, a2, …}, bufs={"…", …}, ptrs={1, 3}, nested={…}}). Buffers are byte arrays;ptrs[i]says which arg slot (0-indexed)bufs[i]should be spliced into. The buffer's address is filled in for the kernel.
Nested pointers. nested is a list of {parent=N, child=M, offset=K} entries (parent/child are 1-based indices into bufs). Before the arg-slot ptrs are applied, the agent writes bufs[M]'s address into bufs[N] at byte offset (an 8-byte native-endian pointer). This is how you pass a struct argument that itself contains a pointer — a buffer pointing at another buffer. Use it for ioctls invoked via raw syscall(SYS_ioctl, fd, cmd, &struct) whose struct has an inner output-buffer pointer (a query/info ioctl), or for syscalls whose argument struct embeds pointers (kacs_access_check-style). Each pointed-at buffer is returned post-call in out_bufs at its own index.
-- ioctl(fd, QUERY, &args) where struct kacs_query_args { u32 class; u32 buf_len; u64 buf_ptr; }
-- buf_ptr (offset 8) must point at the output buffer.
local args = string. -- buf_ptr filled in by nested
local out = string.
local r = vm:
local data = r. -- the QUERY output buffer
Returns a table:
| Field | Type | Description |
|---|---|---|
ret | int | Syscall return value. |
result | int | Same as ret (alias for clarity). |
errno | int | Errno if the syscall returned negative; 0 otherwise. |
out_bufs | array of strings | Post-syscall contents of the supplied bufs (including nested-pointer target buffers). Useful for read-style syscalls. |
The same bufs / ptrs / nested options are available on worker:syscall.
vm:ioctl(fd, cmd, data?, opts?) #
Direct ioctl(2). fd and cmd are integers. data is an optional byte string passed as the third arg's pointer. opts.bufs and opts.ptr_offsets work like :syscall's bufs / ptrs.
Returns {ret, result, out_data, out_bufs}. out_data is the post-call value of data (for ioctls that write back through the same buffer).
Hypervisor resources #
vm:nic(name) #
Returns a Nic userdata for the named bridge attachment, or for a guest-style interface name (eth0, enp0s3, ens3). Guest-name lookup maps to the Nth attached bridge sorted by bridge name.
vm:disk(id) #
Returns a Disk userdata for an already-attached disk. Errors if no disk with that id.
vm:attach_disk(opts?) #
Attaches a new disk and returns a Disk userdata. Opts: {id=string, size=int, image=string}. Defaults: id = attached-<vm_name>, size = 4 GiB, image = no backing file.
Console and clock #
vm:console() #
Returns a Console userdata.
vm:clock() #
Returns a Clock userdata.
Workers #
vm:spawn_worker(opts?) #
Spawns a sub-agent connection so test code can dispatch concurrent ops against the same guest. Returns a Worker userdata. Opts are reserved for future use; passing {thread=true} errors with a future-work pointer.
Batch operations #
vm:batch(fn) #
Collect multiple ops inside fn(b) and dispatch them as one wire round-trip. b exposes:
b:run(...)(with the same shape asvm:run)b:read_file(path)b:write_file(path, data)b:stat(path)b:listdir(path)b:mkdir(path, opts)b:unlink(path)b:rename(from, to)b:syscall(nr, a, b, …)— integer-args form only; the table form is rejected here.
Returns a Lua array of {ok=value} or {err=msg} entries, one per op. A failure on op N does not short-circuit the rest of the batch.
local results = vm:
-- results = {{ok=nil}, {ok=nil}, {ok="1"}, {err="No such file or directory"}}
Accessors #
vm:name() — Returns the VM's name as given to lab:vm. #
vm:profile() — Returns the profile name. #
vm:state() — Returns "created", "booted", "paused", "shutdown", or "dead". #
vm:cid() — Returns the assigned vsock CID, or nil before boot. #
vm:is_quiescent() — true if no in-flight ops, no open files or streams. #
vm:open_file_count() — Number of files currently open through the agent. #
vm:open_stream_count() — Number of streams currently active (tails, fd-streams, console-reads, captures). #
RunResult #
The return value of vm:run(...) (and worker:run(...), proc:wait()).
Fields:
| Field | Type | Description |
|---|---|---|
exit_code | int | Exit code. -1 for signalled, -2 for timed_out. Use signal and timed_out to disambiguate. |
stdout | string | Captured stdout. |
stderr | string | Captured stderr. |
status | string | "exited", "signalled", "timed_out". |
timed_out | bool | true if the timeout fired. |
signal | int or nil | Signal number when status is signalled, else nil. |
Methods:
result:ok()— convenience forresult.exit_code == 0.result:assert_ok()— raises if not OK; the error message includes status, stdout, and stderr (with signal name when signalled).
See also #
Bridge
Provium / Reference
A Bridge represents one Linux bridge with TAP-interface attachments. It is created via lab:bridge("name") and lazily realised — the host-side bridge and per-VM TAPs come up the first time any attached VM boots.
Constructing #
| Source | Returns |
|---|---|
lab:bridge("name") | New bridge. |
lab:bridge("name", opts) | Same. Opts are reserved for future use. |
lab:bridge("name") (already created) | The same bridge. |
lab.<name> | Same lookup as lab:bridge("name") for declared bridges. Returns nil otherwise. |
A bridge is a graph object until a VM attached to it boots; only at that point does the host-side bridge interface exist.
Membership #
bridge:attach(vm) / bridge:attach({vms…}) #
Records that vm is on this bridge. When vm is booted the host installs a TAP, slaves it to the bridge, and adds a -netdev tap flag to the QEMU launch.
Accepts:
- A single VM userdata —
lan:attach(a). - A bare string —
lan:attach("a"). Graph-state only; no VM handle is wired, so future link-down viabridge:detach(vm)will not drive QMP. - An array —
lan:attach({a, b, c}). Validated atomically: a bad type at index N fails before any element is recorded.
bridge:detach(vm) #
Remove vm from the bridge. When vm is a VM userdata, also issues a QMP set_link(false) against the matching netdev so the guest sees a link-down event.
bridge:members() #
Returns an array of attached VM names.
Partitions #
Partitions are network-layer drops between specific pairs of VMs. Two forms:
bridge:partition(a, b) (symmetric) #
Drop both directions of A↔B traffic. Accepts VM userdata or bare strings. Graph-state only — drops install when each VM is booted.
bridge:partition({from=A, to=B}) (directional) #
Drop only A→B traffic. B→A continues to flow. Both endpoints must already be attached and booted; otherwise it errors with a "call bridge:attach first" pointer (the rule would otherwise install onto a non-existent TAP).
bridge:unpartition(a, b) / bridge:unpartition({from=A, to=B}) #
Undo the matching partition. The directional unpartition does NOT require the endpoints to still be attached — if you :detach() a VM and then :unpartition it, the call is a clean no-op rather than an error (detach already cleared the directional rule).
bridge:partition_all() #
Drop every pair of attached VMs. Equivalent to calling bridge:partition for every pair.
bridge:restore_all() #
Undo every partition on the bridge.
bridge:is_partitioned(a, b) #
Returns true if A↔B is currently partitioned (either symmetric or directional from A to B).
Impairments #
Impairments install tc qdiscs on the bridge or per-TAP. All three accept either a scalar (whole-bridge) or a directional table.
bridge:add_latency(ms_or_table) #
Whole-bridge form: bridge:add_latency(50) adds 50 ms of one-way delay to every packet through this bridge.
Directional form: bridge:add_latency({from=A, to=B, ms=50}) records 50 ms for the A→B pair. Realisation is per source: each VM's outbound (from, *) pairs collapse — worst case per axis across latency, drop, and bandwidth — into one qdisc chain on that VM's TAP, so the delay in practice applies to everything leaving A. Endpoint-attachment check applies (see Partitions).
bridge:drop_rate(pct_or_table) #
Whole-bridge: bridge:drop_rate(10) drops ~10 % of packets via netem.
Directional: bridge:drop_rate({from=A, to=B, p=10}). Same per-source realisation as directional latency.
bridge:bandwidth_limit(bps_or_table) #
Whole-bridge: bridge:bandwidth_limit(1_000_000) caps the bridge to 1 Mbit/s via tc tbf.
Directional: bridge:bandwidth_limit({from=A, to=B, bps=500_000}) installs an HTB-root + one rate-limited class on A's source TAP, with a netem child when latency/drop is also set for the same source. Bits per second, not bytes. The to is graph-recorded but the realisation shapes every packet leaving the source TAP — HTB at the bridge layer can't select by destination MAC. Multiple (from=A, to=*) pairs collapse to max(bps) on A's TAP so no pair is over-shaped.
Whole-bridge bandwidth does not combine with whole-bridge latency/drop on the same bridge: when netem is also configured, the latency/drop shaping is what's realised and the bandwidth cap is recorded in the graph but not enforced (combined netem+tbf needs class-based HTB — out of scope for v1). For combined shaping use the directional form on each source.
bridge:add_directional_latency({from=A, to=B, ms=N}) #
Explicit directional form of :add_latency. Same effect as the table form above.
bridge:directional_drop_rate({from=A, to=B, p=N}) #
Explicit directional form of :drop_rate.
bridge:reset() #
Tear down every installed netem / tbf qdisc and clear every partition. Isolation, uplink, and L3 routes are preserved — they're topology, not impairments.
Introspection #
bridge:latency_ms()— Current whole-bridge latency.bridge:drop_rate_pct()— Current whole-bridge drop rate.bridge:bandwidth_bps()— Current whole-bridge bandwidth cap.
Isolation #
Isolation puts a single VM behind a hairpin filter so it cannot reach any other VM on the bridge (but the bridge itself remains alive).
bridge:isolate(vm) / bridge:unisolate(vm) #
Toggle isolation for one VM. Accepts a VM userdata or a bare string.
bridge:is_isolated(vm) #
Returns true if vm is currently isolated on this bridge.
L3 routing (preview) #
bridge:route(other_bridge_or_list) #
Records that traffic should be routed to other_bridge. Graph-state only in v1. No nft forward rules are installed — the call records the intent but cross-bridge IP traffic does not actually flow yet. The first call to :route() per bridge prints a one-shot warning to stderr so test authors notice at the call site rather than chasing dropped packets.
bridge:routes() #
Returns the array of bridge names this bridge is currently routed to.
Uplink #
Uplink installs an nft NAT masquerade rule between the bridge and the host's default-route interface, so VMs on the bridge can reach the outside world.
bridge:enable_uplink() / bridge:disable_uplink() #
Enable / disable NAT for this bridge. Both can fail if the host doesn't have a default-route interface or if nft commands fail; the error includes the underlying detail.
NICs and capture #
bridge:nic(vm) #
Returns a Nic bound to the (bridge, vm) pair. When vm is a VM userdata, the Nic carries the VM handle through so nic:disconnect() / nic:reconnect() can drive QMP set_link. With a bare string, the Nic is graph-state only.
bridge:capture() #
Spawn tcpdump -i <bridge> -U -w - and return a Capture stream. Reads pcap bytes off the tcpdump child's stdout. Requires tcpdump on PATH plus CAP_NET_RAW (in addition to CAP_NET_ADMIN).
The capture holds a guard that pins the bridge's active_captures counter, so vm:snapshot() can detect "stream live, snapshot would race" and refuse.
Auto-close #
bridge:close() #
Auto-close hook used by the resource walker. Tears down host-side networking — TAPs, qdiscs, nft tables, the bridge itself. Idempotent.
Accessors #
bridge:name()— Returns the bridge's name.
See also #
Lab
Provium / Reference
A Lab is the unit of resource ownership in Provium. The provium global is the root Lab; you can carve out sub-labs with provium:lab("name"). Both shapes expose the same surface, so this page is the canonical reference.
Membership #
lab:vm(name, profile?, opts?) #
Three call shapes:
lab:vm("name")— lookup. Errors if no VM with that name has been declared.lab:vm("name", "profile")— create.lab:vm("name", "profile", opts)— create with sizing opts (memory,cpus). See VM boot opts for the split between creation-time and boot-time keys.
Returns a VM userdata.
lab:bridge(name, opts?) #
Two call shapes:
lab:bridge("name")— lookup or create. If the bridge already exists in the lab, returns it; otherwise creates it.lab:bridge("name", opts)— same. Opts are reserved.
Returns a Bridge userdata.
lab:lab(name?) #
Create a sub-lab. Without a name, an anonymous name is generated (__lab_0, __lab_1, …). Returns a Lab userdata. Sub-labs share the parent's config, VMM, event sink, and pool, but have their own membership graph.
local dc1 = provium:
dc1:
dc1:
dc1::
lab:depends_on_file(host_path) #
Declare an external host-file as a fixture-cache dependency. The call itself is a no-op at runtime — its purpose is to be picked up by the cache-key scanner so that editing host_path invalidates the fixture next run.
provium:
Relative paths are resolved against the directory of the file containing the call. The path must be a string literal at the call site; non-literal paths (variables, concatenation) are not detected. Folds path + mtime + size into the cache key. See Fixtures and dependencies — External host-file deps.
Files pushed into the guest via vm:push_file already track themselves — depends_on_file is for files the fixture reads on the host side.
lab:include(resource_or_list) #
Add an existing resource (VM, Bridge, or sub-Lab) to this lab. Accepts:
- A single userdata —
lab:include(other_lab.shared_vm). - An array —
lab:include({a, b, c}).
Names must not already be taken in lab (errors with DuplicateVmName / DuplicateBridgeName). Reserved names (pack, unpack, vm_fixture, lab_fixture) are also rejected. When called from a test scope, names that already exist in a parent scope (the file root) also error to avoid silent shadowing — see labs and scope.
lab:remove(name_or_userdata) #
Remove a resource. Accepts a bare name (tries vm → bridge → sub-lab in order) or a userdata. Removal is graph-state only — the underlying VM, bridge, or sub-lab is NOT shut down or unrealised.
lab:members() #
Returns an array of {kind=string, name=string} entries listing every direct child of the lab. kind is "vm", "bridge", or "sub_lab".
lab:vm_names() / lab:bridge_names() / lab:sub_lab_names() #
Arrays of names of each kind directly in this lab (does not recurse into sub-labs).
lab.<name> (dot sugar) #
Looks up a named resource. Lookup order:
- Reserved keys:
pack,unpack,vm_fixture,lab_fixture. - VM with that name (walks parent scope chain on miss).
- Bridge with that name (walks parent scope chain on miss).
- Sub-lab with that name (walks parent scope chain on miss).
- Returns
nil.
The dot form does not create. Use lab:vm("name", "profile") for that. The parent-scope walk applies when this lab is a per-test scope (the runner sets provium to a test-scope LabUd whose parent chain includes the file root). Federation sub-labs created via lab:lab(name) have an empty parent chain and don't fall through to siblings.
Batch lifecycle #
Each method here applies to every direct VM child of the lab. They do not recurse into sub-labs.
lab:boot() #
Boot every VM in the lab. Returns self so you can chain.
lab:shutdown() #
Shutdown every VM.
lab:pause() / lab:resume() #
Pause / resume every VM. Errors if any VM is in the wrong state for the transition.
Resource claims #
Each test file may make at most one claim against the dispatcher's resource pool. The claim sits across the entire file's lifetime; it is released at file end (or scope-walker end).
lab:claim({memory=…, cpus=…}) #
Reserve resources from the pool. Either field may be omitted (treated as zero). Both are accepted as integers; memory may also be a string with K/M/G suffix.
| Field | Type | Effect |
|---|---|---|
memory | int (bytes) or string "512M" / "2G" | Memory budget. |
cpus | int | vCPU budget. |
provium:
test
test
A second :claim call on the same file errors — lab claim already held; one-shot per lab. When no pool is wired (REPL / single-file ad-hoc runs) the call still records the one-shot constraint but is otherwise a no-op.
The claim emits a claim_acquired event when it goes through; a matching claim_released fires at file end.
Barriers #
lab:barrier(name, count, timeout?) #
Block until count callers have hit the same name-keyed barrier. Returns true when the barrier releases and false on timeout — it does not raise. Default timeout is 60 seconds. The timeout argument accepts a number (seconds) or nil. The count is locked in by the first arrival; a later call with a mismatching count for the same name returns false with a host-side warning. Barriers are reusable across rounds under the same name.
-- count = 1: satisfied immediately.
assert
-- Unmet count: times out and returns false.
assert
Meeting a count > 1 requires a second concurrent host-side caller. Test files run single-threaded, and workers run guest processes (which can't call barrier), so a count > 1 barrier currently times out in an ordinary test file; the multi-caller form is aimed at thread-mode workers, which aren't supported yet. See Labs and scope — Barriers.
Snapshot and restore #
lab:snapshot(dir?) #
Take a whole-lab snapshot. With an explicit dir arg, writes there and returns the dir path as a string. Without args, writes to a fresh tempdir and returns a LabSnapshot userdata.
lab:restore(dir_or_snapshot) #
Restore from a directory path or a LabSnapshot userdata. Reads lab.json for the per-VM index plus each VM's snapshot file under the same dir.
Fixtures #
lab:vm_fixture(path) #
Build (or restore from cache) a single-VM fixture. path is the test-root-relative path of a *.fixture.lua file with the .fixture.lua suffix omitted.
local vm = provium:
local vm = provium:
The cached snapshot is hashed by source bytes + every transitive vm_fixture/lab_fixture reference + every require()d helper + every profile's kernel and initrd identifier. Editing any of those invalidates the cache for that fixture (and every fixture that transitively depends on it).
Returns a VM userdata in the Booted state.
lab:lab_fixture(path) #
Build (or restore from cache) a multi-VM lab fixture. The fixture's chunk must end with return provium:snapshot(). Cached as a directory <key>.lab/ containing the per-VM snapshots and lab.json.
Returns a Lab userdata wrapping a fresh sub-lab into which the fixture has been restored.
Binary helpers #
lab:pack(fmt, ...) / lab:unpack(fmt, s) #
Pass-throughs to Lua 5.4's string.pack / string.unpack. Available so test code that processes binary protocols doesn't need to reach into string.*. Same call shape and semantics as the standard library.
Accessors #
lab:name()— Returns the lab's name. The root lab is named"provium".
See also #
- provium global — file-scope configuration globals on top of the root Lab.
- VM, Bridge — what lives in a lab.
- Snapshot — what
lab:snapshot()returns. - Fixtures and dependencies — for the cache-key model and rebuild triggers.
Disk
Provium / Reference
A Disk wraps one virtio-blk disk attached to a VM. It exposes block-level operations and a fault-injection surface useful for exercising I/O error paths in the guest.
Constructing #
| Source | Returns |
|---|---|
vm:attach_disk({id="vda", size=…, image="…"}) | New disk attached to vm. |
vm:disk("id") | Lookup of an already-attached disk. Errors if absent. |
vm:attach_disk opts:
| Field | Type | Default | Description |
|---|---|---|---|
id | string | "attached-<vm_name>" | Disk identifier within the VM. |
size | int (bytes) | 4 GiB | Modelled disk size. Used for :size() when no image is attached. |
image | string (path) | none | Backing file. Sector ops require an image. |
Disks are 512-byte sectors throughout. The read_sectors and write_sectors ops express offsets and counts in sectors.
Methods #
disk:size() #
Returns the disk's size in bytes. When a backing image is attached, returns the live stat() size of the image file (so a test that resized the underlying file with truncate / fallocate reads honest output). Without an image, returns the modelled size from attach_disk.
disk:read_sectors(offset, n) #
Read n sectors starting at sector offset. Returns the bytes as a Lua string. Errors if:
- The disk is detached (
disk:read_sectors: disk is detached). - No backing image (
disk:read_sectors: no backing image — disk:with_image required). - An
eio_readfault is active (disk:read_sectors: EIO (fault_inject)). - The host-side
readfails (passes the underlying error through).
Honours active faults:
eio_read— short-circuits to EIO before any I/O.slow— sleeps 50 ms per call. After the sleep, re-checks foreio_readso a concurrent fault injection during the sleep still takes effect. After the actual I/O, checks one more time for the same reason.
disk:write_sectors(offset, data) #
Write data (a Lua string) starting at sector offset. The data does not have to be a multiple of 512 bytes; it is written verbatim from the offset. Returns nothing.
Errors and fault handling mirror read_sectors:
- Detached → error.
- No backing image → error.
eio_writeactive before the I/O → EIO.slow→ 50 ms sleep, witheio_writere-checks after the sleep and after the I/O.
disk:fault_inject(mode) #
Activate a fault. Valid modes: "eio_read", "eio_write", "slow". Unknown modes error with the valid list (disk:fault_inject: unknown mode \X` (valid: eio_read, eio_write, slow)`).
Multiple modes can be active simultaneously. When slow and the matching eio_* are both active at call time, the EIO check runs first — the call errors immediately, without the 50 ms delay. (The post-sleep re-checks only matter for an EIO fault set after the call started.)
disk:clear_faults() #
Clear every active fault. Subsequent reads / writes succeed normally.
disk:active_faults() #
Returns a Lua array of the currently-active fault mode names. Useful for tests that need to assert the harness state.
disk:detach() #
Mark the disk as detached and issue a best-effort QMP device_del against the parent VM. Subsequent read_sectors / write_sectors error. The QMP call is best-effort: a disk that was never QMP-added (host-bookkeeping-only attach) silently surfaces "Device not found" but the local detached flag is still set.
disk:is_detached() / disk:id() #
Accessors. :id() returns the disk identifier as given to attach_disk.
Example: full fault-injection pattern #
test
See also #
- VM — disks come from
vm:attach_disk/vm:disk. - Disks and fault injection — patterns for exercising I/O error paths.
Nic
Provium / Reference
A Nic represents one virtual NIC: the binding between a VM and a Bridge. You get one from either side of that pair (vm:nic(bridge_name) or bridge:nic(vm)), and it gives you per-NIC observability and link control.
Constructing #
| Source | Returns |
|---|---|
vm:nic("bridge_name") | NIC for the named bridge attachment. |
vm:nic("eth0") | NIC for the Nth attached bridge sorted by bridge name (eth<N>, enp0s<N>, ens<N>). |
bridge:nic(vm) | NIC for the (bridge, vm) pair. When vm is a userdata, the link-state ops drive QMP. |
The guest-name lookup (eth0) is deterministic per build but may not match the kernel's actual device-probe order on every distro. For portable tests, prefer vm:nic("bridge_name") and bridge:nic(vm).
Methods #
nic:counters() #
Returns a table of per-NIC counters read from /sys/class/net/<tap>/statistics/. The counters are presented from the guest's perspective: the host's tx_bytes on the TAP is what the guest received, so it appears as rx_bytes in the table.
| Field | Description |
|---|---|
rx_bytes | Bytes the guest received (host TAP's tx_bytes). |
tx_bytes | Bytes the guest sent (host TAP's rx_bytes). |
rx_packets | Packets received. |
tx_packets | Packets sent. |
errors | Sum of host TAP's rx_errors and tx_errors. |
Returns zeros when the host TAP isn't yet realised (VM not booted). If you need to assert a NIC has produced traffic, gate the read on vm:state() == "booted" or wait_until on a non-zero counter.
nic:capture() #
Spawn tcpdump -i <tap> on this NIC's host TAP interface. Returns a Capture stream. Errors if the VM hasn't been booted yet (the per-VM TAP doesn't exist):
nic:capture: vm `a` has no TAP on bridge `lan` (not booted?). Call lab:boot() / vm:boot() first.
The capture pins the bridge's active_captures counter, so vm:snapshot() can detect a live capture and refuse the snapshot (no half-captured pcap).
Compare with bridge:capture() which captures the whole bridge, not a single NIC.
nic:disconnect() #
Detach the NIC from the bridge and issue a QMP set_link(false) against the matching netdev so the guest sees a link-down event. Without a VM userdata at construction time (bare-string bridge:nic("name") form), the QMP step is skipped — the call is graph-state only.
nic:reconnect() #
Reverse of :disconnect(). Re-attaches the NIC and issues a set_link(true).
nic:vm_name() / nic:bridge() #
Accessors. :vm_name() returns the VM's name; :bridge() returns the bridge's name.
Example: link-down recovery #
test
See also #
- Bridge — bridge-wide controls (partition, impairments, capture).
- Streams — what
nic:capture()returns. - Bridges and impairments — patterns for using NIC and bridge handles together.
File
Provium / Reference
A File is an open guest-side file handle, returned by vm:open_file(path, mode) or worker:open_file(path, mode). Once closed, all ops error with file is closed. Idempotent: closing twice is safe.
Constructing #
| Source | Returns |
|---|---|
vm:open_file(path, mode) | New file handle in the guest. |
worker:open_file(path, mode) | Same but allocated under a worker's namespace. |
Mode table fields: read, write, create, truncate, append, exclusive, perm. See VM open_file for the full mode-table reference.
At least one of read, write, or append must be true.
Methods #
file:read(n) #
Read up to n bytes from the current cursor. Returns the bytes as a Lua string. Returns the empty string at EOF (POSIX read semantics — never errors on EOF).
local f = vm:
local s = f:
file:read_all() #
Drain the file from the current cursor to EOF. Returns the bytes as a Lua string. Internally chunked at 64 KiB. At EOF, returns the empty string.
file:write(data) #
Write data (a Lua string of bytes) at the current cursor. Returns the number of bytes actually written (which may be less than #data on a partial write — POSIX semantics).
file:seek(offset, whence?) #
Reposition the cursor. whence defaults to "set"; valid values: "set", "cur", "end". Anything else errors with seek: whence must be set/cur/end, got \X``.
Returns the new absolute offset.
file:tell() #
Returns the current cursor position. This authoritatively re-reads from the agent (a no-op Seek(Cur, 0)) rather than trusting the host-side cursor cache, so tell() stays honest when a previous read or write returned mid-flight.
file:close() #
Close the file. Idempotent: a second :close() does not error. After close, every other op errors with file is closed.
file:fd() #
Returns the raw u64 handle id. Useful for vm:ioctl(fd, …), vm:syscall(…) (where the fd is one of the integer args), or vm:fd_stream(fd) to open a streaming subscription against the same file.
After close, returns 0.
file:tail_stream() #
Open a Tail stream rooted at the file's current cursor. Streams new bytes appended after the current position. Useful for "give me a stream that starts here" without re-opening the file.
Errors with file:tail_stream needs the source path; opened via wrap() not wrap_with_path() if the File was constructed without a recorded path (which never happens through vm:open_file — it always records the path).
Example #
test
EOF semantics #
Reading past EOF returns the empty string "", not nil and not an error. Tests that loop "read until empty" should use:
while true
See also #
- VM —
vm:open_file,vm:read_file,vm:write_file,vm:fd_stream. - Worker —
worker:open_filefor files allocated under a worker's namespace. - Streams — what
file:tail_stream()returns. - Files and handles — patterns for guest-side file I/O.
Process
Provium / Reference
A Process is what vm:run_async(...) (or worker:run_async(...)) returns: a handle to a guest process that the agent has launched but is not waiting on. You control the lifetime — the agent does not auto-kill it.
Constructing #
| Source | Returns |
|---|---|
vm:run_async(cmd, opts?) | New process under the VM's main agent. |
worker:run_async(cmd, opts?) | New process under a worker namespace. |
Opts mirror vm:run's shape (args, env, env_clear, cwd), but the timeout / timeout_ms keys are rejected with a pointer to use proc:wait(timeout) instead. stdin works (initial bytes go through RunAsyncArgs); subsequent input goes through proc:stdin_write.
Methods #
proc:wait(timeout?) #
Wait for the process to exit. Returns a RunResult userdata.
timeout accepts:
nil— wait forever.- A number — seconds.
- A string —
"5s"/"500ms"/"5m"/"2h".
Passing 0 is rejected with a pointer to use proc:status() for non-blocking polling — a literal-zero timeout would otherwise SIGKILL the process immediately because the agent's wait_with_timeout(0) sees the deadline already past.
After :wait() returns, the agent-side slot is gone. The Process is "consumed"; subsequent ops still work, but :close() short-circuits without re-killing.
proc:kill(sig?) #
Send a signal. sig accepts:
nil— defaults toSIGTERM.- An integer — that signal number.
- A string — friendly name. Recognised:
term/sigterm/15,kill/sigkill/9,int/sigint/2,hup/sighup/1,quit/sigquit/3,stop/sigstop,cont/sigcont,usr1/sigusr1/10,usr2/sigusr2/12,alrm/sigalrm/14,pipe/sigpipe/13,chld/sigchld/17,winch/sigwinch. Comparison is case-insensitive.
Unknown name → unknown signal name \X``.
proc:signal(sig) #
Same as :kill(sig). Read better when the signal is non-fatal (proc:signal("usr1")).
proc:pid() #
Returns the kernel PID of the process inside the guest. Fetched live via the agent's GetPid op.
proc:handle() #
Returns the opaque agent-side handle id (an integer). Distinct from pid() — the handle stays stable across forks; the PID can change if the process re-execs.
proc:status() #
Non-blocking poll. Returns the agent's view of the process's status without waiting. Useful for "is it still running" without blocking.
proc:stdin_write(data) #
Write data (Lua string) to the process's stdin. Returns whatever the agent reports (typically the byte count).
proc:close_stdin() #
Close the stdin pipe. After this, subsequent :stdin_write errors.
proc:stdout_stream() / proc:stderr_stream() #
Open a Tail stream subscribed to captured stdout / stderr. The agent polls the captured-output buffer and emits frames. Useful when the process produces a lot of output you want to consume incrementally.
The returned Tail's StreamMeta is pre-filled with kind="proc_stdout_stream" (or "proc_stderr_stream") and the process handle id, so snapshot diagnostics show meaningful detail.
proc:close() #
Auto-close hook. If :wait() already happened, this is a no-op. Otherwise, sends SIGTERM, then waits up to 2 seconds for exit (the agent escalates to SIGKILL on its own timeout).
Example: tail logs while the process runs #
test
See also #
- VM —
vm:run_asyncreturns a Process. - Worker —
worker:run_asyncreturns a Process. - Streams — what
proc:stdout_stream/proc:stderr_streamreturn. - Running commands — patterns for sync vs async, stdin, env.
Worker
Provium / Reference
A Worker is what vm:spawn_worker() returns: a sub-agent connection to the same VM. It exposes the same VM-style API for running commands, opening files, and issuing syscalls — handles allocated under it live in the worker's own namespace on the agent side.
Workers are not a hard isolation boundary. They're bookkeeping namespaces — handles are routable from outside the worker, but the agent tracks per-worker membership for cleanup. Enforced per-worker isolation is not currently supported; treat workers as parallelism, not security.
Constructing #
| Source | Returns |
|---|---|
vm:spawn_worker() | New worker on the VM's main agent. |
vm:spawn_worker({thread = false}) | Same. ({thread = true} is rejected with a future-work pointer.) |
Methods #
The worker mirrors the VM's run / file / syscall surface. Where semantics differ from the VM equivalent, it's noted explicitly.
worker:run(cmd, opts?) #
Synchronous exec. Same call shape as vm:run. Returns a RunResult.
worker:run_async(cmd, opts?) #
Async spawn. Returns a Process. Like vm:run_async, the timeout opt is rejected — pass it to proc:wait instead.
The returned Process is auto-registered with the test's resource registry, so the scope walker SIGTERMs and reaps it at scope end. Without this, a worker-spawned child would leak past the test boundary.
worker:open_file(path, mode_table) #
Open a guest-side file under the worker's namespace. Returns a File. Same mode-table shape as vm:open_file.
The returned File is auto-registered with the test scope so file:close() fires automatically at scope end.
worker:syscall(nr, …) #
Direct syscall. Same call shape as vm:syscall — both the integer-only and table forms work. Returns the same {ret, result, errno, out_bufs} shape.
worker:kill(sig?) #
Broadcast a signal to every async process spawned under this worker. Argument shape mirrors proc:kill: nil → SIGTERM, int → that signal, string → friendly name.
A bad argument type (e.g. a Lua table) errors with the type-name in the message rather than silently defaulting to SIGTERM. This catches test-code typos.
worker:join() #
Wait for every in-flight worker child to exit. Returns whatever the agent reports as the joined exit summary.
worker:handle() #
Returns the worker's opaque handle id.
worker:close() #
Auto-close hook. SIGTERMs every in-flight worker child, then joins. The scope walker fires this at scope end.
Concurrency pattern #
Workers shine for tests that need two threads of control inside the same guest:
test
For coordination between workers' guest processes, use a guest-side primitive (file, fifo, etc.). lab:barrier(name, count, timeout?) is a host-side rendezvous — a worker's guest processes can't call it. See Labs and scope — Barriers for what barriers can and can't synchronise today.
See also #
Streams
Provium / Reference
Provium has three concrete stream types. They expose a shared surface, so test code can :next / :read_until / :expect / :drain / :close / :eof / :creation_site against any of them without caring which kind it is.
| Type | Returned by | Backing |
|---|---|---|
| Tail | vm:tail_file, vm:fd_stream, file:tail_stream, proc:stdout_stream, proc:stderr_stream | Frame-based agent stream over vsock. |
| Capture | bridge:capture, nic:capture | tcpdump child stdout — pcap bytes, not framed. |
| ConsoleStream | console:read (returned by vm:console():read()) | Unix socket connected to QEMU's console chardev. |
All three buffer "leftover bytes" past the last expect/read_until match. A subsequent :next returns those leftover bytes as the next frame, so test code never silently loses data.
Common methods #
:next(timeout?) #
Returns the next chunk of bytes as a Lua string, or nil at EOF / timeout. The exact frame shape:
- Tail: one frame per agent-side write. Each frame is whatever bytes the guest had emitted since the previous frame.
- Capture: chunks of pcap bytes from tcpdump's pipe (variable size, up to 64 KiB).
- ConsoleStream: chunks of console bytes (variable size, up to 64 KiB).
The timeout argument accepts seconds (number) or a string with ms/s/m/h suffix. Default: 10 seconds.
:read_until(pattern, timeout?) #
Pull frames until pattern (a Lua string) is found in the accumulated buffer. Returns the prefix up to and including the matched bytes. Errors with a pattern-naming message on timeout (read_until timed out waiting for \X`) or stream EOF (stream EOF`).
Bytes past the matched suffix are kept as pending and replayed on the next call.
Default timeout: 10 seconds.
:expect(pattern, timeout?) #
Like :read_until but discards the matched prefix. Returns nothing. Useful as an assertion: "the stream produced this; I don't care about the bytes". Same error shape as read_until.
Default timeout: 10 seconds.
:drain(timeout?) #
Collect every frame currently available and return them as a Lua array. Stops when the next read would block past the timeout, when EOF is reached, or when no more data is available.
Frame-per-element semantics — each iteration of the inner read produces one Lua-string entry. Pending bytes from a prior expect/read_until come out as the first entry.
Default timeout: 0.5 seconds (much shorter than next/read_until/expect because drain's intent is "what's available right now, then return").
:close() #
Close the underlying transport. Subsequent :next returns nil; subsequent :read_until/:expect error with stream closed / console closed / capture closed. Idempotent.
Pending bytes are dropped on close so a :next after close can't drain stale data.
:eof() #
Returns true once the stream's transport has reported EOF AND no pending bytes remain. Pending bytes mean there's still data to deliver before EOF is honest, so eof() returns false until they've been consumed.
:creation_site() #
Returns {file=string, line=int} for the test-author frame that opened the stream, or nil if no *.test.lua/*.fixture.lua frame is on the stack at creation. Useful for snapshot diagnostics — when vm:snapshot() refuses because of a live stream, the error names the offending stream's creation site so you can fix it.
Tail-only behaviour #
vm:tail_file(path, opts?) accepts a start opt:
| Value | Effect |
|---|---|
"end" (default) | Stream only bytes appended after the call. |
"beginning" or "start" | Replay the whole file from byte 0, then continue tailing. |
| Non-negative integer | Start streaming from that exact byte offset. |
| Negative integer | Start N bytes before EOF — Provium stats the file and resolves to an absolute offset. If N exceeds the current file size, the stream starts at byte 0. |
| Finite float | Truncated toward zero, then treated as the integer cases above (negative floats count back from EOF). |
A closed Tail returns nil from :next — the closed stream is semantically EOF. This matches Capture and ConsoleStream, so while s:next() do … end loops terminate cleanly.
Tails carry per-frame metadata visible to scope-end diagnostics: kind (e.g. tail_file, fd_stream, file_tail_stream, proc_stdout_stream), detail (the path or fd), creation_site, and test_name.
Capture-only notes #
Capture spawns tcpdump -i <iface> -U -w - -s 65535. The output is pcap-formatted bytes — the standard pcap file format with global header followed by per-packet records. Feed it through pcap-parser, tshark -r -, or pyshark to interpret.
The Capture holds a guard against the bridge's active_captures counter so a vm:snapshot() while capture is live errors instead of silently producing a half-captured pcap.
Drop on close prevents :next from returning stale tcpdump bytes after the child has been killed.
ConsoleStream-only notes #
ConsoleStream connects via UnixStream to the VMM's console chardev path (vm:console_socket_path()). Reads come back as raw bytes from the chardev — typically the boot console + login prompt + anything the guest has written to /dev/ttyS0 since the last read.
The connect-time read timeout is 50 ms. The :next(timeout) arg overrides it for the duration of the call and the previous timeout is restored afterwards, so a next("5s") followed by a bare next() doesn't inherit the 5s deadline forever.
Mid-stream ConnectionReset and BrokenPipe errors are mapped to "console EOF" — they happen when the QEMU chardev closes mid-read on a VM reset or shutdown. Semantically EOF for a console stream, not a real I/O error.
Example patterns #
Wait for a log line #
local f = vm:
local stream = f:
stream:
Race a process against a timeout #
local proc = vm:
local out = proc:
out:
-- now hit it
Drain a capture and pass through pcap-parser #
local cap = bridge:
vm:
local frames = cap:
local pcap = table.
-- write pcap to a file or feed it to a parser
See also #
Console
Provium / Reference
A Console wraps the guest's serial console — the host writes to and reads from QEMU's chardev bound to the guest's serial port (/dev/ttyS0). Provium appends console=ttyS0 to the kernel command line if no serial console is present, so kernel output always lands here.
Constructing #
| Source | Returns |
|---|---|
vm:console() | The guest's console. Always succeeds; underlying chardev access is checked at the first :read / :read_log / :write call. |
Methods #
console:read() #
Returns a ConsoleStream backed by a UnixStream to the QEMU chardev socket. Use the stream's :next / :read_until / :expect / :drain to consume console output incrementally.
The stream is registered with the VM's resource graph, so an open console:read() blocks vm:snapshot() from succeeding silently — the snapshot precondition reports "live console stream" with creation site.
Errors with console:read: VMM does not expose a console socket if the VMM backend doesn't surface a chardev path (rare; the LocalAgent backend does not).
console:read_log() #
Returns the entire captured console log to date as a Lua string. Snapshot-style; not a stream. Useful for one-shot inspections:
local log = vm::
t:
console:expect(pattern, timeout?) #
Poll the captured log every 50 ms until pattern (a Lua string) appears or timeout lapses. Default timeout: 30 seconds. Returns the matched substring on hit; raises with the pattern in the message on timeout.
This is the simple form for "wait for the kernel to log X." For long-running streams or when you also want to read past-the-match bytes, use console:read():expect(...) instead.
console:write(data, opts?) #
Write data (Lua string) to the chardev. Opts:
| Key | Type | Effect |
|---|---|---|
timeout | number (seconds) or string "500ms" / "5s" | Bound the blocking write. 0 means no timeout. |
The legacy timeout_ms integer key is rejected with a clear error (opts.timeout_ms is not supported (use timeout = "500ms" or timeout = 0.5)).
Use this to feed input to interactive console programs, or to pre-populate a getty login.
console:close() #
No-op in v1. The chardev itself is owned by the VMM; closing the Console userdata is a Lua-level concept only.
Example: drive a getty login #
test
See also #
Clock
Provium / Reference
A Clock wraps the guest's wall clock. Operations dispatch to the agent, which adjusts the kernel's CLOCK_REALTIME (and equivalents) under the hood.
The clock is signed — you can move the guest backwards in time. This matters for testing time-dependent code: certificate expiry, timeout handling, leap-second handling, retry backoff.
Constructing #
| Source | Returns |
|---|---|
vm:clock() | The guest's clock. |
Methods #
clock:get() #
Returns the current time as seconds-since-epoch (float). For sub-microsecond accuracy use :get_ns().
local t = vm::
-- t ≈ 1700000000.0
clock:get_ns() #
Returns nanoseconds-since-epoch as a 64-bit signed integer. Lua 5.4 integers are 64-bit so no precision is lost.
clock:set(seconds) #
Set the wall clock to seconds seconds since epoch. Accepts integers or floats. Negative values are allowed — the guest goes pre-epoch, useful for testing time_t boundary handling.
Errors on NaN or infinity (clock:set: value must be a finite number (got NaN/inf)). Without this guard, the integer cast would silently saturate or round to zero.
clock:set_ns(ns) #
Set the wall clock to ns nanoseconds since epoch (i64). Same semantics as :set but at full precision.
clock:sleep(duration) #
Sleep for duration seconds. Accepts:
- Integer / float — seconds.
- String —
"100ms"/"5s"/"5m"/"2h".
Errors on NaN, infinity, or negative.
clock:advance(duration) #
Advance the wall clock by duration seconds (signed). Unlike :sleep, this does not block — the kernel's CLOCK_REALTIME jumps forward (or backward).
Negative values are explicitly allowed (clock:advance(-3600) rolls the guest back an hour). Errors on NaN or infinity but not on negative.
Example: certificate expiry test #
test
Notes #
clock:get()(float) loses precision below ~250 ns at modern epoch values because of f64 mantissa width. Use:get_ns()if you need exact ns roundtrip.- The clock is not a guest namespace — every process inside the guest sees the same time axis after
:set/:advance. - Boot-time initial clock is set via
boot_opts.initial_time(see VM boot opts). The Clock methods are for adjustment after boot.
See also #
- VM —
vm:clock()and theboot_opts.initial_timeboot-time setter.
Snapshot and LabSnapshot
Provium / Reference
vm:snapshot() returns a Snapshot wrapping a single file. lab:snapshot() returns a LabSnapshot wrapping a directory containing per-VM snapshot files plus a lab.json index.
Both are thin handles over filesystem paths. They have no agent-side state — once the snapshot file or directory is on disk, the userdata just gives you ergonomic access to it.
Snapshot #
Constructing #
| Source | Returns |
|---|---|
vm:snapshot() | New snapshot at a fresh tempfile. |
vm:snapshot("/explicit/path") | Snapshot at that exact path. |
A fixture's chunk returns a Snapshot (single-VM fixture); the harness reads the path, compresses + sparsifies the file, and installs it into the cache.
Methods #
snap:path() #
Returns the on-disk path as a Lua string.
snap:size() #
Returns the file size in bytes. Returns 0 rather than ENOENT if the file has been moved or deleted (idempotent with :delete()).
snap:delete() #
Best-effort delete. Idempotent — calling on a path that's already gone is fine. Returns nothing.
LabSnapshot #
Constructing #
| Source | Returns |
|---|---|
lab:snapshot() | New lab snapshot in a fresh tempdir. |
provium:snapshot() | Same, on the root lab. |
A lab fixture's chunk returns a LabSnapshot; the harness moves the directory into the cache as <key>.lab/.
Methods #
lab_snap:path() #
Returns the on-disk directory path as a Lua string.
lab_snap:size() #
Returns the sum of file sizes directly under the directory (one level deep, doesn't recurse). Useful for picking large fixtures out of the cache.
lab_snap:delete() #
Recursively deletes the directory. Idempotent — calling on a path that's already gone is fine.
Example: build a fixture #
-- tests/fixtures/base.fixture.lua
local vm = provium::
vm::
return vm:
-- tests/fixtures/cluster.fixture.lua
provium:
local a = provium::
local b = provium::
provium.:
a:
b:
return provium:
-- tests/uses-base.test.lua
test
test
See also #
- VM —
vm:snapshot(),vm:restore(). - Lab —
lab:snapshot(),lab:restore(),lab:vm_fixture(),lab:lab_fixture(). - Fixtures and dependencies — how snapshots become cached fixtures.
Test framework
Provium / Reference
Provium's test framework is implemented mostly in Lua and installed onto every test file's state. This page documents every callable: test, todo, wait_until, the t context, and the per-test metadata table.
test(name, [meta,] fn) #
Register a test in declaration order. Two call shapes:
test
test
| Argument | Required | Type | Description |
|---|---|---|---|
name | yes | string | Test name. Must be unique within the file (duplicate names raise at registration). |
meta | no | table | Per-test metadata. See meta tags. |
fn | yes | function | The test body, called with (t) — the test context. |
Tests run sequentially in declaration order. Each test gets a fresh t context. The harness wraps the call in pcall semantics — a Lua error() becomes a Failed outcome; clean return becomes Passed; explicit t:skip() becomes Skipped.
Duplicate names within a file error at registration time:
test: duplicate name `foo` in this file
todo(reason?) #
Declarative file-scope skip. When called at top level (before any test() body runs), every registered test is reported Skipped with the given reason. Test bodies are not executed.
todo
test
test
-- Both report Skipped with reason "waiting on the spec".
Without an argument, defaults to "todo".
Skipped tests still emit TestSkipped events with the meta intact, so coverage and dashboards see the same shape they would for an inline t:skip().
wait_until(predicate, opts?) #
Call predicate repeatedly until it returns truthy (and return that value), or until the timeout lapses. Useful for polling guest-side conditions that don't have a stream interface.
local pid = wait_until
Opts:
| Field | Default | Type | Description |
|---|---|---|---|
timeout | 10 | number (seconds) or string "30s" / "500ms" / "5m" / "2h" | Total time to wait before giving up. |
interval | 0.1 | number (seconds) | Time between predicate calls. |
desc | "condition" | string | Used in the timeout error message. |
On timeout, errors with wait_until: <desc> not met within <timeout>s. The error includes the offending input (e.g. bad timeout string ...) when the timeout argument is malformed.
The predicate runs under pcall — if it raises, the error propagates immediately (with the raise location) rather than being treated as a "not yet" signal.
wait_until uses wall-clock seconds, not CPU time, so the deadline elapses while _provium_sleep is pausing.
The t context #
A fresh t table is built per test. It carries the test's name and metadata, and the runner keeps a sticky per-test failure record so a pcall(t:assert(false)) is still classified as a failure (the inner pcall swallows the raise, but the failure was recorded before it).
Fields #
t.name— the test's name.t.meta— the per-test metadata table (or{}if none was given).
Assertions #
Every assertion error includes the offending values in the message — assertion failures are self-explanatory without needing to read context.
t:assert(cond, msg?) #
Raise if cond is falsy. msg is the failure message; defaults to "assertion failed".
t:assert_eq(a, b, msg?) #
Raise if a ~= b. The error message includes both values: <msg>: <a> ~= <b>.
t:assert_neq(a, b, msg?) #
Raise if a == b. Mirror of assert_eq.
t:assert_contains(haystack, needle, msg?) #
Raise unless string.find(haystack, needle, 1, true) returns non-nil. Both arguments must be strings; non-string args raise immediately with a "slice 2 limit" pointer.
t:assert_raises(fn, msg?) #
Run fn under pcall and assert that it raised. Returns the error value if the raise happened. Errors with <msg>: expected to raise if fn returned cleanly.
The sticky failure record is saved before invoking fn and restored on a successful raise — that way an assertion inside fn (used to trigger the expected raise) doesn't poison the rest of the test.
Skip and fail #
t:fail(msg?) #
Mark the test failed and raise. msg defaults to "explicit failure (t:fail)".
t:skip(reason) #
Mark the test skipped and raise via the internal sentinel. reason is recorded in the TestOutcome and emitted in the TestSkipped event.
test
Logging #
t:log(msg) #
Append msg (any value, converted via tostring) to the test's log array. The log is included in the FileOutcome.tests[i].log and emitted alongside TestPassed / TestFailed events.
Useful for diagnostic output that should accompany failures. Unlike print, t:log is structured and tied to the specific test.
Outcomes #
A test ends in one of three states:
| Status | Triggered by |
|---|---|
| Passed | Test fn returned without raising AND no assertion failure was recorded. |
| Failed | Test fn raised (via error(), t:assert*, t:fail), or a sticky assertion failure was recorded before a pcall caught the raise. A Rust panic during the test also marks Failed and poisons every subsequent test in the file. |
| Skipped | t:skip(reason) was called, OR meta.skip = true / meta.skip = "reason", OR --tag / --no-tag filtered the test out, OR --include-slow was off and meta.slow = true, OR file-scope todo() was called. |
A skipped test produces no event lifecycle other than TestSkipped. A failed test additionally captures the last 4 KiB of every booted VM's console log into the TestFailed.console_excerpt field — useful for diagnosing kernel-side regressions without reproducing the run.
Time and timeouts #
Per-test timeouts come from meta.timeout. File-default timeouts come from provium.timeout. Per-test wins.
The watchdog fires by tearing down the entire file's lab — there is no finer-grained cancellation in v1 because there is no cooperative cancel point in the AgentClient ops. Practical implication: for files using provium.reset_between_tests = true the lab is restored before the next test anyway, so the lab-wide tear-down is harmless. For files that share live state across tests, a per-test timeout invalidates the rest of the file. Don't put a meta.timeout = N on a flaky test in a state-sharing file expecting siblings to be unaffected.
See also #
- Meta tags — full reference for
meta.slow,meta.tags,meta.skip,meta.timeout,meta.spec. - provium global —
provium.reset_between_tests,provium.timeout. - The test function — patterns for organising a test file.
Meta tags
Provium / Reference
Each test(name, meta, fn) call may include a metadata table. Provium's runner inspects a handful of well-known keys, but every key is preserved in the MetaMap it ships in TestStarted / TestPassed / TestFailed / TestSkipped events. Consumers like provium-coverage use this for spec linkage, classification, and filtering.
Well-known keys #
meta.slow #
test
When --include-slow is not passed to the CLI, tests with truthy meta.slow (true or any non-zero integer) are skipped with reason filtered: slow tests skipped (pass --include-slow).
The default behaviour is "skip slow tests" so provium tests/ is fast by default. CI and explicit slow runs pass --include-slow.
meta.skip #
test
test
Declarative skip. Three accepted forms:
| Value | Skip reason |
|---|---|
true (or any non-zero int) | "skipped (meta.skip)" |
String "<reason>" | "skipped: <reason>" |
false / nil / 0 | Not skipped. |
meta.tags #
test
test -- single string also accepted
Tag filtering works through the CLI:
provium tests/ --tag net— run only tests taggednet. Repeatable:--tag a --tag bis "a OR b".provium tests/ --no-tag dns— run every test EXCEPT those taggeddns. Repeatable; OR'd. Wins over--tag(intersect).
A single-string tags = "smoke" is accepted alongside the canonical array form. Without the single-string convenience, a one-tag test was silently filtered out by --tag with the misleading "did not match --tag" message instead of seeing its lone tag.
meta.subsystems and other arbitrary fields #
meta.tags is the de-facto field for orthogonal cross-cutting flags (slow, flaky, perf, federation). For declaring what a test exercises — particularly when test files are organised by user-visible scenario rather than by code subsystem — use a separate field with array values:
test
Filter via --tag-meta KEY=VALUE:
provium tests/ --tag-meta subsystems=peinit— every test that touches peinit, regardless of where it lives in the directory tree.provium tests/ --tag-meta subsystems=peinit --tag-meta subsystems=loregd— touches peinit OR loregd (OR within key).provium tests/ --tag-meta subsystems=peinit --tag-meta area=boot— touches peinit AND is in the boot area (AND across keys).provium tests/ --no-tag-meta flaky=true— exclude anything tagged flaky=true.
--tag-meta accepts string-scalar ({flaky = "true"}) and array ({subsystems = {"a", "b"}}) meta values; nested-map and integer values don't match. The KEY can be any meta field name; the harness doesn't reserve subsystems or any other name.
The convention split:
tags— orthogonal flags. "How does this test behave?" (slow, flaky, perf).- arbitrary keys (
subsystems,area,feature) — "What does this test exercise?" Filter via--tag-meta.
Why two mechanisms? tags is for things you'd cumulatively-enable ("run me everything tagged slow OR perf"). Arbitrary keys are for orthogonal axes you'd intersect ("run everything that touches peinit AND is in the boot area"). --tag-meta's AND-across-keys is what makes intersection work.
meta.timeout #
test
test
Per-test wall-clock timeout. Numbers are seconds. Strings carry a suffix: "500ms" / "5s" / "5m" / "2h". Per-test wins over the file-default provium.timeout.
When the timeout fires, the watchdog tears down the entire file's lab. See test framework / Time and timeouts for the scope-limitation note.
meta.spec #
test
Provium's runner does not interpret meta.spec — it ships the value through to consumers. provium-coverage reads it to map tests to spec-document sections for coverage reports.
By convention: a stable identifier that names the spec section a test exercises. Useful for cross-referencing tests against normative documents.
Arbitrary keys #
Any other key in the meta table is passed through to consumers as-is. Provium's MetaValue enum covers:
null/nil- bool
- int (i64)
- float (f64)
- string
- bytes (raw
Vec<u8>) - array of meta values
- string→meta map
Nested tables come through as either Array (when the table has integer keys 1..N) or Map (when string keys are present). The decision is made per nesting level; a mixed table is unusual and not specifically handled.
Examples #
Multi-tag with skip-on-condition #
test
Conditional gate via wait_until #
test
See also #
- Test framework —
test(), thetcontext. - CLI —
--tag,--no-tag,--tag-meta,--no-tag-meta,--include-slow. - Events — how meta is emitted in test-lifecycle events.
CLI
Provium / Reference
Provium's CLI surface is the single binary provium. By default it discovers *.test.lua files under the given paths and runs them; subcommands cover REPL, fixture management, and listing.
Synopsis #
provium [PATHS]... [FLAGS]
provium prepare [PROFILE]
provium repl <PROFILE> [--name <NAME>] [--fixture <PATH>]
provium console <PROFILE> [--mem <BYTES>] [--cpus <N>] [--cmdline <TEXT>] [--agent] [--qemu <PATH>] [--print-command] [-- QEMU_ARG...]
provium fixture list
provium fixture build <PATH>
provium fixture rebuild <PATH>
provium fixture clean
provium fixture stale
provium list [--fixtures]
provium lsp-setup [DIR] [--force]
If no paths are given, the current directory is scanned recursively.
Top-level flags #
Discovery and selection #
| Flag | Type | Default | Description |
|---|---|---|---|
--config <PATH> | path | provium.toml | Config file location. |
--filter <STR> | string | none | Only run files whose test-root-relative path contains this substring. |
--include-slow | flag | off | Include meta.slow tests (default skips them). |
--tag <TAG> | string, repeatable | none | Run only tests with one of these tags. OR'd. |
--no-tag <TAG> | string, repeatable | none | Skip tests with any of these tags. Wins over --tag. |
--tag-meta <KEY=VALUE> | string, repeatable | none | Run only tests where meta[KEY] contains VALUE. Multi-flag same KEY = OR within key; different KEYs = AND across keys. Useful for filtering on arbitrary fields like subsystems. |
--no-tag-meta <KEY=VALUE> | string, repeatable | none | Skip tests where meta[KEY] contains VALUE. Same key/value semantics as --tag-meta. Wins over --tag-meta. |
--rerun-failed | flag | off | Run only files that failed in the last run. Reads from ~/.cache/provium/rerun.json. |
--since <PATH> | path | none | Run only files whose mtime is newer than this reference file. |
--watch | flag | off | Re-run on file change (poll every 500 ms). |
VMM and resources #
| Flag | Type | Default | Description |
|---|---|---|---|
--vmm <CHOICE> | qemu / local | qemu | VMM backend. local is for dev runs without KVM — does not actually boot a kernel. |
--mem <BYTES> | size string "4G" | 80 % of host RAM | Pool memory budget. |
--cpus <N> | int | host online CPUs | Pool vCPU budget. |
--cpu-overcommit <F> | float | 1.0 | Multiplier on --cpus. Clamped to [0.5, 8.0]. |
--no-preflight | flag | off | Skip the startup /dev/kvm / iproute2 / nft / qemu / CAP_NET_ADMIN checks. |
--no-ksm | flag | off | Skip Kernel Same-page Merging tuning at startup. |
--no-build | flag | off | Skip every dynamic profile's build command for this run. Use when you know the artifacts are already current. Does not affect provium prepare, whose whole purpose is to build. |
Lifecycle #
| Flag | Type | Default | Description |
|---|---|---|---|
--timeout <DUR> | seconds-int or duration string | 300 (5 min) | Per-file wall-clock timeout. 0 disables. Accepts "500ms", "30s", "10m", "2h". |
--fail-fast | flag | off | Stop after the first failed file. |
Output #
| Flag | Type | Default | Description |
|---|---|---|---|
-v, --verbose | flag | off | Show passing tests too. Mutually exclusive with --quiet. |
-q, --quiet | flag | off | Show only failures. Mutually exclusive with --verbose. |
--json | flag | off | Line-delimited JSON output, one object per file. Mutually exclusive with --events-stdout. |
Observability #
| Flag | Type | Description |
|---|---|---|
--save-events <PATH> | path | Persist the event stream to PATH as length-prefixed msgpack frames. Compatible with provium-coverage --from PATH. |
--events-stdout | flag | Emit msgpack event frames on stdout; the human/JSON renderer redirects to stderr. Mutually exclusive with --json. |
--events-socket <PATH> | path | Multiplex the event stream over a Unix socket. The binary listens, accepts connections, fans out frames. Reused across --watch iterations. |
--coverage | flag | Pipe the buffered event stream into provium-coverage (must be on PATH) after the run. |
Subcommands #
When a subcommand is given, the test-runner mode is suppressed.
provium prepare [PROFILE] #
Run dynamic profiles' build commands without booting anything. With a PROFILE argument, builds just that profile (and errors with provium prepare: profile \if it isn't declared); with no argument, builds every profile that declares abuild` command, in name order, stopping at the first failure.
Use it to pre-warm artifacts — provium prepare && provium --no-build builds once, then runs the suite without rebuilding — or to drive an image build from a machine that isn't a test host: prepare skips the pre-flight checks (/dev/kvm, iproute2, CAP_NET_ADMIN), so it runs anywhere sh and the builder do.
A profile with no build command is a silent no-op. A build that exits non-zero aborts with the profile name and exit status, and Provium never falls through to using whatever stale artifacts are on disk.
provium repl <PROFILE> [--name <NAME>] [--fixture <PATH>] #
Boot a VM and drop into an interactive Lua REPL against it.
| Arg / flag | Description |
|---|---|
<PROFILE> | Profile name from provium.toml. Optional when --fixture is given. |
--name <NAME> | VM name. Defaults to repl. |
--fixture <PATH> | Resume from a fixture instead of cold-booting. |
If --fixture is set without a profile arg, the first profile in provium.toml (sorted by name) is used.
provium console <PROFILE> [flags] [-- QEMU_ARG...] #
Boot a profile's VM and attach your terminal to its serial console — no agent, no Lua. Ctrl-A X quits QEMU; Ctrl-A C toggles the QEMU monitor.
| Flag | Description |
|---|---|
--mem <BYTES> | Override the per-VM memory default. |
--cpus <N> | Override the per-VM vCPU default. |
--cmdline <TEXT> | Override the profile's kernel command line. |
--agent | Inject the agent overlay (off by default for console sessions). |
--qemu <PATH> | Pick the QEMU binary. |
--print-command | Print the assembled QEMU command line and exit. |
Anything after -- is passed verbatim to QEMU.
provium fixture list #
Show every cached entry with size and key.
provium fixture build <PATH> #
Force a build for the named fixture (test-root-relative path, no .fixture.lua suffix). If the fixture is already cached, prints already built: <path> and exits.
provium fixture rebuild <PATH> #
Evict the existing cache entry and rebuild. Both single-VM (.snap) and lab (.lab/) layouts are evicted.
provium fixture clean #
Wipe the entire cache directory.
provium fixture stale #
List .fixture.lua files whose source hash (folded with kernel + initrd identifiers and any external host-file deps declared via vm:push_file / lab:depends_on_file) doesn't correspond to any cached entry. Useful for "what would provium rebuild on the next run?"
provium list [--fixtures] #
List discovered tests (default) or fixtures (--fixtures) without running anything. Useful for piping into xargs or for CI dry-runs.
provium lsp-setup [DIR] [--force] #
Drop Lua Language Server definitions into a test directory so test, provium, wait_until, json, and the rest of the harness globals stop showing up as undefined in your editor.
Writes:
<DIR>/.provium-meta/types.lua—---@metastubs covering the test framework, theTestContext(t:assert_eq,t:fail, …), the rootLab, and thejsonglobal. Less-trafficked methods are typed asany— the goal is silencing diagnostics, not full IDE intellisense.<DIR>/.luarc.json— config pointing the LSP at.provium-meta/.
DIR defaults to the current directory. If .luarc.json already exists, the command refuses to overwrite and prints the snippet to merge in by hand; pass --force to replace it.
Skips pre-flight checks (/dev/kvm, iproute2, nft) so it runs cleanly on machines without test prerequisites — useful from a dev laptop separate from the test host.
After running, reload your editor's Lua server. To extend the types further (e.g. richer completion on a specific userdata you use a lot), edit .provium-meta/types.lua directly.
Exit codes #
| Code | Meaning |
|---|---|
0 | Every file passed (or skipped). |
1 | At least one file did not finish cleanly (failed test, panicked runner, timed-out file). With --coverage, also reflects the coverage post-run's exit code if it failed. |
2 | Internal error — config load failure, pre-flight failure, a dynamic profile's build command failing, or a panic in the dispatcher. |
The exit code is intentionally clamped to 0/1/2 rather than reflecting the number of failed files, so shell scripts don't have to worry about overflow at the 256-file mark or accidentally interpret failed_count == 2 as the internal-error sentinel.
Environment variables #
| Variable | Effect |
|---|---|
PROVIUM_TEST_FILTER | JSON object set by the binary at startup carrying --include-slow / --tag / --no-tag / --tag-meta / --no-tag-meta so the runner picks them up. Test code does not set this directly. |
PROVIUM_OVERLAY | Path to the agent-overlay cpio. Overrides the binary-relative path-walk; lower priority than the per-profile agent_overlay_path config field. Useful for distribution-installed Provium where the overlay isn't co-located with the binary. |
PROVIUM_RERUN_STATE | Override the path of the rerun-state file (default: ~/.cache/provium/rerun.json, or /tmp/provium-rerun.json if $HOME is unset). |
PROVIUM_COVERAGE_TMP | Set by --coverage to point at the temp event file the post-run hook will read. Cleared after the hook completes. |
PROVIUM_COVERAGE_USER_FILE | Set when --coverage reuses an explicit --save-events path so the post-run hook does NOT delete it. |
PROVIUM_COVERAGE_MARKER | Marker file --coverage uses to recognise its own temp file (vs an externally-set PROVIUM_COVERAGE_TMP pointing at user data). |
HOME | Used to compute the default rerun-state path. |
PATH | Searched for qemu-system-x86_64, ip, tc, nft, tcpdump, and (on --coverage) provium-coverage. |
Output shape #
Plain text (default) #
PASS tests/smoke.test.lua (3 passed, 0 failed, 0 skipped)
FAIL tests/networking/partition.test.lua (2 passed, 1 failed, 0 skipped)
FAIL split-brain
client A could not reach client B after partition
TIME tests/long.test.lua (1 passed, 0 failed, 0 skipped)
3 file(s); 6 passed, 1 failed, 0 skipped; 12.34s
-v adds PASS <name> / SKIP <name> lines for each test. -q suppresses everything except the file-status line for files with failures and the summary.
JSON (--json) #
One object per file, line-delimited. Schema:
status is one of "passed", "failed", "skipped". timeout is "in_time" or "timed_out".
Msgpack events (--events-stdout) #
Length-prefixed msgpack frames, one per EventFrame (see events). When --events-stdout is set, the human-readable / JSON renderer redirects to stderr so consumers piping provium --events-stdout | provium-coverage don't see human text interleaved into their msgpack parser.
Filter precedence #
Multiple selection flags compose:
- Discovery: walk
<paths>for*.test.luafiles. --filtersubstring match against test-root-relative path.--rerun-failedintersect with the prior failed set (zero-result clean exit if no prior state).--sincemtime newer than the reference path.- Per-test (within each file):
meta.skip→ instant skip. - Per-test:
meta.slowand--include-slow. - Per-test:
--no-tag(skip if any match). - Per-test:
--tag(run if any match). - Per-test:
--no-tag-meta KEY=VALUE(skip ifmeta[KEY]contains VALUE). - Per-test:
--tag-meta KEY=VALUE(run if all KEYs have at least one matching VALUE).
--watch mode rediscovers from scratch every iteration; --rerun-failed is automatically dropped under --watch so file-edit detection works as expected.
See also #
- provium.toml reference — config-file fields.
- Events — wire format of the msgpack event stream.
- Running tests — patterns for the CLI.
Events
Provium / Reference
Provium's host-side scheduler and runners emit observability events that drive the human-readable summary, the --json output, provium-coverage, and any other consumer that wants to follow what the harness is doing.
The transport is length-prefixed msgpack frames (the same framing as the wire protocol). Get them via --save-events <path>, --events-stdout, or --events-socket <path>.
Frame shape #
Each frame is an EventFrame:
{
"ts": <i64>, // ns since Unix epoch, host clock at emission
"kind": "<name>", // discriminator (snake_case)
"payload": { … } // variant-specific
}
The Event enum is flattened into the envelope so kind and payload appear at the top level of each frame.
PROTOCOL_VERSION (currently 1) is bumped on any wire-shape change. Adding a new variant or field is a strict version bump; removing or renaming one is breaking. See protocol version for the pinning policy.
File lifecycle #
file_discovered #
Emitted once per *.test.lua file before any dispatching begins.
| Field | Type | Description |
|---|---|---|
path | string | Test-root-relative path. |
fixture_refs | array of strings | Fixtures referenced by vm_fixture(...) / lab_fixture(...) (path with .fixture.lua stripped). |
declared_claim | optional ResourceAmount | provium:claim(...) declared at file scope, if any. |
file_dispatched #
A test file was picked up by a runner thread.
| Field | Type | Description |
|---|---|---|
path | string | File path. |
reservation | ResourceAmount | Resources reserved at dispatch (sum of runner overhead + claim). |
file_blocked #
A test file is waiting on the resource pool or PSI pressure.
| Field | Type | Description |
|---|---|---|
path | string | File path. |
waiting_for | ResourceAmount | What the file is waiting on. |
reason | string | pool_full, psi_pressure, etc. |
file_completed #
A test file finished — successfully, with failures, or terminated by timeout / panic.
| Field | Type | Description |
|---|---|---|
path | string | File path. |
status | enum | passed, failed, timed_out, crashed. |
duration_ns | u64 | Wall-clock duration. |
crashed means the runner panicked or hit a test-infrastructure failure; remaining tests in the file are marked failed-due-to-poisoned-state.
Test lifecycle #
test_started #
A test() block within a file started.
| Field | Type | Description |
|---|---|---|
path | string | Containing file's path. |
name | string | test() block name. |
meta | MetaMap | Per-test metadata. Always present; empty if none. |
test_skipped #
The test was filtered or marked Skipped (declarative meta.skip, inline t:skip(), file-scope todo(), or tag/slow filter).
| Field | Type | Description |
|---|---|---|
path | string | Containing file's path. |
name | string | Test name. |
reason | string | Filter expression, t:skip() argument, or todo() argument. |
meta | MetaMap | Per-test metadata. |
test_passed #
| Field | Type | Description |
|---|---|---|
path | string | File path. |
name | string | Test name. |
duration_ns | u64 | Test wall-clock duration. |
meta | MetaMap | Per-test metadata. |
test_failed #
| Field | Type | Description |
|---|---|---|
path | string | File path. |
name | string | Test name. |
duration_ns | u64 | Wall-clock up to failure. |
reason | string | Assertion message, exception text, or panic payload. |
console_excerpt | string | Last 4 KiB from each booted VM's console log. May be empty. |
meta | MetaMap | Per-test metadata. |
VM lifecycle #
vm_spawned #
| Field | Type | Description |
|---|---|---|
file | string | File that owns this VM. |
vm_name | string | Name as given to lab:vm. |
profile | string | Profile used to boot. |
memory_bytes | u64 | Memory cap. |
cid | u32 | Assigned vsock CID. |
vm_shutdown #
| Field | Type | Description |
|---|---|---|
file | string | File that owned this VM. |
vm_name | string | VM name. |
duration_ns | u64 | VM uptime. |
Pool and claims #
pool_state #
Periodic snapshot of resource-pool usage. Default cadence: 1 Hz.
| Field | Type | Description |
|---|---|---|
used | ResourceAmount | Currently in use. |
available | ResourceAmount | Currently free. |
claim_acquired #
A file successfully claimed resources via provium:claim(...).
| Field | Type | Description |
|---|---|---|
path | string | File path the claim belongs to. |
amount | ResourceAmount | Amount claimed. |
claim_released #
A file released its claim. Pairs with claim_acquired on the same path.
| Field | Type | Description |
|---|---|---|
path | string | File path. |
amount | ResourceAmount | Amount released. |
Fixtures #
fixture_build_started #
| Field | Type | Description |
|---|---|---|
path | string | Fixture path (test-root-relative, no .fixture.lua). |
fixture_build_done #
| Field | Type | Description |
|---|---|---|
path | string | Fixture path. |
duration_ns | u64 | Build duration. |
snapshot_bytes | u64 | Cached snapshot size, post-compression. |
fixture_build_waiting #
A second file is waiting on the build lock another file holds.
| Field | Type | Description |
|---|---|---|
path | string | Fixture path being waited on. |
held_by_file | string | File currently holding the build lock. |
fixture_cache_hit #
A fixture was resumed from its cached snapshot — no rebuild required.
| Field | Type | Description |
|---|---|---|
path | string | Fixture path. |
Shared types #
ResourceAmount #
{
"memory_bytes": <u64>,
"cpus": <u32>
}
Used in pool / claim / reservation events.
MetaMap and MetaValue #
MetaMap is BTreeMap<String, MetaValue>. MetaValue is a tagged-by-type union over native msgpack types:
| Variant | msgpack mapping |
|---|---|
Null | unit / nil |
Bool | bool |
Int | i64 |
Float | f64 |
Str | str |
Bytes | bin (distinct from str) |
Array | array of MetaValue |
Map | map of String → MetaValue |
The deserialiser dispatches on the input type rather than relying on declaration-order fallthrough, so valid-UTF-8 byte sequences are not mis-classified as strings.
Event guarantees #
file_discoveredfor every discovered file, before any dispatching.- For each file: at most one
file_dispatched, optionalfile_blocked(s) before it, exactly onefile_completedafter. - For each test: exactly one
test_started, then exactly one oftest_passed/test_failed/test_skipped. claim_acquiredandclaim_releasedare paired per file.fixture_build_startedandfixture_build_doneare paired per build. Afixture_build_waitingmay precede the_doneif the file queued behind a peer.fixture_cache_hitfires AFTER successful restore — never on a corrupt entry that turns into a rebuild.
Consuming the stream #
The three output channels — --save-events <path> (file), --events-stdout (pipe), and --events-socket <path> (multi-consumer Unix socket) — and the patterns for choosing between them are covered in events and coverage.
See also #
- CLI — flags that produce / consume events.
- Protocol version — wire-shape pinning policy.
- Events and coverage — patterns for using the event stream.
Protocol version
Provium / Reference
Provium has one wire-version constant: PROTOCOL_VERSION, currently 1.
It pins:
- The host ↔ agent protocol — every op, every result variant, every error shape.
- The observability event stream — every
Eventvariant and every payload field. - The Hello / HelloOk / HelloErr handshake and the
OpenModefield set.
When it bumps #
PROTOCOL_VERSION increments any time:
- A new wire variant is added (op or event).
- A field is added to an existing payload struct.
- A new
OpenModeflag,ExecResultvariant, orOpResultshape lands. - The
EventFrameenvelope changes.
It is not bumped for changes that don't reach the wire — internal struct renames, host-side refactors, agent-side optimisation, additional Lua bindings that map onto existing wire ops.
What it does NOT pin #
- The Lua API surface (the userdata methods
vm,bridge, etc.). That surface evolves freely; tests that use it are rebuilt against the same Provium version that runs them. - Internal types like
Vm,Lab,Bridge. Those are language-level objects, not wire-level contracts. - The CLI flag set. New flags can be added without bumping the protocol; removing or renaming a flag is a separate compatibility concern documented in the CLI reference.
How the pin is enforced #
The conformance suite locks every wire-facing struct against an explicit byte expectation. Examples of what it locks:
Hello.protocol_versionis a u32 currently encoded as1.HelloOkfield tags (agent_version,guest_os,agent_features, …).OpResult.outcomeis the"ok"/"err"discriminator key, withvaluefor the payload.SyscallResultfield set:ret,errno, optionalout_bufs(omitted when empty).OpenMode's six flags —read,write,create,truncate,append,exclusive— each serialise as their own named field (it is a struct of booleans, not a packed bitfield).
Any refactor that changes a tag name, reorders an enum, or shifts a bit fails the pin test. The fix is to bump PROTOCOL_VERSION and update the pin to match the new shape — explicit, not silent.
How to add a new op or event #
- Add the new variant / payload type to the protocol crate.
- Add a serialisation round-trip test alongside it.
- Bump
PROTOCOL_VERSION. - Update the conformance pin to lock the new shape.
- (Op only) Wire the agent-side handler.
- (Op only) Wire the host-side dispatcher and a Lua binding.
Skipping step 4 produces a protocol change without explicit pinning — exactly the regression the pin tests are designed to catch.
Backwards compatibility #
Provium does not commit to backwards compatibility on the host-agent wire. The host and agent are versioned together — they ship in lock-step out of the same workspace. The Hello handshake exchanges versions and HelloErrs the connection if they don't match, so a stale agent against a fresh host fails fast at connection time, not silently mid-op.
For the event stream, consumers (provium-coverage, dashboards) are versioned against PROTOCOL_VERSION. A consumer built against version N reading a stream from version N+1 will fail to deserialise the new fields; the typical recovery is to rebuild the consumer.