Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Provium

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:vm("a", "peios")              -- create a VM in the root lab
provium:bridge("lan")                 -- create a bridge in the root lab
provium:lab("subset")                 -- create a sub-lab
provium:vm_fixture("base")            -- restore a fixture into a VM
provium:lab_fixture("ha-pair")        -- restore a fixture into a sub-lab
provium:claim({memory="4G", cpus=2})  -- file-level resource reservation
provium:boot()                        -- batch-boot every VM in the lab
provium:shutdown()                    -- batch-shutdown every VM in the lab
provium:snapshot()                    -- whole-lab snapshot
provium:restore(snap)                 -- whole-lab restore
provium:pack(...) / provium:unpack(...)  -- 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.reset_between_tests = true

test("a", function(t) provium:vm("v", "peios"):boot() end)
test("b", function(t) provium:vm("v", "peios"):boot() end)  -- 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.timeout = "30s"

test("a", function(t) … end)         -- per-test deadline = 30s
test("b", {timeout = "5m"}, function(t) … end)  -- 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 keysResolves to
provium.pack, provium.unpackLua's standard string.pack / string.unpack. Reserved against shadowing.
provium.vm_fixture, provium.lab_fixtureFunctions equivalent to the :vm_fixture(...) / :lab_fixture(...) methods.

After reserved keys, the lookup tries:

  1. A VM declared by provium:vm("<name>", ...) — returns the VM userdata.
  2. A bridge declared by provium:bridge("<name>") — returns the Bridge userdata.
  3. A sub-lab declared by provium:lab("<name>") — returns the LabUd userdata.
  4. nil if nothing matches.

Examples:

local lan = provium:bridge("lan")
local a   = provium:vm("a", "peios")
local b   = provium:vm("b", "peios")

-- Later in the test:
provium.lan:attach({provium.a, provium.b})
provium.a:boot()
if provium.dc1 then provium.dc1:boot() end

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:vm_fixture("base")     -- method form
local vm2 = provium.vm_fixture("base")     -- 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:pack(">I4 I4 s2", id, seq, payload)
local id, seq, payload = provium:unpack(">I4 I4 s2", frame)

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 provium carries 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.encode({a = 1, b = "x"})        -- '{"a":1,"b":"x"}'
json.encode({10, 20, 30})            -- '[10,20,30]'
json.encode(true)                    -- 'true'
json.encode(nil)                     -- 'null'
json.encode({a = 1, b = nil})        -- '{"a":1}'  (b isn't in the table — Lua semantics)
json.encode({[1]=1, [3]=3})          -- '[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.decode('{"x": 42}')
print(t.x)                           -- 42

local t = json.decode('{"a": {"b": ["c", "d"]}}')
print(t.a.b[2])                      -- "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):

InputLua resultNotes
nullnilTop-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:read_file("/var/state/peios/state.json")
local state = json.decode(body)
t:assert_eq(state.ready, true)

Building a request body for a test client:

local body = json.encode({op = "write", key = "k", value = "v"})
vm:run("curl", {"-X", "POST", "-d", body, "http://localhost:8080/api"}):assert_ok()

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 #

SourceReturns
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.nameSame 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.

KeyWhereTypeDescription
memorylab:vm optsint (bytes) or string "512M"/"2G"VM memory cap. Hands to QEMU as -m <size>.
cpuslab:vm optsintvCPU count. Hands to QEMU as -smp <n>.
kernel_cmdlinevm:boot optsstringReplaces the profile's cmdline.
rng_seedvm:boot optsint (u64)Seeds the guest's virtio-rng. Use for determinism.
initial_timevm:boot optsint or float (seconds since epoch)Sets the guest's wall clock at boot.
filesvm:boot optsarray of {path=string, content=string}Files to inject into the guest's filesystem before init runs.

Example:

local vm = provium:vm("v", "peios", {memory = "2G", cpus = 4})
vm:boot({
    rng_seed = 0xdeadbeef,
    initial_time = 1700000000,
    files = {
        {path = "/etc/hostname", content = "v"},
    },
})

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:snapshot()
local s2 = vm:snapshot("/tmp/before-mutation.snap")

vm:restore(snap_or_path) #

Restore from a Snapshot userdata (preferred) or a bare path string. Errors if not in Created or Shutdown.

vm:shutdown()
vm:restore(s)

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 with args= are not allowed; use one form or the other.

Returns a RunResult userdata.

Opts keyTypeDescription
argsarray of stringsDirect positional args (only used with the table-as-opts form).
envstring→string mapEnvironment variables.
env_clearboolWhen true, the guest sees only env; otherwise env merges with the agent's environment.
cwdstringWorking directory inside the guest.
stdinstringBytes piped to the process's stdin.
timeout_msintHard wall-clock timeout in milliseconds.
timeoutint / float / stringSame, 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:

FieldTypeDescription
sizeint (bytes)File size.
mtimefloat (seconds since epoch)Modification time.
mtime_nsint (ns since epoch)Same, full precision.
permintPOSIX mode bits (≤ 4095, i.e. 0o7777).
entry_typestringOne 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:

KeyTypeEffect
readboolOpen for reading.
writeboolOpen for writing.
createboolCreate if absent.
truncateboolTruncate to zero bytes on open.
appendboolAppend-only writes.
exclusiveboolCombine with create=true to require the file not already exist (O_EXCL).
permintPOSIX 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.pack("<I4I4I8", PRIVS_CLASS, 4096, 0)  -- buf_ptr filled in by nested
local out  = string.rep("\0", 4096)
local r = vm:syscall(SYS_ioctl, {
    args   = { fd, QUERY_CMD, 0 },     -- arg slot 2 receives the struct pointer
    bufs   = { args, out },            -- buf 1 = struct, buf 2 = output
    ptrs   = { 2 },                    -- buf 1 → arg slot 2
    nested = { { parent = 1, child = 2, offset = 8 } },  -- buf 2's addr → buf 1 @ offset 8
})
local data = r.out_bufs[2]             -- the QUERY output buffer

Returns a table:

FieldTypeDescription
retintSyscall return value.
resultintSame as ret (alias for clarity).
errnointErrno if the syscall returned negative; 0 otherwise.
out_bufsarray of stringsPost-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 as vm: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:batch(function(b)
    b:write_file("/tmp/a", "1")
    b:write_file("/tmp/b", "2")
    b:read_file("/tmp/a")
    b:stat("/tmp/missing")
end)
-- 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:

FieldTypeDescription
exit_codeintExit code. -1 for signalled, -2 for timed_out. Use signal and timed_out to disambiguate.
stdoutstringCaptured stdout.
stderrstringCaptured stderr.
statusstring"exited", "signalled", "timed_out".
timed_outbooltrue if the timeout fired.
signalint or nilSignal number when status is signalled, else nil.

Methods:

  • result:ok() — convenience for result.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 #

  • Lab — what creates and contains VMs.
  • Process — what vm:run_async returns.
  • Worker — what vm:spawn_worker returns.
  • Disk, Nic — hypervisor-side handles.
  • Console, Clock — guest-side accessors.
  • Streams — what vm:tail_file and vm:fd_stream return.

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 #

SourceReturns
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 via bridge: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 #

  • Nic — per-(bridge, vm) handle for link-state and per-NIC capture.
  • Streams — what bridge:capture() returns.
  • Lab — bridges live in a lab.

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:lab("dc1")
dc1:vm("a", "peios")
dc1:vm("b", "peios")
dc1:bridge("lan"):attach({dc1.a, dc1.b})

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:depends_on_file("../templates/sshd_config.in")

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:

  1. Reserved keys: pack, unpack, vm_fixture, lab_fixture.
  2. VM with that name (walks parent scope chain on miss).
  3. Bridge with that name (walks parent scope chain on miss).
  4. Sub-lab with that name (walks parent scope chain on miss).
  5. 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.

FieldTypeEffect
memoryint (bytes) or string "512M" / "2G"Memory budget.
cpusintvCPU budget.
provium:claim({memory = "4G", cpus = 4})

test("…", function(t) end)
test("…", function(t) end)

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(provium:barrier("checkpoint", 1))

-- Unmet count: times out and returns false.
assert(not provium:barrier("pair", 2, 0.1))

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:vm_fixture("base")
local vm = provium:vm_fixture("setups/networked")

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 #

SourceReturns
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:

FieldTypeDefaultDescription
idstring"attached-<vm_name>"Disk identifier within the VM.
sizeint (bytes)4 GiBModelled disk size. Used for :size() when no image is attached.
imagestring (path)noneBacking 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_read fault is active (disk:read_sectors: EIO (fault_inject)).
  • The host-side read fails (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 for eio_read so 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_write active before the I/O → EIO.
  • slow → 50 ms sleep, with eio_write re-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("guest sees EIO and recovers after clear", function(t)
    local img = "/tmp/test-image"
    -- Pre-create the backing file.
    local f = io.open(img, "w"); f:write(string.rep("\0", 512 * 1024)); f:close()

    local vm = provium:vm("v", "peios"):boot()
    local disk = vm:attach_disk({id = "vda", size = 512 * 1024, image = img})

    -- Confirm baseline read works.
    local body = disk:read_sectors(0, 1)
    t:assert_eq(#body, 512)

    -- Inject EIO.
    disk:fault_inject("eio_read")
    local ok, err = pcall(function() disk:read_sectors(0, 1) end)
    t:assert(not ok)
    t:assert(tostring(err):find("EIO"))

    -- Clear and confirm reads recover.
    disk:clear_faults()
    body = disk:read_sectors(0, 1)
    t:assert_eq(#body, 512)
end)

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 #

SourceReturns
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.

FieldDescription
rx_bytesBytes the guest received (host TAP's tx_bytes).
tx_bytesBytes the guest sent (host TAP's rx_bytes).
rx_packetsPackets received.
tx_packetsPackets sent.
errorsSum 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("guest reconnects after link flap", function(t)
    local lan = provium:bridge("lan")
    local a   = provium:vm("a", "peios"):boot()
    local b   = provium:vm("b", "peios"):boot()
    lan:attach({a, b})
    a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
    b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

    -- Baseline.
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()

    local nic = a:nic("lan")
    nic:disconnect()
    -- Pings should fail now.
    local r = a:run("ping -c 1 -W 1 10.0.0.2")
    t:assert(not r:ok())

    nic:reconnect()
    -- And succeed after reconnect.
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

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 #

SourceReturns
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:open_file("/etc/hostname", {read=true})
local s = f:read(64)

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("file handle round-trip", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local h = vm:open_file("/tmp/data", {write=true, create=true, truncate=true, perm=0x180})  -- 0o600
    local n = h:write("hello world")
    t:assert_eq(n, 11)
    h:close()

    local r = vm:open_file("/tmp/data", {read=true})
    r:seek(6, "set")
    t:assert_eq(r:read(5), "world")
    t:assert_eq(r:tell(), 11)
    r:close()
end)

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 do
    local chunk = h:read(4096)
    if chunk == "" then break end
    -- process chunk
end

See also #

  • VM — vm:open_file, vm:read_file, vm:write_file, vm:fd_stream.
  • Worker — worker:open_file for 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 #

SourceReturns
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 to SIGTERM.
  • 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("server emits ready then handles request", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local proc = vm:run_async("python3", {args = {"server.py"}})
    local out  = proc:stdout_stream()

    -- Wait for the server to log "ready".
    out:expect("ready", "5s")

    -- Now hit it.
    local r = vm:run("curl -s http://localhost:8080/")
    r:assert_ok()
    t:assert(r.stdout:find("hello"))

    -- Tear down. Inside the test scope this happens automatically;
    -- here we just demonstrate it works explicitly too.
    proc:kill("term")
    local exit = proc:wait("2s")
    t:assert(exit.signal == 15 or exit.exit_code == 0)
end)

See also #

  • VM — vm:run_async returns a Process.
  • Worker — worker:run_async returns a Process.
  • Streams — what proc:stdout_stream / proc:stderr_stream return.
  • 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 #

SourceReturns
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("two concurrent writers don't tear", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local w1 = vm:spawn_worker()
    local w2 = vm:spawn_worker()

    -- Each worker writes its own block of data, in parallel.
    local p1 = w1:run_async("dd", {args = {"if=/dev/urandom", "of=/tmp/a", "bs=1M", "count=8"}})
    local p2 = w2:run_async("dd", {args = {"if=/dev/urandom", "of=/tmp/b", "bs=1M", "count=8"}})

    p1:wait("10s"):assert_ok()
    p2:wait("10s"):assert_ok()

    t:assert_eq(vm:stat("/tmp/a").size, 8 * 1024 * 1024)
    t:assert_eq(vm:stat("/tmp/b").size, 8 * 1024 * 1024)
end)

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 #

  • VM — vm:spawn_worker() and the parent surface.
  • Process, File — what worker ops return.

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.

TypeReturned byBacking
Tailvm:tail_file, vm:fd_stream, file:tail_stream, proc:stdout_stream, proc:stderr_streamFrame-based agent stream over vsock.
Capturebridge:capture, nic:capturetcpdump child stdout — pcap bytes, not framed.
ConsoleStreamconsole: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:

ValueEffect
"end" (default)Stream only bytes appended after the call.
"beginning" or "start"Replay the whole file from byte 0, then continue tailing.
Non-negative integerStart streaming from that exact byte offset.
Negative integerStart 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 floatTruncated 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:open_file("/var/log/messages", {read=true})
local stream = f:tail_stream()
stream:expect("ready", "30s")

Race a process against a timeout #

local proc = vm:run_async("slow-server")
local out  = proc:stdout_stream()
out:expect("listening", "10s")
-- now hit it

Drain a capture and pass through pcap-parser #

local cap = bridge:capture()
vm:run("ping -c 5 10.0.0.2")
local frames = cap:drain("2s")
local pcap = table.concat(frames)
-- write pcap to a file or feed it to a parser

See also #

  • VM — vm:tail_file, vm:fd_stream.
  • Bridge — bridge:capture.
  • Nic — nic:capture.
  • Console — console:read returns a ConsoleStream.
  • Process — proc:stdout_stream / proc:stderr_stream return Tails.
  • File — file:tail_stream returns a Tail.
  • Streams and tails — patterns and gotchas.

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 #

SourceReturns
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:console():read_log()
t:assert(log:find("Welcome to Peios"))

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:

KeyTypeEffect
timeoutnumber (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("login as root via console", function(t)
    provium.timeout = "60s"
    local vm = provium:vm("v", "peios"):boot()
    local console = vm:console()
    local stream  = console:read()
    stream:expect("login:", "30s")
    console:write("root\n")
    stream:expect("Password:", "5s")
    console:write("toor\n")
    stream:expect("# ", "5s")
end)

See also #

  • VM — vm:console().
  • Streams — what console:read() returns.

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 #

SourceReturns
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:clock():get()
-- 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("client refuses an expired cert", function(t)
    local vm = provium:vm("v", "peios"):boot()
    -- Set the clock to before the cert was issued.
    vm:clock():set(1500000000)  -- 2017-07-14
    local r = vm:run("openssl s_client -connect server:443 < /dev/null")
    r:assert_ok()  -- baseline: cert is valid

    -- Jump past expiry.
    vm:clock():advance(20 * 365 * 24 * 3600)  -- +20 years
    local r2 = vm:run("openssl s_client -connect server:443 < /dev/null")
    t:assert(not r2:ok())  -- cert is now expired
    t:assert(r2.stderr:find("certificate has expired"))
end)

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 the boot_opts.initial_time boot-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 #

SourceReturns
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 #

SourceReturns
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("base", "peios"):boot()
vm:run("seq 1000000 > /srv/corpus"):assert_ok()
return vm:snapshot()
-- tests/fixtures/cluster.fixture.lua
provium:bridge("lan")
local a = provium:vm("a", "peios"):boot()
local b = provium:vm("b", "peios"):boot()
provium.lan:attach({a, b})
a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")
return provium:snapshot()
-- tests/uses-base.test.lua
test("base fixture has the corpus pre-built", function(t)
    local vm = provium:vm_fixture("fixtures/base")
    vm:run("test -s /srv/corpus"):assert_ok()
end)

test("cluster fixture brings two VMs", function(t)
    local cluster = provium:lab_fixture("fixtures/cluster")
    cluster.a:run("ping -c 1 10.0.0.2"):assert_ok()
end)

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("simple", function(t)
    t:assert(true)
end)

test("with metadata", {tags={"smoke"}, timeout="30s"}, function(t)
    t:assert(true)
end)
ArgumentRequiredTypeDescription
nameyesstringTest name. Must be unique within the file (duplicate names raise at registration).
metanotablePer-test metadata. See meta tags.
fnyesfunctionThe 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("waiting on the spec")
test("a", function(t) … end)
test("b", function(t) … end)
-- 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(function()
    local r = vm:run("pidof nginx")
    if r:ok() then return r.stdout:match("%d+") end
end, {timeout = "30s", interval = "200ms", desc = "nginx running"})

Opts:

FieldDefaultTypeDescription
timeout10number (seconds) or string "30s" / "500ms" / "5m" / "2h"Total time to wait before giving up.
interval0.1number (seconds)Time between predicate calls.
desc"condition"stringUsed 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("only on btrfs", function(t)
    if vm:run("findmnt /"):ok() and not vm:run("findmnt -t btrfs /"):ok() then
        t:skip("not a btrfs root")
    end
    -- … btrfs-specific test body …
end)

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:

StatusTriggered by
PassedTest fn returned without raising AND no assertion failure was recorded.
FailedTest 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.
Skippedt: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("big batch import", {slow = true}, function(t) … end)

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("not implemented yet", {skip = true}, function(t) … end)
test("waiting on RFC", {skip = "see issue #42"}, function(t) … end)

Declarative skip. Three accepted forms:

ValueSkip reason
true (or any non-zero int)"skipped (meta.skip)"
String "<reason>""skipped: <reason>"
false / nil / 0Not skipped.

meta.tags #

test("dns lookup", {tags = {"net", "dns"}}, function(t) … end)
test("ipv6 only", {tags = "ipv6"}, function(t) … end)  -- single string also accepted

Tag filtering works through the CLI:

  • provium tests/ --tag net — run only tests tagged net. Repeatable: --tag a --tag b is "a OR b".
  • provium tests/ --no-tag dns — run every test EXCEPT those tagged dns. 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("DNS service survives peinit restart", {
    subsystems = {"peinit", "loregd", "networking"},
    tags = {"dns", "services"},
    slow = true,
}, function(t) … end)

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("flaky network probe", {timeout = "30s"}, function(t) … end)
test("quick", {timeout = 0.5}, function(t) … end)

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("token issue follows §4.2.1", {spec = "PSD-KACS §4.2.1"}, function(t) … end)

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("federation handshake (multi-DC)", {
    tags = {"federation", "slow", "multi-dc"},
    timeout = "5m",
    spec = "PSD-FEDERATION §3.1",
}, function(t)
    if not have_two_dcs() then
        t:skip("requires two DCs")
    end
    -- … real test body …
end)

Conditional gate via wait_until #

test("sysctl knob takes effect", {
    spec = "PSD-PEINIT §6.5",
    timeout = "10s",
}, function(t)
    vm:run("sysctl -w kernel.shm_rmid_forced=1"):assert_ok()
    wait_until(function()
        return vm:read_file("/proc/sys/kernel/shm_rmid_forced") == "1\n"
    end, {timeout = "5s", desc = "sysctl knob propagated"})
end)

See also #

  • Test framework — test(), the t context.
  • 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 #

FlagTypeDefaultDescription
--config <PATH>pathprovium.tomlConfig file location.
--filter <STR>stringnoneOnly run files whose test-root-relative path contains this substring.
--include-slowflagoffInclude meta.slow tests (default skips them).
--tag <TAG>string, repeatablenoneRun only tests with one of these tags. OR'd.
--no-tag <TAG>string, repeatablenoneSkip tests with any of these tags. Wins over --tag.
--tag-meta <KEY=VALUE>string, repeatablenoneRun 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, repeatablenoneSkip tests where meta[KEY] contains VALUE. Same key/value semantics as --tag-meta. Wins over --tag-meta.
--rerun-failedflagoffRun only files that failed in the last run. Reads from ~/.cache/provium/rerun.json.
--since <PATH>pathnoneRun only files whose mtime is newer than this reference file.
--watchflagoffRe-run on file change (poll every 500 ms).

VMM and resources #

FlagTypeDefaultDescription
--vmm <CHOICE>qemu / localqemuVMM backend. local is for dev runs without KVM — does not actually boot a kernel.
--mem <BYTES>size string "4G"80 % of host RAMPool memory budget.
--cpus <N>inthost online CPUsPool vCPU budget.
--cpu-overcommit <F>float1.0Multiplier on --cpus. Clamped to [0.5, 8.0].
--no-preflightflagoffSkip the startup /dev/kvm / iproute2 / nft / qemu / CAP_NET_ADMIN checks.
--no-ksmflagoffSkip Kernel Same-page Merging tuning at startup.
--no-buildflagoffSkip 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 #

FlagTypeDefaultDescription
--timeout <DUR>seconds-int or duration string300 (5 min)Per-file wall-clock timeout. 0 disables. Accepts "500ms", "30s", "10m", "2h".
--fail-fastflagoffStop after the first failed file.

Output #

FlagTypeDefaultDescription
-v, --verboseflagoffShow passing tests too. Mutually exclusive with --quiet.
-q, --quietflagoffShow only failures. Mutually exclusive with --verbose.
--jsonflagoffLine-delimited JSON output, one object per file. Mutually exclusive with --events-stdout.

Observability #

FlagTypeDescription
--save-events <PATH>pathPersist the event stream to PATH as length-prefixed msgpack frames. Compatible with provium-coverage --from PATH.
--events-stdoutflagEmit msgpack event frames on stdout; the human/JSON renderer redirects to stderr. Mutually exclusive with --json.
--events-socket <PATH>pathMultiplex the event stream over a Unix socket. The binary listens, accepts connections, fans out frames. Reused across --watch iterations.
--coverageflagPipe 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 \` not found in provium.tomlif 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 / flagDescription
<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.

FlagDescription
--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.
--agentInject the agent overlay (off by default for console sessions).
--qemu <PATH>Pick the QEMU binary.
--print-commandPrint 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 — ---@meta stubs covering the test framework, the TestContext (t:assert_eq, t:fail, …), the root Lab, and the json global. Less-trafficked methods are typed as any — 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 #

CodeMeaning
0Every file passed (or skipped).
1At 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.
2Internal 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 #

VariableEffect
PROVIUM_TEST_FILTERJSON 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_OVERLAYPath 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_STATEOverride the path of the rerun-state file (default: ~/.cache/provium/rerun.json, or /tmp/provium-rerun.json if $HOME is unset).
PROVIUM_COVERAGE_TMPSet by --coverage to point at the temp event file the post-run hook will read. Cleared after the hook completes.
PROVIUM_COVERAGE_USER_FILESet when --coverage reuses an explicit --save-events path so the post-run hook does NOT delete it.
PROVIUM_COVERAGE_MARKERMarker file --coverage uses to recognise its own temp file (vs an externally-set PROVIUM_COVERAGE_TMP pointing at user data).
HOMEUsed to compute the default rerun-state path.
PATHSearched 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:

{
  "path": "tests/smoke.test.lua",
  "timeout": "in_time",
  "passed": true,
  "chunk_error": null,
  "tests": [
    {"name": "boots", "status": "passed", "message": null, "log": []},
    {"name": "fails", "status": "failed", "message": "1 ~= 2", "log": []}
  ]
}

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:

  1. Discovery: walk <paths> for *.test.lua files.
  2. --filter substring match against test-root-relative path.
  3. --rerun-failed intersect with the prior failed set (zero-result clean exit if no prior state).
  4. --since mtime newer than the reference path.
  5. Per-test (within each file): meta.skip → instant skip.
  6. Per-test: meta.slow and --include-slow.
  7. Per-test: --no-tag (skip if any match).
  8. Per-test: --tag (run if any match).
  9. Per-test: --no-tag-meta KEY=VALUE (skip if meta[KEY] contains VALUE).
  10. 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.

FieldTypeDescription
pathstringTest-root-relative path.
fixture_refsarray of stringsFixtures referenced by vm_fixture(...) / lab_fixture(...) (path with .fixture.lua stripped).
declared_claimoptional ResourceAmountprovium:claim(...) declared at file scope, if any.

file_dispatched #

A test file was picked up by a runner thread.

FieldTypeDescription
pathstringFile path.
reservationResourceAmountResources reserved at dispatch (sum of runner overhead + claim).

file_blocked #

A test file is waiting on the resource pool or PSI pressure.

FieldTypeDescription
pathstringFile path.
waiting_forResourceAmountWhat the file is waiting on.
reasonstringpool_full, psi_pressure, etc.

file_completed #

A test file finished — successfully, with failures, or terminated by timeout / panic.

FieldTypeDescription
pathstringFile path.
statusenumpassed, failed, timed_out, crashed.
duration_nsu64Wall-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.

FieldTypeDescription
pathstringContaining file's path.
namestringtest() block name.
metaMetaMapPer-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).

FieldTypeDescription
pathstringContaining file's path.
namestringTest name.
reasonstringFilter expression, t:skip() argument, or todo() argument.
metaMetaMapPer-test metadata.

test_passed #

FieldTypeDescription
pathstringFile path.
namestringTest name.
duration_nsu64Test wall-clock duration.
metaMetaMapPer-test metadata.

test_failed #

FieldTypeDescription
pathstringFile path.
namestringTest name.
duration_nsu64Wall-clock up to failure.
reasonstringAssertion message, exception text, or panic payload.
console_excerptstringLast 4 KiB from each booted VM's console log. May be empty.
metaMetaMapPer-test metadata.

VM lifecycle #

vm_spawned #

FieldTypeDescription
filestringFile that owns this VM.
vm_namestringName as given to lab:vm.
profilestringProfile used to boot.
memory_bytesu64Memory cap.
cidu32Assigned vsock CID.

vm_shutdown #

FieldTypeDescription
filestringFile that owned this VM.
vm_namestringVM name.
duration_nsu64VM uptime.

Pool and claims #

pool_state #

Periodic snapshot of resource-pool usage. Default cadence: 1 Hz.

FieldTypeDescription
usedResourceAmountCurrently in use.
availableResourceAmountCurrently free.

claim_acquired #

A file successfully claimed resources via provium:claim(...).

FieldTypeDescription
pathstringFile path the claim belongs to.
amountResourceAmountAmount claimed.

claim_released #

A file released its claim. Pairs with claim_acquired on the same path.

FieldTypeDescription
pathstringFile path.
amountResourceAmountAmount released.

Fixtures #

fixture_build_started #

FieldTypeDescription
pathstringFixture path (test-root-relative, no .fixture.lua).

fixture_build_done #

FieldTypeDescription
pathstringFixture path.
duration_nsu64Build duration.
snapshot_bytesu64Cached snapshot size, post-compression.

fixture_build_waiting #

A second file is waiting on the build lock another file holds.

FieldTypeDescription
pathstringFixture path being waited on.
held_by_filestringFile currently holding the build lock.

fixture_cache_hit #

A fixture was resumed from its cached snapshot — no rebuild required.

FieldTypeDescription
pathstringFixture 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:

Variantmsgpack mapping
Nullunit / nil
Boolbool
Inti64
Floatf64
Strstr
Bytesbin (distinct from str)
Arrayarray of MetaValue
Mapmap 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_discovered for every discovered file, before any dispatching.
  • For each file: at most one file_dispatched, optional file_blocked(s) before it, exactly one file_completed after.
  • For each test: exactly one test_started, then exactly one of test_passed / test_failed / test_skipped.
  • claim_acquired and claim_released are paired per file.
  • fixture_build_started and fixture_build_done are paired per build. A fixture_build_waiting may precede the _done if the file queued behind a peer.
  • fixture_cache_hit fires 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 Event variant and every payload field.
  • The Hello / HelloOk / HelloErr handshake and the OpenMode field 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 OpenMode flag, ExecResult variant, or OpResult shape lands.
  • The EventFrame envelope 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_version is a u32 currently encoded as 1.
  • HelloOk field tags (agent_version, guest_os, agent_features, …).
  • OpResult.outcome is the "ok" / "err" discriminator key, with value for the payload.
  • SyscallResult field set: ret, errno, optional out_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 #

  1. Add the new variant / payload type to the protocol crate.
  2. Add a serialisation round-trip test alongside it.
  3. Bump PROTOCOL_VERSION.
  4. Update the conformance pin to lock the new shape.
  5. (Op only) Wire the agent-side handler.
  6. (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.

See also #

  • Events — what the event stream carries.
  • CLI — flags that produce events.

Peios Learn — documentation for the Peios project.

Built with Trail.