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

Provium

A KVM-backed test harness — tests written in Lua, run inside real virtual machines against a real kernel.

Single-page view · as markdown

What is Provium

Provium / Getting started

Provium is a test harness for software that needs a real operating system underneath it. It runs each test file in its own Lua state, boots real VMs on demand, and exposes a Lua API for driving the guest (commands, files, syscalls, networking, faults), and reports results through the same passed/failed/skipped shape any other test runner would.

It is the runner Peios uses for kernel-side and system-service tests, but there is nothing Peios-specific in the harness — any guest that ships the Provium agent binary works.

Why Provium exists #

Most kernel-adjacent code is hard to test. Unit tests can verify pure logic, but they cannot exercise:

  • Driver behaviour against real hardware abstractions (disks, NICs, clocks).
  • Networking behaviour under partition, latency, packet loss, or bandwidth caps.
  • Multi-host topologies — two guests on the same bridge, three guests in a cluster.
  • The boot path itself.
  • Failure injection (EIO, slow I/O, link-down, kernel panic recovery).
  • Snapshots, restores, and time travel.

The alternatives — containers, mock kernels, in-process simulators — each give up something important. Provium chooses the path of "use a real VM, make it cheap to spawn, and put a high-leverage API on top."

A single test file looks like this:

test("guest can ping its bridge peer", 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")
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

When the file runs, Provium boots two VMs, sets up a real Linux bridge with TAP interfaces, attaches both, runs the commands inside the guests, and tears everything down at the end. No mocks, no shims. The two ip addr lines are ordinary guest commands: Provium wires layer 2 and leaves IP addressing to the guest.

What Provium gives you #

CapabilityWhat it does
VM lifecycleBoot, snapshot, restore, pause, resume, reset, power-button. Snapshots survive across files via the fixture cache.
Layer-1 opsvm:run("cmd"), vm:read_file, vm:write_file, vm:stat, vm:mkdir — the things you'd normally do over SSH, but driven through a vsock agent so they stay fast and self-contained.
Layer-0 opsvm:syscall, vm:ioctl — direct invocations against the guest kernel, with byte-buffer support for in/out parameters.
File handlesOpen, read, write, seek, tell, close, tail. Mirrors POSIX semantics.
Async processesvm:run_async returns a Process userdata you can :wait, :kill, :signal, write stdin into, and stream stdout/stderr from.
Workersvm:spawn_worker() lets a test concurrently exercise the guest from multiple agent connections without spinning up another VM.
NetworkingReal Linux bridges with TAP interfaces. bridge:partition, bridge:add_latency, bridge:drop_rate, bridge:bandwidth_limit, bridge:isolate, bridge:capture (pcap), and uplink/NAT for outbound traffic.
DisksAttach images, read sectors, write sectors, inject eio_read / eio_write / slow faults.
ConsoleRead the boot log, stream the chardev, write input. Useful for tests that exercise early-boot behaviour or interactive prompts.
Clock controlvm:clock():set, :advance, :sleep. Tests that depend on time can move time deterministically.
StreamsTail/Capture/Console streams all share next / read_until / expect / drain / close / eof so log-watching, pcap-watching, and console-watching all feel the same.
Fixturesprovium:vm_fixture("base") builds a snapshot once, caches it on disk, and restores it for every test that asks for it. Lab fixtures (provium:lab_fixture(...)) cache whole multi-VM topologies the same way.
Resource poolA scheduler with memory and CPU budgets. Test files declare what they need with provium:claim({memory="2G", cpus=4}); the scheduler runs as many in parallel as the budget allows.
ObservabilityEvery host-side action emits a structured msgpack event. Pipe it to provium-coverage, save to a file, multiplex over a Unix socket, or just watch the human-readable summary.
Determinism aidsboot_opts.rng_seed, boot_opts.initial_time, fixture-cache keying that folds in kernel + initrd identity.

How it compares #

ProviumLXC / DockerKUnitMocked I/O
Real kernelYes (per VM)Shared with hostYes (single test kernel)No
Driver-level testingYesLimitedLimitedNo
Network impairmentsBuilt-inExternal toolsNoNo
Multi-host topologiesBuilt-inCompose / k8sNoNo
Snapshot + restoreBuilt-in (fixtures)ManualNoN/A
Fault injectionBuilt-in (fault_inject, clock:advance)LimitedLimitedYes
Per-test isolationFresh VM per fileContainer per testTest-binary boundaryProcess
Wire protocol exposedYes (vm:syscall, vm:ioctl)NoDirect in-kernelN/A
Test languageLua 5.4Shell / Go / PythonCAny
DependenciesQEMU, KVM, iproute2, nftablesDocker daemon, etc.Kernel buildTest framework

How it works #

flowchart LR
    cli[provium CLI] --> disp[Dispatcher]
    disp --> runner1[File runner #1]
    disp --> runner2[File runner #2]
    runner1 --> lua1[Lua state]
    runner2 --> lua2[Lua state]
    lua1 -->|host bindings| host1[VM, Bridge, Lab]
    host1 -->|QEMU launch| vm1[Guest VM]
    vm1 -->|vsock| agent1[provium-agent]
    agent1 -->|exec, syscall, file ops| guest_kernel[Guest kernel]

When you run provium tests/, the CLI:

  1. Walks tests/ for *.test.lua files.
  2. Hands each file to the dispatcher, which acquires resources from the pool.
  3. Spins up a Lua state per file, installs the provium global, and executes the file.
  4. Inside the file, calls like provium:vm("a", "peios"):boot() launch real QEMU processes and hand back userdata wrappers around them.
  5. Operations against those VMs (vm:run, vm:read_file, vm:syscall) are dispatched over vsock to the provium-agent running in the guest.
  6. At file end (or per-test if provium.reset_between_tests = true), the resource graph is walked in reverse-dependency order and every stream, process, file, worker, VM, and bridge is closed.

The whole loop is observable: every VM spawn, every file dispatched, every fixture cache hit, every test pass/fail, every pool-state snapshot becomes an event on the wire.

What's not here #

  • No browser / GUI testing. Provium drives guests through agent ops, not graphical interfaces.
  • No Windows guests. The agent is Linux-only in v1.
  • No live cluster orchestration. Provium is a test harness, not Terraform.
  • No mock VMM. Tests run against real KVM. The --vmm local mode exists for ad-hoc dev runs without KVM but does not boot a kernel — it just gives the host bindings something to dispatch against.

Quick start

Provium / Getting started

This guide walks through the minimum setup needed to run one Provium test against a real VM. It assumes you have KVM, QEMU, iproute2, and nftables installed; see project structure for prerequisites detail.

Install #

Provium is a Cargo workspace. Build the host binary:

cd provium
cargo build --release --bin provium

The binary lands at target/release/provium. Optionally, install it onto your PATH:

cargo install --path provium-host --bin provium

Verify:

provium --help

Pre-flight check #

Provium runs a startup pre-flight on every invocation that can reach a VM (only lsp-setup and prepare skip it). There is no standalone pre-flight command, so any cheap run — provium list, say — exercises it before you start writing tests. To skip the checks (useful in containers without KVM):

provium tests/ --no-preflight

The pre-flight checks for:

CheckRecovery
/dev/kvm exists and is openablesudo modprobe kvm-intel (or kvm-amd); add yourself to the kvm group
/dev/vhost-vsock existssudo modprobe vhost_vsock
ip and tc on PATHapt install iproute2 / pacman -S iproute2
nft on PATHapt install nftables / pacman -S nftables
qemu-system-x86_64 on PATHapt install qemu-system-x86_64 / pacman -S qemu-base
Effective CAP_NET_ADMINsudo setcap cap_net_admin,cap_net_raw=eip $(which provium)

Provide a kernel and initrd #

Provium boots VMs by direct kernel boot (-kernel / -initrd). You need:

  • A bzImage-format kernel.
  • An initramfs with a working /init. Almost any initrd works — a vanilla distro initramfs, a buildroot image, a from-scratch cpio with just /init and busybox.

Provium injects its own agent into your initrd at launch (concatenated cpio, content-hash cached) and chains to your /init. You don't need to bake provium-agent into your image. To opt out (e.g. when your image already bundles an agent), set inject_agent = false on the profile — see provium.toml reference.

For Peios, the kernel + initrd are built by the Peios image-build pipeline. For ad-hoc use, see the project structure section.

Create provium.toml #

Provium loads ./provium.toml (override with --config). The minimum useful config declares one profile:

[provium]
roots = ["tests"]

[profiles.peios]
kernel  = "/path/to/bzImage"
initrd  = "/path/to/provium-initrd.cpio.gz"
cmdline = "console=ttyS0 quiet"

[profiles.<name>] is the dictionary provium:vm("name", "<profile>") looks up. The roots setting is the list of directories scanned for *.test.lua and *.fixture.lua files; it also controls where require("helper") resolves.

See provium.toml reference for every field.

Write your first test #

Create tests/smoke.test.lua:

test("guest boots and runs commands", function(t)
    local vm = provium:vm("smoke", "peios"):boot()
    local r = vm:run("uname -a")
    r:assert_ok()
    t:assert(r.stdout:find("Linux"), "uname should report Linux: " .. r.stdout)
end)

test("filesystem ops round-trip", function(t)
    local vm = provium:vm("smoke", "peios"):boot()
    vm:write_file("/tmp/hello", "world")
    t:assert_eq(vm:read_file("/tmp/hello"), "world")
end)

Two things to know:

  • Each test(...) body runs in its own scope. The two provium:vm("smoke", "peios") calls above each create a fresh VM in their respective test scope; the "smoke" name in test 1 and test 2 are independent and don't collide. Each VM is silently shut down at end of test.
  • To share a VM across tests, declare it at file top-level (local vm = provium:vm("v", "peios"):boot()) and either capture the userdata as a Lua local or look it up from inside tests via the 1-arg form provium:vm("v").

Run it #

provium tests/

You should see:

PASS tests/smoke.test.lua (2 passed, 0 failed, 0 skipped)

1 file(s); 2 passed, 0 failed, 0 skipped; 3.42s

Useful flags for the development loop:

  • provium tests/ --watch — re-runs on file change (poll-based, 500 ms).
  • provium tests/smoke.test.lua — run a single file.
  • provium tests/ --filter smoke — substring match against the relative path.
  • provium tests/ --rerun-failed — re-run only files that failed last time.
  • provium tests/ -v — show passing tests too.
  • provium tests/ --json — line-delimited JSON output, one object per file.

See the CLI reference for every flag.

Add an assertion that should fail #

Sanity-check the failure path:

test("fails on purpose", function(t)
    t:assert_eq(1, 2, "1 must equal 2")
end)
FAIL tests/smoke.test.lua (2 passed, 1 failed, 0 skipped)
    FAIL fails on purpose
        1 must equal 2: 1 ~= 2

Provium's exit code is 0 when every file passed (or skipped), 1 when one or more files had a failure or timed out, and 2 for internal errors (config load failure, pre-flight failure, etc.).

What's next #

  • Read project structure to understand how provium.toml, tests/, fixtures, and the cache directory all fit together.
  • Read the test-framework reference for everything the test() and t API expose.
  • Read VMs and profiles for the full set of provium:vm(...) options.
  • Skim the CLI reference to see what's available for the running side.

Project structure

Provium / Getting started

Provium has no scaffolding command. A project is whatever directory provium.toml lives in. This page documents what each piece does.

Directory layout #

my-project/
  provium.toml                  # Required. Profiles + scan roots.
  tests/                        # One of the directories listed in `roots`.
    smoke.test.lua              # Run by `provium`.
    networking/
      partition.test.lua
      uplink.test.lua
    helpers/
      assert_pingable.lua       # `require("helpers.assert_pingable")`
    fixtures/
      booted-pair.fixture.lua   # Built once, restored per test.
  ~/.cache/provium/
    fixtures/                   # Per-user fixture cache.
      <hash>.snap               # Single-VM fixture snapshots.
      <hash>.lab/               # Multi-VM lab-fixture directories.
    rerun.json                  # `--rerun-failed` state.

The only required file is provium.toml. Tests, helpers, and fixtures all live under whatever directory you list in [provium].roots.

provium.toml #

The configuration file at the project root. Two sections:

[provium]
roots = ["tests", "vendor/upstream-tests"]
cache_dir       = "/var/cache/provium/fixtures"   # optional
cache_max_size  = "20G"                            # optional

[profiles.peios]
kernel   = "/build/peios/bzImage"
initrd   = "/build/peios/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"
guest_os = "peios"

[profiles.peios-debug]
kernel   = "/build/peios-debug/bzImage"
initrd   = "/build/peios-debug/initrd.cpio.gz"
cmdline  = "console=ttyS0 debug loglevel=7"

[profiles.<name>] blocks declare named (kernel, initrd, cmdline) tuples. Test code looks them up by name: provium:vm("a", "peios") boots the VM using [profiles.peios]. You can have any number of profiles; tests pick whichever they need.

[provium].roots is the list of directories scanned for *.test.lua files. It also controls where require("helper.module") resolves; helpers/foo.lua under any root is reachable as require("helpers.foo").

cache_dir and cache_max_size configure where fixture snapshots are stored and how big the cache is allowed to grow. Defaults: ~/.cache/provium/fixtures/, 20 GiB. See provium.toml reference for full detail.

Test files #

Files matching *.test.lua under any roots directory are picked up by provium. Each file is run in a fresh Lua state with a fresh root Lab.

A test file consists of test(name, [meta,] fn) calls. Tests run in declaration order, and each test runs sequentially (one at a time within a file). The harness builds a fresh t context for each test and passes it as the function's first argument.

test("simple", function(t)
    t:assert_eq(1 + 1, 2)
end)

test("with metadata", {tags = {"smoke"}, timeout = "30s"}, function(t)
    t:assert(true)
end)

test("declaratively skipped", {skip = "still figuring out the spec"}, function(t)
    -- never runs
end)

See the test-framework reference for test(), the t context, todo(), and wait_until().

Fixture files #

Files matching *.fixture.lua are not picked up by the test runner directly. Instead, test files reference them by name:

test("uses a pre-booted VM", function(t)
    local vm = provium:vm_fixture("booted-base")
    -- vm is restored from a cached snapshot
end)

When the test runs, Provium:

  1. Locates <root>/booted-base.fixture.lua (under any roots directory).
  2. Hashes the file's source bytes plus every transitive dependency (other fixtures it references via vm_fixture/lab_fixture, every helper it requires, and any host file declared with depends_on_file) plus every profile's kernel and initrd identity and the wire-protocol version into a cache key.
  3. Looks up <cache_dir>/<key>.snap. If present, restores it and hands the test a fresh VM.
  4. If absent, builds the fixture by running its chunk under a build-time Lua state, takes the resulting snapshot, sparse-zstd-compresses it, and installs it into the cache.

A fixture file's chunk must end in a return statement that hands back either a vm:snapshot() (single VM) or a provium:snapshot() (whole lab). Example:

-- booted-base.fixture.lua
local vm = provium:vm("base", "peios"):boot()
vm:run("mkdir -p /srv/state"):assert_ok()
return vm:snapshot()

See fixtures and dependencies for the cache lifecycle, eviction policy, and what triggers a rebuild.

Helper files #

Plain Lua files anywhere under a roots directory are reachable through require. The roots directories are prepended to package.path at file-runner setup, so tests/helpers/assert_pingable.lua can be loaded as:

local pingable = require("helpers.assert_pingable")

Helpers are not detected as tests (they don't end in .test.lua) and not detected as fixtures. Editing a helper invalidates the cache key of every fixture that transitively requires it.

The fixture cache #

~/.cache/provium/fixtures/
  c5e6f8….snap           # Single-VM fixture snapshot (sparse, zstd).
  c5e6f8….lock           # Per-key build lock.
  abc123….lab/           # Lab-fixture directory.
    lab.json             # Per-VM snapshot index.
    base.snap            # Per-VM snapshot.
    extra.snap
  abc123….lab.lock

The cache is per-user by default (~/.cache/provium/fixtures/). Override it system-wide with [provium].cache_dir in provium.toml. The cache is shared across runs and across files within a run — concurrent file runners building the same fixture coordinate via the per-key lock file.

LRU eviction runs at provium startup before tests dispatch: the harness sums file sizes under cache_dir, sorts entries by access time, and deletes the oldest until the total is under cache_max_size.

Inspect or manage the cache from the CLI:

provium fixture list      # Show every cached entry, with sizes.
provium fixture build P   # Force-build the named fixture.
provium fixture rebuild P # Evict and rebuild.
provium fixture clean     # Wipe the cache.
provium fixture stale     # List fixtures whose source hashes don't match any cache entry.

The rerun state file #

After every run that produced at least one failure, Provium writes the canonical paths of the failing files to ~/.cache/provium/rerun.json (override with $PROVIUM_RERUN_STATE). The --rerun-failed flag intersects discovered files against this list, so:

provium tests/ --rerun-failed

re-runs only files that failed last time. A clean run leaves the file alone, so the failed set persists until you fix the failures.

Required external binaries #

BinaryUsed forProvided by
qemu-system-x86_64VMM backendqemu-system-x86 package
ipBridge / TAP / link operationsiproute2
tcLatency / drop-rate / bandwidth qdiscsiproute2
nftPer-bridge partition rules, NAT for uplinknftables
tcpdumpbridge:capture() and nic:capture()tcpdump

Provium's startup pre-flight checks for /dev/kvm, /dev/vhost-vsock, ip, tc, nft, qemu-system-x86_64, and effective CAP_NET_ADMIN. Missing pieces fail with an actionable message before any test runs. The exception is tcpdump: the pre-flight does not check for it, because it is only needed once a test calls capture().

What lives outside the project tree #

  • The fixture cache (default: ~/.cache/provium/fixtures/).
  • The rerun-state file (default: ~/.cache/provium/rerun.json).
  • Optional --save-events / --events-socket paths (your choice).
  • The kernel and initrd files referenced by [profiles.<name>].

Everything else — tests, fixtures, helpers, configuration — lives inside the project directory and is the project author's responsibility to keep version-controlled.

Writing tests with test() and t

Provium / Writing tests

A Provium test file is a sequence of test(...) calls. The harness runs them in declaration order, gives each one a fresh t context, and records pass / fail / skip. This page covers the patterns for getting the most out of that loop.

The exhaustive reference is on test framework and meta tags.

Anatomy of a test file #

-- tests/file_handles.test.lua

provium.timeout = "10s"        -- file-default per-test timeout

test("baseline I/O round-trips", function(t)
    local vm = provium:vm("v", "peios"):boot()
    vm:write_file("/tmp/data", "hello")
    t:assert_eq(vm:read_file("/tmp/data"), "hello")
end)

test("permission bits stick on create", {tags = {"perms"}}, function(t)
    local vm = provium:vm("v", "peios"):boot()
    local h = vm:open_file("/tmp/secret", {write=true, create=true, perm=0x180})  -- 0o600
    h:close()
    t:assert_eq(vm:stat("/tmp/secret").perm, 0x180)
end)

Two things to know up front:

  1. Each test() body runs in its own scope. The provium:vm("v", …) calls in the two tests above each create their own VM in their test scope; they're independent and do not share state. The VMs are silently shut down at test end. To share a VM across tests, declare it at file scope (top-level local vm = provium:vm("v", "peios"):boot()) and look it up by name (provium:vm("v")) or capture the userdata as a Lua local.
  2. Each test gets a fresh t context. t.name and t.meta are per-test; assertions and skips are scoped to the running test.

For the full scoping rules (lookup fallthrough, shadow detection, fixtures), see labs and scope.

Naming tests #

Test names must be unique within a file. The harness raises at registration time on a duplicate:

test: duplicate name `boots` in this file

Pick names that read like the assertion, not the implementation. "boots and runs uname" is better than "test_boot_uname" — the renderer prefixes them with status (PASS, FAIL) and indents under the file path, so the name reads as a sentence.

Assertions #

The t context exposes:

AssertionUse when
t:assert(cond, msg?)You're checking any boolean condition.
t:assert_eq(a, b, msg?)Two values must be equal. The error message includes both values.
t:assert_neq(a, b, msg?)Two values must be unequal.
t:assert_contains(haystack, needle, msg?)A string must appear inside another string. Both args must be strings.
t:assert_raises(fn, msg?)Calling fn must raise; returns the error value.
t:fail(msg?)You've decided the test failed for reasons that don't fit an assertion.

Every assertion that fires raises (with error(..., 2) so the call site, not the assertion implementation, is in the message). The harness catches the raise and records the test as Failed.

Even when you wrap a body in pcall and swallow the error, the harness still detects the failure — an assertion marks the test failed before it raises, and that mark is sticky:

test("expected to detect a failure", function(t)
    local ok = pcall(function() t:assert(false, "should fail") end)
    -- ok is false (we caught the assert), but the test still
    -- reports Failed: the failure was recorded before the raise.
end)

Use t:assert_raises(fn) if you want the inverse — "this should raise":

test("write to a closed handle raises", function(t)
    local h = vm:open_file("/tmp/x", {write=true, create=true})
    h:close()
    local err = t:assert_raises(function() h:write("oops") end)
    t:assert_contains(tostring(err), "closed")
end)

Skipping #

Three ways to skip:

Inline (t:skip(reason)) #

test("only on btrfs", function(t)
    if not is_btrfs(vm) then t:skip("not a btrfs root") end
    -- … rest of test body …
end)

t:skip raises an internal sentinel that the harness treats as Skipped. Useful when the skip condition can only be evaluated at runtime.

Declarative ({skip = …}) #

test("not implemented yet", {skip = true}, function(t) … end)
test("waiting on RFC", {skip = "see issue #42"}, function(t) … end)

The body never runs. Cleaner than inline when the test is permanently disabled or pending an unrelated change.

File-scope (todo("reason")) #

todo("waiting on the spec")
test("a", function(t) … end)
test("b", function(t) … end)

Every registered test is reported Skipped with the given reason. Use this when an entire test file is non-applicable temporarily — do not delete the tests, just mark the file pending.

Logging diagnostic data #

t:log(msg) appends to a per-test log array. The harness includes the log in the file outcome and the TestPassed / TestFailed events — useful for diagnostic context when something goes wrong:

test("retries until success", function(t)
    for i = 1, 5 do
        local r = vm:run("flaky-command")
        t:log("attempt " .. i .. " -> exit " .. r.exit_code)
        if r:ok() then return end
    end
    t:fail("flaky-command failed 5 times")
end)

Avoid print in tests — print writes to stdout and gets interleaved with the harness's own output, while t:log is structured and tied to the specific test.

Per-test metadata #

The optional second arg to test(...) is a metadata table. Provium inspects a handful of well-known keys:

KeyPurpose
slow = trueSkip unless --include-slow is passed.
skip = …Declarative skip.
tags = {...}Tag-based filtering (--tag, --no-tag).
timeout = "30s"Per-test wall-clock timeout.
spec = "PSD-…"Spec linkage for provium-coverage.

Anything else passes through to event consumers. See meta tags for the full reference.

test("federation handshake", {
    tags    = {"federation", "slow"},
    timeout = "5m",
    spec    = "PSD-FEDERATION §3.1",
}, function(t) … end)

Polling with wait_until #

wait_until(predicate, opts?) calls predicate repeatedly until it returns truthy. Use it for guest-side conditions that don't have a stream interface:

test("nginx comes up", function(t)
    vm:run("systemctl start nginx"):assert_ok()
    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"})
    t:assert(pid)
end)

For things that produce a stream (logs, console output, captured stdout), prefer :expect on the stream over wait_until — it gets event-driven semantics and much tighter feedback.

File-default timeouts #

Set provium.timeout at file scope to put a default on every test:

provium.timeout = "30s"

test("…", function(t) … end)        -- 30s
test("…", {timeout = "5m"}, function(t) … end)  -- per-test wins; 5m

Per-test meta.timeout always wins over the file default. When a per-test timeout fires, the watchdog tears down the entire file's lab — there's no finer-grained cancellation in v1. See time and timeouts for the scope-limitation note.

Reset-between-tests #

Set provium.reset_between_tests = true at file scope to take a baseline snapshot after the file's top-level chunk and restore it between every test:

provium.reset_between_tests = true

local vm = provium:vm("v", "peios"):boot()
vm:write_file("/etc/marker", "baseline")  -- snapshot baseline includes this

test("a", function(t)
    vm:run("rm /etc/marker"):assert_ok()
    -- After this test, the lab is restored: the marker is back.
end)

test("b", function(t)
    vm:run("test -f /etc/marker"):assert_ok()  -- still present, baseline restored
end)

Mutually exclusive with file-scope open streams. Opening a tail_file, console:read, bridge:capture, etc. at top-level errors at chunk load with the offending stream's creation site named. Move stream opens into test() bodies.

Common patterns #

One-test-per-VM #

When tests are independent and a fresh VM per test is acceptable, opt into reset-between-tests:

provium.reset_between_tests = true

test("a", function(t)
    local vm = provium:vm("v", "peios"):boot()
    -- … tests against fresh state …
end)

One-VM-many-tests, ordered #

When tests build on each other, leave reset_between_tests off and let state accumulate:

local vm = provium:vm("v", "peios"):boot()

test("create user", function(t)
    vm:run("useradd alice"):assert_ok()
end)

test("user appears in /etc/passwd", function(t)
    t:assert(vm:read_file("/etc/passwd"):find("^alice:"))
end)

Order matters here. If you delete the first test, the second will fail; that's intentional.

Fixture-backed setup #

When the setup is expensive (install packages, fetch data, build a config), put it in a *.fixture.lua file and call provium:vm_fixture("name"):

-- tests/fixtures/corpus.fixture.lua
local vm = provium:vm("base", "peios"):boot()
vm:run("seq 1000000 > /srv/corpus"):assert_ok()
return vm:snapshot()

-- tests/uses-corpus.test.lua
test("corpus is pre-built", function(t)
    local vm = provium:vm_fixture("fixtures/corpus")
    vm:run("test -s /srv/corpus"):assert_ok()
end)

The fixture is built once, cached on disk, and restored per test that asks for it. See fixtures and dependencies for the cache lifecycle.

See also #

  • Test framework reference — every method on t, plus wait_until and todo.
  • Meta tags reference — every well-known meta key.
  • Labs and scope — lab:claim, lab:barrier, reset_between_tests.

VMs and profiles

Provium / Writing tests

Every Provium test ultimately drives one or more guest VMs. This page covers the lifecycle: creating a VM, picking its profile, controlling boot, taking snapshots, and tearing down.

The exhaustive reference is on VM.

Creating a VM #

local vm = provium:vm("name", "profile")

The first arg is the VM's name (per-scope unique). The second arg is a profile name from provium.toml. The VM is in Created state until you call :boot().

Two forms. provium:vm(name, profile) creates a VM in the current scope. provium:vm(name) looks one up by name, walking from the current scope to the file root. Inside a test() body, the current scope is the per-test scope; at file top-level, it's the file root. See labs and scope for the full rules.

Optional third arg is a sizing table — the two keys the scheduler and QEMU need before boot:

local vm = provium:vm("v", "peios", {memory = "2G", cpus = 4})

Everything else about a boot — kernel command line, determinism seeds, injected files — is passed to vm:boot(opts) instead:

vm:boot({
    kernel_cmdline = "console=ttyS0 quiet earlyprintk=ttyS0",
    rng_seed = 0xdeadbeef,
    initial_time = 1700000000,
    files = {
        {path = "/etc/hostname", content = "v"},
        {path = "/root/.ssh/authorized_keys", content = pubkey},
    },
})

The split is deliberate: memory and cpus size the VM, and the scheduler needs them when the VM is declared; the boot opts shape one particular boot and merge per field into the VM's pending boot options.

Booting #

local vm = provium:vm("v", "peios"):boot()

:boot() returns self so you can chain. The VM is in Booted state on return. A vm_spawned event fires as soon as the agent has handshaken; consumers see it before :boot() returns.

For multi-VM tests, you can boot each individually:

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

Or batch-boot via the lab:

provium:vm("a", "peios")
provium:vm("b", "peios")
provium:boot()                -- boots both

The batch form is mainly useful when you've declared a topology in a fixture builder and want to bring it up atomically.

Profiles #

A profile is a [profiles.<name>] block in provium.toml:

[profiles.peios]
kernel   = "/build/peios/bzImage"
initrd   = "/build/peios/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"
guest_os = "peios"

Each profile names a (kernel, initrd, cmdline) tuple. A test picks which profile to use by name:

provium:vm("a", "peios")           -- uses [profiles.peios]
provium:vm("a", "peios-debug")     -- uses [profiles.peios-debug]

You can have any number of profiles. Common patterns:

PatternProfiles
Test against multiple kernel versionspeios-stable, peios-mainline
Compare optimised and debug buildspeios, peios-debug
Test pre/post a feature flagpeios-prefeatx, peios-postfeatx

Multi-profile fixtures invalidate every cached fixture when any profile's kernel or initrd identifier changes — see fixtures and dependencies.

Lifecycle methods #

A VM's lifecycle: :boot() takes it from Created to Booted; :pause() / :resume() toggle between Booted and Paused; :reset() warm-reboots (still Booted); and :shutdown() or :power_button() end in Shutdown. The full state machine and per-method semantics are in the VM reference.

Operations against a VM in the wrong state error cleanly with a hint: VM not booted on a Created VM, VM is paused; use resume() on a Paused one, VM is shutdown; create a new one after shutdown.

You typically don't call :shutdown() explicitly. The harness's resource-graph walker tears every VM down at the appropriate scope boundary: test-scope VMs at the end of their test() body, file-scope VMs at file end. (reset_between_tests = true snapshots and restores instead — see labs and scope.)

Boot opts in detail #

The opts you'll use most often are memory and cpus (sizing, at creation), kernel_cmdline (per-VM loglevel, nokaslr, etc. — replaces the profile's cmdline; at boot), and files (inject config before init runs; at boot):

local vm = provium:vm("v", "peios", {memory = "1G", cpus = 2})
vm:boot({
    files = {
        {path = "/etc/test.conf", content = "key=value\n"},
    },
})

This is a subset — the full boot-opts table (types, defaults, rng_seed, initial_time) is in the VM reference.

Querying VM state #

The accessors you'll use most are vm:state() (returns "created", "booted", "paused", "shutdown", or "dead") and vm:is_quiescent() (true when there are no in-flight ops, open files, or open streams). The full accessor list — name, profile, cid, open_file_count, open_stream_count — is in the VM reference.

is_quiescent and the open-count accessors are useful for snapshot precondition asserts:

test("snapshot is taken at quiescence", function(t)
    local vm = provium:vm("v", "peios"):boot()
    vm:run("…")
    t:assert(vm:is_quiescent(), "VM must be quiescent before snapshot")
    local s = vm:snapshot()
    t:assert(s:size() > 0)
end)

Snapshots #

local s = vm:snapshot()              -- writes to a tempfile
local s = vm:snapshot("/tmp/x.snap") -- writes to that path

Returns a Snapshot userdata wrapping the path. Use it to:

  • Restore later in the same test: vm:shutdown(); vm:restore(s).
  • Inspect size: s:size().
  • Delete: s:delete() (idempotent).

The snapshot file is what fixture builders return. If the snapshot fails because of an open stream, the error names the stream's creation site — close streams before snapshotting:

test("snapshot needs no open streams", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local stream = vm:tail_file("/var/log/messages")
    -- vm:snapshot() would error here; close first.
    stream:close()
    local s = vm:snapshot()
end)

Restoring #

Restore from a Snapshot userdata or a bare path string:

vm:shutdown()
vm:restore(s)            -- from snapshot userdata
vm:restore("/tmp/x.snap") -- from path

The VM moves through Shutdown → Created → Booted (the restored state is already Booted). Restoring requires the VM to be in Created or Shutdown first.

Determinism patterns #

For tests that depend on randomness or wall-clock time, fix both at boot:

local vm = provium:vm("v", "peios")
vm:boot({
    rng_seed = 0xdeadbeef,
    initial_time = 1700000000,
})

After boot, you can move time forward (or backward) with vm:clock():advance(N) — see Clock reference.

Pausing for inspection #

vm:pause() freezes the guest's vCPUs. Useful for:

  • Time-sensitive tests where you need to read multiple bits of state without races.
  • Snapshotting (the snapshot path will pause anyway, but explicit pause makes the test's intent clear).
vm:pause()
local before = vm:read_file("/proc/loadavg")
vm:resume()

Reset and power-button #

vm:reset() warm-reboots the guest — same VM, same RAM image initially, then init re-runs. Stays in Booted.

vm:power_button() sends ACPI power-button. The guest's init handles it as a graceful shutdown signal (typically: stop services, sync filesystems, kernel halts). Ends in Shutdown.

test("guest survives reset", function(t)
    local vm = provium:vm("v", "peios"):boot()
    vm:write_file("/tmp/before", "1")
    vm:reset()
    -- /tmp is tmpfs in the test profile; vm:reset() is a warm
    -- reboot, so /tmp is fresh. Check that the test's expectation
    -- matches what the profile actually does.
    local r = vm:run("test ! -f /tmp/before")
    r:assert_ok()
end)

Multi-VM topologies #

The two-VM pattern is the workhorse of networking tests:

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")

test("a can reach b", function(t)
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

For larger topologies, use sub-labs to keep names organised — see labs and scope.

See also #

  • VM reference — every method, every option.
  • Snapshot reference — snapshot/lab-snapshot userdata.
  • provium.toml reference — profile configuration.
  • Bridges and impairments — wiring VMs together.

Running commands inside the guest

Provium / Writing tests

Provium gives you several ways to run commands inside a guest. This page is the practical guide; the canonical method reference is on VM.

Sync exec: vm:run #

The default. Returns a RunResult with exit_code, stdout, stderr, status, signal, timed_out.

local r = vm:run("echo hello")
r:assert_ok()
t:assert_eq(r.stdout, "hello\n")

Two call shapes:

Shell form: vm:run(string) #

vm:run("echo hello | tr a-z A-Z")
vm:run("ls /etc/*.conf | head -3")

The string is run through /bin/sh -c "<string>" so shell metacharacters work. Convenient for one-liners; quote-handling is the shell's problem.

Direct exec: vm:run(cmd, {args, ...}) or vm:run(cmd, {arr}) #

vm:run("echo", {args = {"hello"}})            -- canonical
vm:run("echo", {"hello"})                     -- legacy bare-array form

No shell. The first arg is the executable; positional args go in args (or as a bare array when no opts keys are present). Use this when:

  • The args contain shell metacharacters you don't want interpreted.
  • You don't want a shell process in your tree (PID, signal handling, etc.).
  • You're passing user-controlled data that you'd otherwise have to quote.

The detection between the two table forms is by recognised keys: the presence of any of env, env_clear, cwd, stdin, timeout, timeout_ms, or args selects the opts form; a table without any of them is treated as the legacy direct-args array.

Environment, cwd, stdin #

vm:run("printenv FOO", {env = {FOO = "bar"}})
vm:run("ls", {cwd = "/etc"})
vm:run("cat", {stdin = "hello\n"})

Combine freely:

local r = vm:run("python3 -c 'import os, sys; print(os.environ[\"X\"], file=sys.stderr); print(sys.stdin.read())'", {
    env  = {X = "from-env"},
    stdin = "from-stdin\n",
})
t:assert_eq(r.stderr, "from-env\n")
t:assert_eq(r.stdout, "from-stdin\n")

env_clear = true makes the guest see ONLY the keys you supplied:

vm:run("env", {env = {PATH = "/usr/bin"}, env_clear = true})

Without env_clear, your env merges with the agent's environment.

Timeouts #

Two equivalent keys: timeout_ms (int, milliseconds) and timeout (number seconds, or string with suffix).

vm:run("sleep 100", {timeout_ms = 500})        -- TimedOut
vm:run("sleep 100", {timeout = 0.5})           -- Same
vm:run("sleep 100", {timeout = "500ms"})       -- Same
vm:run("sleep 100", {timeout = "5s"})          -- 5s
vm:run("sleep 100", {timeout = "1m"})          -- 1 minute

When the timeout fires, the agent kills the process and returns a RunResult with status = "timed_out", timed_out = true, and exit_code = -2. Use r.timed_out to disambiguate from a clean exit with code -2.

local r = vm:run("flaky", {timeout = "2s"})
if r.timed_out then
    t:fail("flaky timed out")
elseif not r:ok() then
    t:fail("flaky failed: " .. r.stderr)
end

RunResult fields and helpers #

Most tests only need three things from a RunResult:

local r = vm:run("…")

r:assert_ok() -- raise if not ok; message includes status, stdout, stderr
r.stdout      -- captured stdout (bytes)
r:ok()        -- shorthand for exit_code == 0

When a command can fail in more than one way, check r.status ("exited" / "signalled" / "timed_out") rather than pattern-matching exit_code — the sentinel exit codes and the signal field are documented in the RunResult reference.

Async exec: vm:run_async #

Returns a Process userdata immediately. The agent does NOT auto-kill; you control the lifetime.

local proc = vm:run_async("python3", {args = {"server.py"}})
-- … do other things …
proc:kill("term")
local r = proc:wait("2s")

The opts shape mirrors vm:run's, except that passing timeout / timeout_ms is rejected — use proc:wait(timeout) instead. (Silently honouring timeout here would be a footgun: the agent doesn't auto-kill, so the timeout would do nothing.)

Stdin pipe #

local proc = vm:run_async("cat")
proc:stdin_write("hello\n")
proc:stdin_write("world\n")
proc:close_stdin()
local r = proc:wait("5s")
t:assert_eq(r.stdout, "hello\nworld\n")

Streaming stdout / stderr #

local proc = vm:run_async("server")
local out  = proc:stdout_stream()
out:expect("listening on port 8080", "10s")
-- now exercise the server

See streams and tails for the full stream API.

Signals #

proc:kill()                  -- defaults to SIGTERM
proc:kill(9)                 -- SIGKILL by number
proc:kill("kill")            -- by name
proc:kill("usr1")            -- SIGUSR1
proc:signal("usr2")          -- alias for kill(); reads better for non-fatal signals

Signals are accepted by friendly name (term, kill, usr1, …), with a sig prefix (sigterm), or as a bare integer. The full recognised-name list is in the Process reference.

Inspecting the process #

proc:pid()       -- live kernel PID inside the guest
proc:handle()    -- opaque agent-side handle id
proc:status()    -- non-blocking poll

pid() calls into the agent every time. handle() is a stable in-memory id that never changes.

Waiting #

proc:wait()              -- wait forever
proc:wait("5s")          -- wait at most 5 seconds
proc:wait(0.5)           -- 500 ms

Don't pass 0 — the harness rejects it and points you at proc:status() for non-blocking polling. The Process reference explains why a literal-zero timeout is a footgun.

Workers: parallel commands in the same VM #

vm:spawn_worker() returns a Worker — a sub-agent connection. Useful for driving the same VM from multiple threads of control:

local w1 = vm:spawn_worker()
local w2 = vm:spawn_worker()

-- Two writers in parallel.
local p1 = w1:run_async("dd if=/dev/urandom of=/tmp/a bs=1M count=8")
local p2 = w2:run_async("dd if=/dev/urandom of=/tmp/b bs=1M count=8")

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

Workers expose the same surface as the VM (run, run_async, open_file, syscall, kill, join, close). Files and processes allocated under a worker live in the worker's namespace; cleanup is per-worker.

For coordination between workers' guest processes, use a guest-side primitive (file on a shared mount, fifo, network message). lab:barrier(name, count, timeout?) is a host-side rendezvous and can't be reached from inside a guest — see Labs and scope — Barriers.

Common patterns #

"Did the fixture build correctly?" #

test("fixture data is present", function(t)
    local vm = provium:vm_fixture("base")
    local r = vm:run("wc -c < /srv/corpus")
    r:assert_ok()
    t:assert(tonumber(r.stdout) > 0)
end)

"Run a server, hit it, tear down" #

test("nginx serves index", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local proc = vm:run_async("nginx", {args = {"-g", "daemon off;"}})
    local out  = proc:stdout_stream()
    -- nginx writes nothing to stdout in this mode; use wait_until on
    -- the listening port.
    wait_until(function()
        return vm:run("ss -ltn | grep :80"):ok()
    end, {timeout = "10s", desc = "nginx listening on :80"})
    local r = vm:run("curl -s http://localhost/")
    r:assert_ok()
    t:assert(r.stdout:find("Welcome to nginx"))
end)

"Compose multiple ops in one round trip" #

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[3].ok == "1"
-- results[4].err contains "No such file"

vm:batch is one wire round-trip for N ops. Useful when latency dominates (many small ops back-to-back) or when you want to inspect the ordered outcome.

See also #

  • VM reference — every method.
  • Process reference — async-process surface.
  • Worker reference — concurrent agent connections.
  • Streams and tails — proc:stdout_stream / proc:stderr_stream.

Files and handles

Provium / Writing tests

Provium gives you two layers for guest-side file I/O. Most tests use the high-level vm:read_file / vm:write_file / vm:stat calls; tests that need cursor control, partial reads, or POSIX semantics drop to vm:open_file and the File userdata.

High-level: read_file / write_file / stat #

These are one-shot operations. Each is a single round-trip to the agent.

vm:write_file("/etc/test.conf", "key=value\n")
local body = vm:read_file("/etc/test.conf")
local meta = vm:stat("/etc/test.conf")

vm:read_file(path) #

Returns the entire file as a Lua string. Errors on agent-side read failure (ENOENT, EACCES, etc.).

local content = vm:read_file("/etc/hostname")

vm:write_file(path, data) #

Replaces the file's contents. Creates if absent. The mode bits follow agent defaults — use vm:open_file if you need explicit perm.

vm:write_file("/tmp/data", "binary\0bytes")

vm:push_file(host_path, guest_path, opts?) #

Read a file on the host and write its bytes to guest_path in the guest. Equivalent to reading the host file in Lua and passing the bytes to vm:write_file, but with one important extra: inside a fixture, the host file is folded into the fixture's cache key automatically, so rebuilding the host artifact (e.g. a binary you're testing) invalidates the snapshot.

vm:push_file("../peios-uapi/target/release/peios-uapi", "/usr/bin/peios-uapi")

Relative host_path is resolved against the fixture (or helper) file's directory, not the cwd at invocation time. Pass {auto_dep = false} to skip the auto-fold for a single call (e.g. a large test corpus you don't want included in the key):

vm:push_file("../big-corpus.tar", "/data.tar", {auto_dep = false})

The auto-fold relies on static scanning of the call site, so non-literal host paths (variables, concatenation) are NOT tracked. Use lab:depends_on_file with a literal string to declare them explicitly. See Fixtures and dependencies — External host-file deps for the full model.

Mode bits follow write_file's agent defaults — vm:run("chmod +x …") after the push if you need executable bits.

vm:stat(path) #

Returns a table:

local m = vm:stat("/etc/test.conf")
print(m.size)        -- 10
print(m.perm)        -- POSIX mode bits
print(m.entry_type)  -- "file", "directory", "symlink", …

The full field table (including the complete entry_type value list) is in the VM reference. Two fields carry the modification time: use mtime_ns for exact comparisons; use mtime (float seconds) when "around what o'clock" is enough.

perm is in the POSIX range. Lua 5.4 doesn't accept 0o… literals — use decimal or hex (0x180 for 0o600). 4095 is 0o7777, the upper bound.

vm:listdir(path) #

Returns an array of {name, entry_type} tables:

for _, e in ipairs(vm:listdir("/etc")) do
    print(e.name, e.entry_type)
end

vm:mkdir(path, opts?) #

Create a directory:

vm:mkdir("/tmp/d")
vm:mkdir("/tmp/d/e/f", {parents = true})
vm:mkdir("/tmp/private", {perm = 0x1c0})  -- 0o700

vm:unlink(path) #

Remove a file or empty directory. Errors on non-empty directory (use vm:run("rm -rf …") for that).

vm:rename(from, to) #

Atomic rename within the guest filesystem.

Low-level: vm:open_file #

Returns a File userdata that you can read, write, seek, and close.

local h = vm:open_file("/tmp/data", {read=true, write=true, create=true, truncate=true})
h:write("hello")
h:seek(0)
local s = h:read(5)
h:close()

Mode table #

At least one of read, write, append must be true; an empty mode table errors at open time. The flags you'll combine most often are read / write, create (create if absent), truncate, and perm (POSIX mode for newly-created files):

-- Write-only, create, truncate, mode 0o600.
local h = vm:open_file("/etc/secret", {write=true, create=true, truncate=true, perm=0x180})

The full mode-table reference (including append and exclusive / O_EXCL) is on vm:open_file.

Reading #

local h = vm:open_file("/etc/hostname", {read=true})

local first = h:read(64)         -- up to 64 bytes
local rest  = h:read_all()       -- drain to EOF
h:close()

h:read(n) returns up to n bytes; at EOF it returns the empty string "", never an error — so a read loop terminates on chunk == "". h:read_all() drains from the cursor to EOF in one call. See EOF semantics in the File reference.

Writing #

local h = vm:open_file("/tmp/data", {write=true, create=true, truncate=true})
local n = h:write("hello world")
-- n is 11 (bytes actually written, may be less than #data on partial write)
h:close()

Seek and tell #

local h = vm:open_file("/tmp/x", {read=true})
h:seek(10, "set")             -- absolute offset 10
h:seek(5, "cur")              -- relative +5 from current
h:seek(-1, "end")             -- 1 byte before EOF
h:tell()                      -- current offset

tell() reports the authoritative agent-side position, not a host-side cache — the File reference explains how.

Closing #

h:close()
h:close()  -- second close is fine; idempotent
h:read(1)  -- raises "file is closed"

The harness's resource walker auto-closes files at scope end via the _provium_close_test_scope hook, so you can usually omit explicit closes. Closing manually is good practice when the file's lifetime is bounded by a clear point in the test.

Raw fd #

local fd = h:fd()              -- u64 handle id
vm:ioctl(fd, MY_IOCTL_NUM)

fd() returns 0 on a closed file, otherwise the handle's u64 value. Useful for vm:ioctl(fd, …) and vm:syscall(…) invocations that take a file descriptor.

Tailing files #

Two ways to tail:

vm:tail_file(path, opts?) #

Subscribe to bytes appended to a file. Returns a Tail.

local stream = vm:tail_file("/var/log/messages")
vm:run("logger 'hello'"):assert_ok()
local line = stream:read_until("\n", "5s")
t:assert(line:find("hello"))

opts.start controls the starting position — "end" (the default: only bytes appended after the call), "beginning" (replay from byte 0, then continue tailing), or a byte offset. Negative-offset and float handling are in the VM reference.

file:tail_stream() #

Open a tail rooted at the file handle's current cursor:

local h = vm:open_file("/var/log/messages", {read=true})
h:seek(0, "end")
local stream = h:tail_stream()
-- stream now subscribes from current EOF onwards

Useful when you've already seek-d to a known position.

vm:fd_stream(fd_or_file) #

Open a tail against an existing file handle (by fd integer or by the File userdata directly):

local h = vm:open_file("/dev/console", {read=true})
local stream = vm:fd_stream(h)

Common patterns #

Writing then reading back #

test("config round-trips", function(t)
    local body = "key1=value1\nkey2=value2\n"
    vm:write_file("/etc/test.conf", body)
    t:assert_eq(vm:read_file("/etc/test.conf"), body)
    t:assert_eq(vm:stat("/etc/test.conf").size, #body)
end)

Asserting permission bits #

Lua 5.4 has no 0o… literal. Use decimal or hex:

test("private file is 0600", function(t)
    local h = vm:open_file("/etc/secret", {write=true, create=true, perm=0x180})  -- 0o600
    h:close()
    t:assert_eq(vm:stat("/etc/secret").perm, 0x180)
end)

Tailing a log while the test acts #

test("logger writes are persisted", function(t)
    local stream = vm:tail_file("/var/log/messages")
    vm:run("logger 'event 1'"):assert_ok()
    vm:run("logger 'event 2'"):assert_ok()
    stream:expect("event 1", "5s")
    stream:expect("event 2", "5s")
end)

Listing then filtering #

test("/tmp has the expected files", function(t)
    vm:write_file("/tmp/a", "")
    vm:write_file("/tmp/b", "")
    local names = {}
    for _, e in ipairs(vm:listdir("/tmp")) do
        if e.entry_type == "file" then table.insert(names, e.name) end
    end
    table.sort(names)
    t:assert(names[1] == "a" and names[2] == "b")
end)

Files inside a worker #

worker:open_file(path, mode) allocates the file under the worker's namespace. The returned File auto-registers with the test scope:

local w = vm:spawn_worker()
local h = w:open_file("/tmp/from-worker", {write=true, create=true})
h:write("hi")
h:close()

Otherwise the API is identical.

Batch I/O for low latency #

For many small ops back-to-back, batch them in one round trip:

local results = vm:batch(function(b)
    b:write_file("/tmp/a", "1")
    b:write_file("/tmp/b", "2")
    b:write_file("/tmp/c", "3")
    b:read_file("/tmp/a")
    b:stat("/tmp/b")
end)

Each entry in results is {ok=value} or {err=msg}. A failure on one op doesn't short-circuit the rest. See VM batch for the per-op return shape.

See also #

  • VM reference — read_file, write_file, stat, mkdir, listdir, unlink, rename, open_file, tail_file, fd_stream.
  • File handle reference — every method on the File userdata.
  • Streams and tails — patterns for tail streams.

Disks and fault injection

Provium / Writing tests

Provium's disk support has two goals: give the test direct sector-level access to the backing image, and inject faults that exercise the guest's error-handling paths.

The exhaustive method reference is on Disk.

Attaching a disk #

local img = "/tmp/test.img"
-- Pre-create a backing file; Provium does not auto-create.
io.open(img, "w"):write(string.rep("\0", 1024 * 1024)):close()

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

The two opts that matter for fault-injection work are id (names the disk so you can re-look it up via vm:disk(id)) and image (the backing file — required for read_sectors / write_sectors). The full opts table, types, and defaults are in the Disk reference.

Reading and writing sectors #

Sectors are 512 bytes throughout. Offsets and counts are in sectors, not bytes.

-- Read sector 0 (the first 512 bytes).
local sec0 = disk:read_sectors(0, 1)

-- Read 4 sectors starting at sector 100 (bytes 51200..53247).
local block = disk:read_sectors(100, 4)
assert(#block == 4 * 512)

-- Write at sector 50.
disk:write_sectors(50, "hello, sector 50")

Without a backing image, both ops error with a no backing image — disk:with_image required message.

Fault injection #

Three modes:

ModeEffect
eio_readEvery read_sectors short-circuits to EIO.
eio_writeEvery write_sectors short-circuits to EIO.
slowEvery read_sectors / write_sectors sleeps 50 ms before doing the I/O.

Modes are activated with disk:fault_inject(mode) and cleared with disk:clear_faults(). Multiple modes can be active simultaneously — with slow + eio_read both set, the EIO check wins: the read errors immediately, without the 50 ms delay.

Inject EIO #

test("guest sees EIO on read", function(t)
    -- … attach disk with backing image …
    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"))
end)

Inject slow I/O #

slow delays each sector op but doesn't change its outcome. Assert both halves: the op took the hit, and it still worked. The guest Clock gives you a sub-millisecond time source (os.time() only has 1-second resolution):

test("write completes despite slowness", function(t)
    disk:fault_inject("slow")

    local clock = vm:clock()
    local before = clock:get_ns()
    disk:write_sectors(0, "data")            -- sleeps ~50 ms, then writes
    local elapsed_ms = (clock:get_ns() - before) / 1e6

    t:assert(elapsed_ms >= 50, "slow fault should add at least 50 ms")
    t:assert_eq(disk:read_sectors(0, 1):sub(1, 4), "data")  -- the write still landed
end)

The 50 ms delay is fixed per call; it is not currently configurable from test code.

Combine modes #

test("slow EIO is still EIO", function(t)
    disk:fault_inject("slow")
    disk:fault_inject("eio_read")
    local ok, err = pcall(function() disk:read_sectors(0, 1) end)
    t:assert(not ok)  -- slow doesn't change the outcome
    t:assert(tostring(err):find("EIO"))
end)

Concurrent injection during I/O #

The harness re-checks the fault set after the slow-fault sleep AND after the actual I/O completes, so an EIO fault that lands mid-call still takes effect. There is currently no way to exercise this from a test, though: test code runs on a single thread, and workers run guest processes — they can't call disk:fault_inject. Inject faults up-front, act, then clear; the mid-call recheck is defensive insurance in the harness, not a pattern you can drive.

Inspecting state #

local active = disk:active_faults()  -- {"eio_read", "slow"}
disk:is_detached()                   -- false

Clearing #

disk:clear_faults()
local r = disk:read_sectors(0, 1)    -- succeeds

Detaching a disk #

disk:detach()
local ok = pcall(function() disk:read_sectors(0, 1) end)
assert(not ok)  -- "disk is detached"

disk:detach() issues a best-effort QMP device_del against the parent VM and marks the local handle detached; after that, sector ops error. The exact behaviour for disks that were never QMP-added is in the Disk reference.

Common patterns #

"Does the guest retry after a transient EIO?" #

test("guest retries on transient EIO", function(t)
    -- Inject EIO.
    disk:fault_inject("eio_read")

    -- Have the guest start a read in the background.
    local proc = vm:run_async("dd if=/dev/vda of=/tmp/out bs=512 count=1")

    -- Wait briefly, then clear so retry succeeds.
    vm:clock():sleep("100ms")
    disk:clear_faults()

    local r = proc:wait("5s")
    -- If the guest's driver retries, this succeeds. Otherwise dd
    -- returned an I/O error.
    r:assert_ok()
end)

"Does the filesystem remount read-only after EIO?" #

test("filesystem goes read-only after persistent EIO", function(t)
    disk:fault_inject("eio_write")
    vm:run("echo data > /mnt/test/file"):assert_ok()  -- might succeed or fail
    -- Force a sync to surface the write.
    vm:run("sync")
    -- Now check the kernel's view: errors=remount-ro should kick in.
    local r = vm:run("findmnt /mnt/test -o OPTIONS")
    t:assert(r.stdout:find("ro"))
end)

"Does the guest panic on EIO at boot?" #

test("EIO at boot does not panic", function(t)
    disk:fault_inject("eio_read")
    vm:reset()
    -- vm:reset() returns to Booted; check the console for panic strings.
    local log = vm:console():read_log()
    t:assert(not log:find("kernel panic"))
end)

Multiple disks per VM #

local data = vm:attach_disk({id = "vdb", size = 1024 * 1024, image = "/tmp/data.img"})
local logs = vm:attach_disk({id = "vdc", size = 1024 * 1024, image = "/tmp/logs.img"})

-- Inject EIO on data only; logs is unaffected.
data:fault_inject("eio_read")

Use vm:disk(id) to look up an already-attached disk:

local data = vm:disk("vdb")
data:fault_inject("slow")

Caveats #

  • Sector size is fixed at 512 bytes. Tests that need 4 KiB sectors should expect their guest to layer that on top.
  • read_sectors and write_sectors go directly to the host file, not through QEMU's block backend. This means a test that exercises QEMU's block translation (sparse holes, compression, etc.) will not see those layers — the disk userdata is a direct view of the underlying image bytes.
  • disk:size() reports the live image file size when an image is attached. Test code that resizes the underlying file (truncate, fallocate) sees the new size, not the modelled size from attach_disk.

See also #

  • Disk reference — every method, every error message.
  • VM reference — vm:attach_disk, vm:disk.

Bridges and impairments

Provium / Writing tests

Provium's networking is real Linux bridges with TAP attachments. You declare a topology, the harness realises it as the VMs boot, and you can mutate it (partition, impair, capture) at runtime.

The exhaustive method reference is on Bridge and Nic.

Note

Provium wires layer 2 only. A bridge gives each attached VM a NIC on a shared segment; IP addressing, routing, and name resolution inside the guests are the guest image's business. The examples on this page assign addresses with ordinary ip addr commands run in the guests.

Declared vs realised #

Provium tracks resources in two layers: the resource graph records what a test has declared (bridges, attachments, partitions, impairments), and the harness separately realises that graph on the host — bridge interfaces, TAPs, tc qdiscs, nft rules, QMP calls — once the backing pieces exist, typically when an attached VM boots. A call marked graph-state only updates the declared graph without touching the host: it's recorded and visible to inspection methods, but on its own it doesn't change anything real.

Wiring up #

local lan = provium:bridge("lan")
local a   = provium:vm("a", "peios"):boot()
local b   = provium:vm("b", "peios"):boot()
lan:attach({a, b})  -- atomic; either all attach or none do

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")

-- a can reach b.
a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()

bridge:attach takes a single VM or an array (validated atomically — a bad element fails before anything is recorded). The bare-string form and its graph-state-only caveat are in the Bridge reference.

The host-side bridge interface and per-VM TAPs come up the first time any attached VM boots.

Multi-bridge topologies #

local mgmt = provium:bridge("mgmt")
local data = provium:bridge("data")
local a    = provium:vm("a", "peios"):boot()
local b    = provium:vm("b", "peios"):boot()

mgmt:attach({a, b})    -- both VMs on mgmt
data:attach({a, b})    -- both VMs on data too

-- a:nic("mgmt") and a:nic("data") return separate Nic handles.
local mgmt_nic = a:nic("mgmt")
local data_nic = a:nic("data")

Inside the guest, each NIC shows up as a separate interface. The mapping from bridge name to guest-side interface name (eth0, eth1, …) is determined by attachment order sorted by bridge name. For portable tests, prefer vm:nic("mgmt") (by bridge name) over vm:nic("eth0") (by guest-name index).

Partitions #

A partition is a network-layer drop between two specific VMs. Two flavours:

Symmetric #

lan:partition(a, b)        -- A↔B traffic dropped both ways
lan:unpartition(a, b)      -- restore

Symmetric partitions are graph-state — they install drop rules at boot via nft and lift cleanly.

Directional #

lan:partition({from = a, to = b})    -- only A→B dropped; B→A still flows
lan:unpartition({from = a, to = b})

Directional partitions install per-TAP nft rules and require both endpoints to be already attached and booted — otherwise they error with a "call bridge:attach first" pointer. The (more lenient) unpartition semantics are in the Bridge reference.

Whole-bridge #

lan:partition_all()         -- every pair partitioned
lan:restore_all()           -- every partition lifted

Inspect #

if lan:is_partitioned(a, b) then
    -- A↔B is currently partitioned (symmetric or A→B directional)
end

Impairments #

Three knobs: latency, drop rate, bandwidth limit. Each accepts either a scalar (whole-bridge) or a directional table.

Latency #

lan:add_latency(50)                                -- 50 ms one-way to every flow
lan:add_latency({from = a, to = b, ms = 50})       -- 50 ms recorded for A→B

Implemented as netem qdiscs. A directional impairment is recorded per (from, to) pair but realised per source: each VM's outbound pairs collapse into one qdisc chain on its own TAP, taking the worst case across its pairs — so in practice the {from = a, to = b} latency above delays everything leaving a, not just its traffic to b. The endpoint attachment check applies (same as directional partitions).

Drop rate #

lan:drop_rate(10)                                  -- ~10 % loss, both ways
lan:drop_rate({from = a, to = b, p = 10})          -- recorded for A→B

Bandwidth limit #

lan:bandwidth_limit(1024 * 1024)                       -- 1 Mbit/s, both ways
lan:bandwidth_limit({from = a, to = b, bps = 500000})  -- 500 kbit/s leaving A

The number is bits per second, not bytes — matches tc rate Nbit. Two realisation caveats matter when you design a test: the directional form shapes every packet leaving the source TAP (not just traffic to the named to), and whole-bridge bandwidth is not enforced while whole-bridge latency/drop is also set — for combined shaping use the directional form on each source. The full realisation details (TBF vs HTB, max(bps) collapse across pairs) are in the Bridge reference.

Combine directional bandwidth with directional latency / drop on the same source for a complete profile:

lan:bandwidth_limit({from = a, to = b, bps = 1_000_000})
lan:add_latency({from = a, to = b, ms = 25})
lan:drop_rate({from = a, to = b, p = 1})

Reset #

lan:reset()    -- tear down every netem/tbf qdisc, clear every partition

reset is a clean way to go back to "default" without enumerating every impairment you applied. Isolation, uplink, and L3 routes survive a reset — they're topology, not impairments.

Inspect #

lan:latency_ms()        -- current whole-bridge latency
lan:drop_rate_pct()     -- current whole-bridge drop rate
lan:bandwidth_bps()     -- current whole-bridge bandwidth cap

These return the most recently applied whole-bridge value. They don't enumerate per-direction impairments.

Isolation #

Isolation puts one VM behind a hairpin filter — it can't reach any other VM on the bridge, but the bridge stays up:

lan:isolate(a)
local r = a:run("ping -c 1 -W 1 10.0.0.2")
-- r:ok() is false; a is isolated

lan:unisolate(a)
a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()

lan:is_isolated(vm) returns true if the VM is currently isolated.

NICs #

local nic = a:nic("lan")              -- by bridge name
local nic = a:nic("eth0")             -- by guest-name index
local nic = lan:nic(a)                -- equivalent

The Nic gives you per-NIC capabilities the bridge can't. The ones you'll use most:

nic:counters()         -- per-NIC traffic counters, guest's perspective
nic:disconnect()       -- link-down via QMP set_link(false)
nic:reconnect()        -- link-up

disconnect / reconnect drive QMP set_link so the guest sees a real link-down event. The full method list, the counter field table (and its guest-perspective mapping), and the graph-state-only bare-string case are in the Nic reference.

Packet capture #

Two scopes:

Bridge-wide capture #

local cap = lan:capture()
a:run("ping -c 5 10.0.0.2")
local frames = cap:drain("2s")
local pcap = table.concat(frames)
-- pcap is now standard pcap-format bytes, parseable by tshark, etc.

bridge:capture() returns a Capture stream of pcap-format bytes. It requires tcpdump on PATH, and a live capture blocks vm:snapshot() (no half-captured pcap) — capability requirements and the mechanism are in the Bridge reference.

Per-NIC capture #

local nic = a:nic("lan")
local cap = nic:capture()    -- captures only A's TAP, not the whole bridge

Useful when multiple VMs are on the bridge and you only want one VM's perspective.

nic:capture() 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.

Uplink (NAT to the outside world) #

lan:enable_uplink()
vm:run("curl -s https://example.com/"):assert_ok()
lan:disable_uplink()

enable_uplink installs an nft NAT masquerade rule between the bridge and the host's default-route interface. Failures (e.g. no default-route interface) error with the underlying detail.

L3 routing (preview) #

lan:route(other_bridge)

bridge:route records the routing intent in the graph but installs no nft forward rules in v1 — cross-bridge IP traffic does not actually flow yet, and the first call per bridge prints a one-shot warning saying so. bridge:routes() returns the recorded routes. Plan tests around the limitation. See the Bridge reference.

Common patterns #

Test split-brain recovery #

test("application recovers after partition heals", function(t)
    local lan = provium:bridge("lan")
    local a, b = provium:vm("a", "peios"):boot(), 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 can talk to b.
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()

    -- Partition.
    lan:partition(a, b)
    local r = a:run("ping -c 1 -W 1 10.0.0.2")
    t:assert(not r:ok())

    -- Heal.
    lan:unpartition(a, b)
    -- Wait for ARP / route to re-converge.
    wait_until(function()
        return a:run("ping -c 1 -W 1 10.0.0.2"):ok()
    end, {timeout = "10s", desc = "post-heal connectivity"})
end)

Test latency-sensitive code #

test("client retries on slow link", function(t)
    local lan = provium:bridge("lan")
    -- … attach VMs …
    lan:add_latency(500)  -- 500 ms each way ≈ 1s RTT
    local r = client:run("curl --max-time 0.5 http://10.0.0.2/")
    t:assert(not r:ok())  -- timeout fires

    lan:reset()
    r = client:run("curl --max-time 0.5 http://10.0.0.2/")
    r:assert_ok()
end)

Test packet loss tolerance #

test("client succeeds with 30% drop", function(t)
    lan:drop_rate(30)
    -- Apps should retry; some will succeed.
    local successes = 0
    for _ = 1, 20 do
        if client:run("curl --max-time 5 http://10.0.0.2/"):ok() then
            successes = successes + 1
        end
    end
    t:assert(successes >= 5)  -- pessimistic floor
end)

Inspect packet flow with capture #

test("DNS query produces UDP traffic", function(t)
    lan:enable_uplink()
    local cap = lan:capture()
    a:run("dig @8.8.8.8 example.com")
    local pcap = table.concat(cap:drain("3s"))
    cap:close()
    -- Pipe pcap through tshark or pyshark to assert UDP/53.
end)

See also #

  • Bridge reference — every method on the Bridge userdata.
  • Nic reference — per-NIC handle.
  • Streams reference — what bridge:capture() and nic:capture() return.

Streams and tails

Provium / Writing tests

Provium has three stream userdata types — Tail, Capture, and ConsoleStream — that share a common surface: next / read_until / expect / drain / close / eof / creation_site. This page is the practical guide.

The exhaustive method reference is on Streams.

What returns what #

SourceTypeUse for
vm:tail_file(path, opts?)TailFollowing a file as it grows.
vm:fd_stream(fd_or_file)TailFollowing an open file handle.
file:tail_stream()TailFollowing a file from its current cursor.
proc:stdout_stream() / proc:stderr_stream()TailFollowing an async process's output.
bridge:capture()CaptureSniffing every packet on a bridge.
nic:capture()CaptureSniffing one VM's TAP.
console:read()ConsoleStreamReading the guest's serial console.

All three types support the same operations. The differences are in the underlying transport and the per-frame shape.

Operations #

Four reading operations cover almost every test. Their exact semantics — frame shapes per type, default timeouts, error strings, and the pending-bytes buffer — are in the Streams reference; this section shows how each is used.

One shared property matters for correctness: bytes past an expect/read_until match are kept and replayed on the next call, so a sequence of reads never silently loses data.

:next(timeout?) — pull the next chunk #

Returns the next chunk of bytes as a Lua string, or nil at EOF / timeout.

local stream = vm:tail_file("/var/log/messages")
vm:run("logger 'event'"):assert_ok()
local frame = stream:next("5s")
print(frame)  -- "Jan  1 00:00:00 v: event\n"

:read_until(pattern, timeout?) — read until a substring #

local line = stream:read_until("\n", "5s")

Pulls frames until pattern (a Lua string of bytes) appears. Returns the prefix up to AND including the matched bytes. Errors with the pattern in the message on timeout.

:expect(pattern, timeout?) — assert and discard #

stream:expect("ready", "30s")
-- next() / read_until() will see anything past "ready"

Like read_until, but discards the matched prefix. Returns nothing. Use this when you want the assertion semantics — "this stream produced X" — without caring about the bytes themselves.

:drain(timeout?) — collect everything available #

local chunks = stream:drain("2s")
-- chunks is a Lua array of strings
local body = table.concat(chunks)

Read frames until the stream goes quiet, hits EOF, or the timeout lapses. Useful for "what did the stream produce in this window?" Note its default timeout is deliberately short (0.5 s vs 10 s for the other ops).

Housekeeping: :close(), :eof(), :creation_site() #

stream:close() drops the underlying transport (idempotent; a closed stream reads as EOF). stream:eof() tells you the stream is finished and fully consumed. stream:creation_site() reports where the stream was opened — you'll mostly meet it in snapshot-refusal errors. Exact semantics for all three are in the Streams reference.

Choosing between next, read_until, expect, drain #

ScenarioUse
"Tell me when X happens.":expect("X", timeout)
"What's the next line?":read_until("\n", timeout)
"Pull bytes until I have enough."Loop on :next(timeout)
"Snapshot everything in this window.":drain(timeout)
"Has the stream finished?":eof() after :next() returns nil.

:expect is the workhorse for log-watching tests — it has tight, unambiguous error messages on timeout, and it doesn't dump bytes you don't want into your handler.

:read_until is the workhorse when you need the matched bytes (parsing structured log lines, checking that the prefix matches an expected shape).

:next is most useful in loops where you want to inspect each frame before deciding what to do.

:drain is most useful at the end of a test to confirm "nothing weird snuck through" or to capture a quiet window for offline analysis.

EOF semantics #

A closed stream is semantically EOF — :next returns nil, :read_until and :expect error. Idiomatic loops:

while true do
    local frame = stream:next("100ms")
    if not frame then break end       -- EOF or timeout
    process(frame)
end

For Tail specifically, the closed-stream-returns-nil behaviour mirrors Capture and ConsoleStream so the while s:next() do … end idiom works across all three types.

Tail-specific: starting position #

vm:tail_file("/var/log/messages")                    -- start at end (default)
vm:tail_file("/var/log/messages", {start = "beginning"})  -- replay from byte 0
vm:tail_file("/var/log/messages", {start = -512})    -- last 512 bytes then follow

"end" is the most common — only bytes appended after the call are streamed. "beginning" is useful for tests that need to assert on the whole file. Negative integers mean "N bytes before EOF". The full start value table (exact offsets, clamping, float handling) is in the Streams reference.

Capture: pcap bytes #

bridge:capture() and nic:capture() produce raw pcap bytes (the standard pcap-savefile format, not pcap-ng). Concatenate the chunks and pipe into tshark, tcpdump -r -, or a parser library:

local cap = lan:capture()
a:run("ping -c 5 10.0.0.2")
local pcap = table.concat(cap:drain("3s"))
cap:close()

-- Analyse inside a guest without touching the host disk.
local r = a:run("tcpdump -r /dev/stdin -n", {stdin = pcap})
print(r.stdout)

A live capture blocks vm:snapshot() on purpose — no half-captured pcaps. Close the capture before snapshotting. (The mechanism is described in the Streams reference.)

ConsoleStream: bytes from the chardev #

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")

ConsoleStream reads raw bytes from QEMU's console chardev — typically the boot log, login prompt, and anything the guest has written to /dev/ttyS0 since the last read. A VM reset or shutdown reads as console EOF, not an error. Transport details (socket, timeouts, error mapping) are in the Streams reference.

Process streams #

proc:stdout_stream() and proc:stderr_stream() open Tail streams subscribed to captured output:

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

The stream's creation_site and kind/detail carry the process handle id, so snapshot diagnostics can name "proc_stdout_stream(handle=42)" when something refuses a snapshot.

File streams #

file:tail_stream() opens a Tail rooted at the file's current cursor:

local h = vm:open_file("/var/log/messages", {read=true})
h:seek(0, "end")  -- start at current EOF
local stream = h:tail_stream()
-- stream subscribes from EOF onwards, just like vm:tail_file with start="end"

vm:fd_stream(fd_or_file) is the lower-level form — accepts either an integer fd (from file:fd()) or the File userdata directly:

local h = vm:open_file("/dev/console", {read=true})
local stream = vm:fd_stream(h)

Snapshots and live streams #

Snapshots refuse to run while a stream is live. The error names the stream:

provium: vm:snapshot() refused — file `tests/x.test.lua` has live streams:
  - tail_file("/var/log/messages") at tests/x.test.lua:42
  - proc_stdout_stream(handle=7) at tests/x.test.lua:55
Close the streams before snapshotting (or remove the snapshot).

Two ways out:

  1. Close the streams explicitly before vm:snapshot().
  2. Move the snapshot earlier in the test, before the streams are opened.

The pre-condition is enforced for both single-VM (vm:snapshot()) and lab (provium:snapshot()) snapshots. It also blocks provium.reset_between_tests = true files at chunk-load time when file-scope streams are open.

Common patterns #

"Wait for a log line" #

local stream = vm:tail_file("/var/log/messages")
vm:run("trigger-something"):assert_ok()
stream:expect("trigger landed", "5s")

"Race a process boot against its readiness signal" #

local proc = vm:run_async("server")
local out  = proc:stdout_stream()
out:expect("ready", "10s")
-- safe to hit the server now

"Capture pcap during a specific operation" #

local cap = lan:capture()
a:run("ping -c 3 10.0.0.2"):assert_ok()
local pcap = table.concat(cap:drain("2s"))
cap:close()

"Drive an interactive prompt over the console" #

local console = vm:console()
local stream  = console:read()
stream:expect("login:", "30s")
console:write("root\n")
stream:expect("# ", "10s")

"Confirm nothing weird snuck through" #

local cap = lan:capture()
vm:run("…benign workload…"):assert_ok()
local frames = cap:drain("1s")
cap:close()
local pcap = table.concat(frames)
local r = vm:run("tcpdump -r /dev/stdin -n", {stdin = pcap})
t:assert(not r.stdout:find("malformed"))

See also #

  • Streams reference — every method, EOF semantics, type-specific notes.
  • Console reference — console:read and console:expect.
  • VM reference — vm:tail_file, vm:fd_stream.
  • File handle reference — file:tail_stream.

Labs and scope

Provium / Writing tests

A Lab is the unit of resource ownership in Provium. The provium global is the root Lab; sub-labs let you carve out scoped subsets. This page covers the patterns for using labs effectively.

The exhaustive method reference is on Lab.

Per-test scope #

Each test() body runs in its own ephemeral sub-Lab. Resources you create inside a test are local to that test; resources declared at file scope are visible (via lookup fallthrough) and persist across tests.

-- File scope: persists across all tests
local shared = provium:vm("shared", "peios"):boot()

test("first", function(t)
    local local_vm = provium:vm("local", "peios"):boot()  -- test scope
    -- both `shared` and `local_vm` work here
end)
-- `local_vm` is shutdown silently here

test("second", function(t)
    local local_vm = provium:vm("local", "peios"):boot()  -- DIFFERENT VM
    -- The "local" name in test 1 and test 2 are independent — no collision.
    -- `shared` is still here, with whatever state test 1 left.
end)

The rules:

OperationWhat happens in a test() body
provium:vm(name, profile)Creates in the test scope. Auto-shutdown at test end.
provium:vm(name)Looks up name: test scope first, then file root. Errors if not found.
provium.foo (dot access)Same lookup as above; returns nil on miss.
provium:bridge(name, opts?)Same as VMs: create local, lookup walks up.
provium:lab(name)Creates a sub-lab in the test scope (auto-cleaned).
provium:vm_fixture(name) / provium:lab_fixture(name)Always materialises at file root, regardless of where called. The fixture cache stays warm across tests.

Shadow detection. Declaring a name at test scope when it already exists at file scope is an error, not a silent shadow:

local shared = provium:vm("shared", "peios"):boot()

test("oops", function(t)
    -- This errors: "name already declared at parent scope (lab `provium`).
    -- Pick a different name, or use `provium:vm("shared")` to reuse it."
    local v = provium:vm("shared", "peios")
end)

The intent: if you wanted the file-scope VM, use the lookup form (provium:vm("shared")). If you wanted a fresh independent VM, pick a different name.

Federation sub-labs are isolated. A sub-lab created via provium:lab("dc1") has no parent chain — dc1.web doesn't fall through to find a sibling DC's "web". This is intentional; federation models distinct sites.

Things that don't walk parents (each by design):

  • vm_names / bridge_names / members — return only the local scope's contents.
  • boot / shutdown / pause / resume — batch ops on the local scope's VMs.
  • snapshot / restore — operate on the local scope plus its sub-labs (downward, not upward).
  • claim, barrier — file-scope coordination primitives; test-scope claims/barriers are isolated.

Per-test scope vs reset_between_tests #

These solve different problems and compose well together — they're not alternatives.

Per-test scope (default, automatic)reset_between_tests = true (opt-in)
What it isolatesNew declarations made inside a test() body.Mutable state of file-scope resources.
MechanismTest-scope sub-Lab; auto-shutdown at test end.Snapshot after file setup; restore between tests.
Per-test costBooting the test-scope VMs you declared.Snapshot restore (cheaper than full boot, slower than nothing).
What it doesn't help withFile-scope VMs accumulating cruft across tests.Name collisions across tests for ad-hoc VMs.

The typical heavy test file uses both:

provium.reset_between_tests = true

-- File-scope setup: expensive cluster build, snapshot baseline.
local lan = provium:bridge("lan")
local web = provium:vm("web", "peios"):boot()
local db  = provium:vm("db",  "peios"):boot()
lan:attach({web, db})
web:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
db:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")
db:run("psql -c 'CREATE TABLE t (...)'")  -- baseline schema

test("api writes propagate to db", function(t)
    -- web and db are restored to baseline at start of every test.
    web:run("curl -X POST http://localhost/items -d '...'")
    local r = db:run("psql -tA -c 'SELECT count(*) FROM t'")
    t:assert_eq(r.stdout:match("%d+"), "1")
end)

test("scratch VM joins the network", function(t)
    -- Throwaway VM, test-scope, auto-shutdown at test end.
    local probe = provium:vm("probe", "peios"):boot()
    lan:attach(probe)  -- bridge is file-scope, found via parent walk
    probe:run("ip addr add 10.0.0.3/24 dev eth0 && ip link set eth0 up")
    probe:run("curl -s http://10.0.0.1/health"):assert_ok()
end)

Without per-test scope, the second test couldn't introduce probe without colliding (or having to pick a unique name per test). Without reset_between_tests, the row the first test inserted would still be in the database for every later test.

When to use which:

  • Per-test scope alone is enough when each test is self-contained: declare what you need, do work, done. The smoke-test shape — local vm = provium:vm("v", "peios"):boot(); vm:run(...) repeated per test.
  • Add reset_between_tests when you have a non-trivial setup at file scope (configured cluster, populated DB, attached topology) and tests mutate it. Pays for itself once a per-test setup-cost crosses the snapshot/restore time.
  • Skip both by declaring reset_between_tests = false AND being careful with naming when you actually want state to accumulate across tests (e.g. progressive integration scenarios).

Why sub-labs #

Most simple tests use only the root lab — provium:vm("a", "peios"), provium:bridge("lan"). Sub-labs are useful when:

  • You're modelling multi-DC topologies (provium:lab("dc1"), provium:lab("dc2")).
  • You want to snapshot or restore a coherent subset of resources.
  • You want to use lab_fixture to cache an entire topology.
local dc1 = provium:lab("dc1")
dc1:vm("a", "peios")
dc1:vm("b", "peios")
dc1:bridge("lan"):attach({dc1.a, dc1.b})

local dc2 = provium:lab("dc2")
dc2:vm("c", "peios")
dc2:bridge("lan"):attach(dc2.c)

dc1.a is shorthand for dc1:vm("a"). The dot lookup tries vm → bridge → sub-lab in order; missing names return nil (no error).

Anonymous sub-labs #

local sub = provium:lab()  -- name auto-generated: __lab_0, __lab_1, ...

Useful for one-shot subsets that don't need a stable name.

Membership: include and remove #

You can move resources between labs:

local v = provium:vm("v", "peios")
local sub = provium:lab("sub")
sub:include(v)            -- v is now in sub
provium:remove(v)         -- and gone from root

lab:include accepts a single VM, Bridge, or sub-Lab userdata, or an array of those. It errors on duplicate names, reserved names, and shadow conflicts — the exact error cases are in the Lab reference.

lab:remove is graph-state only — the underlying VM, bridge, or sub-lab is NOT shut down. It's removed from the lab's child list. Useful when you want to take ownership of a resource somewhere else.

Listing members #

provium:vm_names()          -- {"v", "v2"}
provium:bridge_names()      -- {"lan"}
provium:sub_lab_names()     -- {"dc1", "dc2"}
provium:members()           -- [{kind="vm", name="v"}, {kind="bridge", name="lan"}, …]

members() is the unified accessor; the others are sliced by kind.

Batch lifecycle #

The lifecycle methods on a lab apply to every direct VM child (not recursive):

provium:boot()       -- boot every VM in the root lab
provium:shutdown()   -- shutdown every VM
provium:pause()
provium:resume()

Useful when a test sets up the topology declaratively and wants to bring it up atomically:

local lan = provium:bridge("lan")
local a   = provium:vm("a", "peios")
local b   = provium:vm("b", "peios")
lan:attach({a, b})
provium:boot()                -- boots a and b together

For per-sub-lab control:

local dc1 = provium:lab("dc1")
dc1:vm("a", "peios"):boot()
dc1:vm("b", "peios"):boot()
-- dc1 boots independently of the root lab

Resource claims #

Each test file may make at most one claim against the dispatcher's resource pool:

provium:claim({memory = "4G", cpus = 4})

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

The claim sits across the file's lifetime; it's released at file end. A second :claim errors — lab claim already held; one-shot per lab. The accepted field shapes and the no-pool behaviour are in the Lab reference.

Why claim? The dispatcher won't oversubscribe — it tracks total RAM and CPU budget across files and only schedules a file when its claim plus the per-file overhead fits. A file that needs 4 VMs at 2 GiB each should claim ~10 GiB so it doesn't get scheduled alongside other heavy files and OOM the host.

-- For a 3-VM, 2-CPU-each, 1-GiB-each test:
provium:claim({memory = "5G", cpus = 7})  -- 3 GiB + per-file overhead, 6 + 1 vCPU

Barriers #

lab:barrier(name, count, timeout?) is an N-arrival rendezvous: it blocks the caller until count callers have hit the same name-keyed barrier, then returns true for all of them. On timeout it returns false — it does not raise. The default timeout, count lock-in, and round-reuse semantics are in the Lab reference.

-- count = 1 is satisfied immediately: a labelled checkpoint.
provium:barrier("setup-done", 1)

-- An unmet count times out and returns false rather than raising.
local ok = provium:barrier("both-ready", 2, 0.5)
if not ok then
    error("second arriver never showed up")
end

A count above 1 needs a second concurrent host-side caller. Test files execute on a single thread, and workers run guest processes (they can't call back into the test's Lua), so today a count > 1 barrier in an ordinary test file simply times out. The multi-caller form is aimed at thread-mode workers (vm:spawn_worker({thread = true})), which aren't supported yet.

For inter-VM coordination (e.g. between two guests), use a guest-side primitive (file in a shared mount, fifo, network message). Barriers are host-side only.

Snapshot and restore #

local snap = provium:snapshot()           -- to a tempdir, returns LabSnapshot
local snap = provium:snapshot("/tmp/x")   -- to that path, returns the path string

The LabSnapshot userdata's accessors are in the Snapshot reference. Restore with:

provium:restore(snap)             -- from LabSnapshot userdata
provium:restore("/tmp/x")         -- from path

The same precondition that blocks vm:snapshot() blocks lab:snapshot() — open streams cause the snapshot to refuse with the offending stream's creation site.

Lab fixtures #

A lab fixture builds a multi-VM topology once, caches the snapshot, and restores it per test:

-- tests/fixtures/cluster.fixture.lua
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")
return provium:snapshot()
-- tests/uses-cluster.test.lua
test("cluster has both VMs reachable", function(t)
    local cluster = provium:lab_fixture("fixtures/cluster")
    cluster.a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

provium:lab_fixture(path) returns a Lab userdata wrapping a fresh sub-lab with the fixture restored. For single-VM fixtures, use provium:vm_fixture(path) — same cache mechanism, returns a VM directly. What goes into the cache key (and therefore what triggers a rebuild) is covered in fixtures and dependencies.

Common patterns #

Multi-DC topology #

local function dc(name, vms)
    local d = provium:lab(name)
    for _, n in ipairs(vms) do d:vm(n, "peios") end
    d:bridge("lan"):attach(d:vm_names())
    return d
end

local dc1 = dc("dc1", {"a", "b"})
local dc2 = dc("dc2", {"c", "d"})
provium:boot()  -- boot every VM

File-level resource claim #

provium:claim({memory = "8G", cpus = 6})

test("3-VM cluster", function(t)
    -- The dispatcher won't run another file alongside this one
    -- if the pool can't afford 8G + 6 CPUs.
end)

Cached cluster fixture #

-- fixture builder
return provium:snapshot()  -- after building the topology

-- test usage
local cluster = provium:lab_fixture("fixtures/big-cluster")
local a = cluster.a  -- look up by name from the restored sub-lab

Reset-between-tests with a sub-lab snapshot #

provium.reset_between_tests = true snapshots the root lab. To reset only a subset:

local sub = provium:lab("workspace")
local v = sub:vm("v", "peios"):boot()
v:write_file("/tmp/data", "")
local snap = sub:snapshot()

test("a", function(t)
    v:write_file("/tmp/data", "1")
end)

test("b", function(t)
    sub:restore(snap)  -- explicit restore
    t:assert_eq(v:read_file("/tmp/data"), "")  -- back to baseline
end)

This pattern is more verbose than provium.reset_between_tests = true but works when only part of the lab needs resetting.

Auto-close ordering #

When a test scope ends (per-test or per-file), the harness's resource graph walker closes resources in reverse-dependency order:

  1. Streams (Tails, Captures, ConsoleStreams).
  2. Processes (vm:run_async, worker:run_async).
  3. Files (vm:open_file, worker:open_file).
  4. Workers.
  5. Bridges.
  6. VMs.

This is implemented as a Lua-side registry that every register_resource call appends to. Resources are closed in priority order, then within a priority by registration order.

You don't usually need to call :close yourself — the walker fires it automatically. Calling explicitly is fine (the methods are idempotent) and useful when the resource's lifetime is bounded by a clear point in the test.

See also #

  • Lab reference — every method.
  • provium global — file-scope configuration globals.
  • VM reference, Bridge reference — what lives in a lab.
  • Pool and parallelism — how the dispatcher uses claims.

Running tests with the CLI

Provium / Running tests

The provium binary is what you run. This page covers the day-to-day patterns. The full flag inventory is on the CLI reference.

The basic invocation #

provium tests/

Walks tests/ for *.test.lua files and runs each. Default output:

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

2 file(s); 5 passed, 1 failed, 0 skipped; 12.34s

A single file works too:

provium tests/networking/partition.test.lua

If you omit paths entirely, the current directory is scanned recursively.

Selecting what runs #

--filter <substring> #

Match against the test-root-relative path:

provium tests/ --filter networking
provium tests/ --filter partition.test

--rerun-failed #

After every run with at least one failure, Provium writes the canonical paths to ~/.cache/provium/rerun.json. --rerun-failed intersects discovered files against this list:

provium tests/ --rerun-failed

If no prior failure state exists, --rerun-failed exits cleanly with a notice (running the FULL suite is almost never what the user wanted).

A clean run leaves the failed-state file alone, so the failed set persists until you fix the failures.

--since <reference-file> #

Run only files whose mtime is newer than the reference file:

provium tests/ --since main.lua          # files edited after main.lua
provium tests/ --since .last-checkpoint  # custom marker

Useful for "what's changed since I last ran".

Per-test filters #

Tag and slow filtering happen inside each file (the test body's meta is what's matched):

provium tests/ --tag smoke
provium tests/ --tag smoke --tag fast    # OR: any tag matches
provium tests/ --no-tag flaky            # exclude tests tagged flaky
provium tests/ --include-slow            # include meta.slow tests

--no-tag wins over --tag (intersect semantics).

For filtering on arbitrary meta fields (not just tags), use --tag-meta KEY=VALUE:

provium tests/ --tag-meta subsystems=peinit
provium tests/ --tag-meta subsystems=peinit --tag-meta subsystems=loregd   # OR within KEY
provium tests/ --tag-meta subsystems=peinit --tag-meta area=boot            # AND across KEYs
provium tests/ --no-tag-meta flaky=true

This is the recommended pattern when test files are organised by user-visible scenario rather than by code subsystem — annotate tests with subsystems = {"peinit", "loregd"} (or any other axis you care about) and let CI compute "what to run" from the changeset:

# CI: PR touched the peinit subsystem
provium tests/ --tag-meta subsystems=peinit

See meta tags for the convention.

Skipped tests still appear in outcomes — you'll see SKIP <name> in -v mode, with the filter expression as the reason.

Output modes #

Default (compact) #

FAIL tests/x.test.lua (2 passed, 1 failed, 0 skipped)
    FAIL the failing case
        message lines

Verbose (-v) #

Every test status, not just failures:

PASS tests/x.test.lua (3 passed, 0 failed, 0 skipped)
    PASS first
    PASS second
    PASS third

Quiet (-q) #

Only failures show. Files with all passing / skipped are silent. The summary line still prints.

JSON (--json) #

Line-delimited JSON, one object per file:

{"path":"tests/x.test.lua","timeout":"in_time","passed":true,"chunk_error":null,"tests":[{"name":"…","status":"passed","message":null,"log":[]}]}

Useful for piping into another tool. Mutually exclusive with --events-stdout.

Msgpack events (--events-stdout) #

Length-prefixed msgpack frames over stdout. The human-readable / JSON renderer redirects to stderr automatically. Useful with provium-coverage:

provium tests/ --events-stdout | provium-coverage

See events and coverage for the full event-stream story.

Watch mode #

provium tests/ --watch

Polls every 500 ms for stat-changed files; re-runs when one changes. Use Ctrl-C to exit. The empty-set case is fine — the watcher rescans every tick, so dropping a *.test.lua into the watched root after launch triggers discovery.

--rerun-failed is automatically dropped under --watch so file-edit detection works as expected (otherwise each tick would re-run only the original failed set forever).

Per-file timeouts #

provium tests/ --timeout 300            # 5 min (default)
provium tests/ --timeout 30s
provium tests/ --timeout 10m
provium tests/ --timeout 500ms
provium tests/ --timeout 0              # disable

Bare integers are seconds. Suffixes accepted: ms, s, m, h. 0 disables the timeout entirely.

When a file's timeout fires, the harness records it as timed_out, marks the file as failed for exit-code purposes, and emits a file_completed event with status = "timed_out".

Pool and CPU controls #

By default Provium uses 80 % of host RAM and the full host CPU count for the resource pool. Override:

provium tests/ --mem 16G                 # explicit pool memory
provium tests/ --cpus 8                  # explicit pool vCPUs
provium tests/ --cpu-overcommit 1.5      # multiplier on --cpus

See pools and parallelism for how the pool, claims, and dispatcher interact — including when overcommit is safe. Flag defaults and the clamp range are in the CLI reference.

Dev-mode flags #

--no-preflight #

Skip the /dev/kvm / iproute2 / nft / qemu / CAP_NET_ADMIN checks. Use in containers or CI environments where you know the environment is fine but the checks would fail.

--no-ksm #

Skip Kernel Same-page Merging tuning at startup. Default tunes /sys/kernel/mm/ksm/* per the design's pool-density goals; pass this on shared dev hosts where you don't want global tuning.

--vmm local #

Use the in-process LocalAgent backend instead of QEMU. Useful when KVM isn't available (CI, dev-on-laptop). The local backend does not actually boot a kernel — it gives the host bindings something to dispatch against, but tests that rely on guest-side behaviour (running commands, file I/O) won't work.

provium tests/ --vmm local

Subcommands #

When a subcommand is given, the test-runner mode is suppressed.

provium repl <profile> #

Boot a VM and drop into an interactive Lua REPL:

provium repl peios                  # cold-boot the peios profile
provium repl peios --name dev       # custom VM name
provium repl --fixture base         # resume from a fixture

Useful for poking at a guest interactively. The full Provium API is available — vm:run, vm:read_file, vm:tail_file, etc.

provium fixture <op> #

Manage the fixture cache:

provium fixture list                       # show every cached entry
provium fixture build path/to/fixture      # force-build
provium fixture rebuild path/to/fixture    # evict and rebuild
provium fixture clean                      # wipe the cache
provium fixture stale                      # list fixtures whose source doesn't match any cache entry

See fixtures and dependencies for when each is useful.

provium prepare [profile] #

Run dynamic profiles' build commands without booting anything — one profile by name, or every profile that declares a build. Pre-warm images with provium prepare && provium tests/ --no-build; see dynamic profiles.

provium list #

List discovered tests / fixtures without running anything:

provium list                 # tests
provium list --fixtures      # fixtures

provium lsp-setup [dir] #

Write Lua Language Server stubs and a .luarc.json into a test directory so the harness globals resolve in your editor — see the CLI reference.

Exit codes #

For CI, three values: 0 means every file passed (or skipped), 1 means at least one file did not finish cleanly, 2 means an internal error (config, pre-flight, dispatcher). The exit code is deliberately clamped to those three — the precise triggers and the clamping rationale are in the CLI reference.

CI patterns #

"Run everything, fail on any failure" #

#!/bin/sh
provium tests/

Exit code is your test result. Capture stdout for the summary, stderr for the human-readable lines (when --json is set).

"Run with structured output" #

provium tests/ --json | tee results.jsonl

Each line is a complete {"path":…,"tests":[…]} object. Easy to pipe into a custom dashboard or per-test reporter.

"Save events for post-hoc analysis" #

provium tests/ --save-events events.msgpack
provium-coverage --from events.msgpack > coverage.html

The msgpack file is portable; you can analyse it on a different host.

"Fail fast" #

provium tests/ --fail-fast

Stops after the first failed file. Useful for tight inner loops where you want to see the first failure quickly.

"Slow CI vs fast CI" #

# Fast: skip slow tests by default.
provium tests/

# Slow / nightly: run everything.
provium tests/ --include-slow

meta.slow = true tests are skipped by default. Tag them in the test bodies, and your dev loop stays fast while nightly catches the slow stuff.

"Parallel CI sharding" #

There's no built-in shard splitter, but provium accepts any number of path arguments, so split the file list in your CI script and pass each shard its own files:

# $SHARDS = total shard count, $INDEX = this shard (0-based).
git ls-files 'tests/**/*.test.lua' | awk "NR % $SHARDS == $INDEX" | xargs provium

Each shard runs a disjoint subset of files. Don't try to build a shard with --filter — it's a single substring match against the relative path, not a pattern list.

See also #

  • CLI reference — every flag, every env var.
  • Events and coverage — --save-events, --events-stdout, --coverage.
  • Pools and parallelism — how the resource pool works.

Events and coverage

Provium / Running tests

Provium emits a structured msgpack event stream covering every file, test, VM, fixture, and pool transition. This page covers the practical side of consuming it; the wire format is on events.

Why events #

The human-readable PASS / FAIL summary is fine for the dev loop, but not enough for:

  • Coverage reports (which test exercised which spec section?).
  • Dashboards (live view of pool state, in-flight files, fixture builds).
  • Post-hoc analysis ("why did this CI run take 4× longer than yesterday?").
  • Debugging fixture rebuilds (which file is waiting on which build lock?).

For all of these, the event stream is what you want.

Output channels #

Provium can route events to three places independently. Combine flags freely.

--save-events <path> — to a file #

provium tests/ --save-events events.msgpack

Writes length-prefixed msgpack frames to the file. Use for post-hoc analysis or replay.

Under --watch, the file is truncated at each iteration so it contains exactly the most recent run.

--events-stdout — to stdout #

provium tests/ --events-stdout | provium-coverage

Streams events on stdout. The human-readable / JSON renderer redirects to stderr so consumers piping provium --events-stdout | … don't see human text interleaved into their msgpack parser. (provium-coverage reads stdin when --from is omitted, so the bare pipe above is the whole pipeline.)

Mutually exclusive with --json.

--events-socket <path> — over a Unix socket #

provium tests/ --watch --events-socket /tmp/provium.sock
# in another terminal — any client that can read a Unix socket:
socat - UNIX-CONNECT:/tmp/provium.sock | provium-coverage

The binary listens, accepts connections, and fans out frames. Useful for live dashboards. The socket persists across --watch iterations so connected clients aren't dropped on every re-run.

Coverage post-run #

--coverage is sugar over --save-events + a post-run pipe to provium-coverage:

provium tests/ --coverage

Internally:

  1. If --save-events <path> is also set, Provium uses that file.
  2. Otherwise, Provium tees events to a scratch tempfile ($TMPDIR/provium-coverage-<pid>.msgpack) with a sibling marker file.
  3. After the run, runs provium-coverage --from <path>.
  4. Cleans up the tempfile if Provium owns it (the marker is the proof).

Failures from the post-run pipe propagate as the exit code — a coverage failure shows up as exit 1.

The cleanup is robust: SIGINT / SIGTERM handlers and an atexit hook clear the tempfile even on a killed run, so CI won't accumulate orphaned msgpack files.

What's in the stream #

The stream covers five areas: file lifecycle (file_discovered → file_dispatched → file_completed, with file_blocked when a file waits on the pool), test outcomes (test_started then one of test_passed / test_failed / test_skipped), VM lifecycle (vm_spawned / vm_shutdown), pool and claim activity, and fixture builds (fixture_build_started / _done / _waiting and fixture_cache_hit).

Every frame is {ts, kind, payload}. The full variant list and every payload field are in the events reference.

Replay and analysis #

provium tests/ --save-events events.msgpack
provium-coverage --from events.msgpack

The msgpack file is portable. You can analyse it on a different host than the one that produced it, or run multiple consumers against the same file:

provium-coverage --from events.msgpack > coverage.html
my-custom-tool --from events.msgpack > metrics.json

Building your own consumer #

The protocol is provium-protocol's EventFrame over length-prefixed msgpack frames (the same framing as the agent wire protocol). The framing helpers in provium-protocol::frame are exposed for reuse.

A minimal Rust consumer:

use provium_protocol::events::{Event, EventFrame};
use provium_protocol::frame::{read_frame, DEFAULT_MAX_FRAME_BYTES};
use std::fs::File;
use std::io::BufReader;

fn main() -> std::io::Result<()> {
    let f = File::open("events.msgpack")?;
    let mut r = BufReader::new(f);
    loop {
        let frame: EventFrame = match read_frame(&mut r, DEFAULT_MAX_FRAME_BYTES) {
            Ok(f) => f,
            Err(_) => break,  // EOF or malformed; production code should distinguish
        };
        match frame.event {
            Event::TestPassed(t) => println!("PASS {}::{}", t.path, t.name),
            Event::TestFailed(t) => println!("FAIL {}::{} — {}", t.path, t.name, t.reason),
            _ => {}
        }
    }
    Ok(())
}

For other languages, use any msgpack library that handles the length-prefix framing (e.g. read 4 bytes BE length, then read N bytes, then deserialise as msgpack).

Live multiplexing for dashboards #

--events-socket is the right shape when you have a long-running dashboard:

# Provium runs in watch mode; events flow over the socket.
provium tests/ --watch --events-socket /tmp/provium.sock

# Dashboard connects once and stays connected across reruns.
my-dashboard --from-socket /tmp/provium.sock

The socket persists across --watch iterations precisely so dashboards don't have to reconnect on every re-run.

For non-dashboard pipelines (CI feeding a coverage tool), prefer --events-stdout — simpler, no socket-file management.

Observability event guarantees #

The harness guarantees ordering and pairing invariants (exactly one file_completed per dispatched file, exactly one terminal event per started test, paired claim and fixture-build events) — the full list is in events reference — Event guarantees. Consumers can rely on these to track in-flight state, e.g. counting "in-flight files" as file_dispatched - file_completed.

Practical patterns #

"Why did this run take so long?" #

provium tests/ --save-events events.msgpack
# parse events.msgpack to find the longest file_completed.duration_ns

"Which fixtures rebuilt?" #

provium tests/ --save-events events.msgpack
# count fixture_build_started events

If many fixtures are rebuilding, something invalidated the cache — usually a kernel swap or a helper edit.

"Spec coverage report" #

provium tests/ --include-slow --coverage

provium-coverage reads meta.spec from each test_passed / test_failed event and produces a spec-section report.

"What was the guest doing when this test failed?" #

The test_failed event includes console_excerpt — the last 4 KiB of every booted VM's console log captured at the failure moment. Useful when failures are kernel-side and reproducing locally is expensive.

See also #

  • Events reference — every event, every field.
  • Protocol version — wire-shape pinning.
  • The CLI — --save-events, --events-stdout, --events-socket, --coverage.

Fixtures and dependencies

Provium / Running tests

Fixtures are pre-built VM (or whole-lab) snapshots that test files restore from instead of re-doing setup work. This page covers the cache model end-to-end.

The exhaustive method reference is on Lab (vm_fixture, lab_fixture); the userdata is on Snapshot.

Discovery #

A fixture is any *.fixture.lua file under one of the roots directories listed in provium.toml. Test files reference fixtures by their test-root-relative path with the .fixture.lua suffix omitted:

tests/
  fixtures/
    base.fixture.lua            -- referenced as "fixtures/base"
    cluster.fixture.lua         -- referenced as "fixtures/cluster"
  uses-base.test.lua
-- in uses-base.test.lua:
local vm = provium:vm_fixture("fixtures/base")

If the same name resolves under multiple roots, the first-listed root wins. Missing fixtures error at restore time with fixture \X` not found in any test root`.

Two fixture shapes #

Single-VM fixture (vm_fixture) #

The chunk ends with return vm:snapshot():

-- tests/fixtures/base.fixture.lua
local vm = provium:vm("base", "peios"):boot()
vm:run("seq 1000000 > /srv/corpus"):assert_ok()
return vm:snapshot()

Cached as <key>.snap — a single sparse zstd-compressed file.

Lab fixture (lab_fixture) #

The chunk ends with return provium:snapshot():

-- tests/fixtures/cluster.fixture.lua
local lan = provium:bridge("lan")
local a = provium:vm("a", "peios"):boot()
local b = provium:vm("b", "peios"):boot()
lan:attach({a, b})
return provium:snapshot()

Cached as <key>.lab/ — a directory containing per-VM .snap files plus a lab.json index.

What ends up in the cache key #

The cache key is a SHA-256 of, in order:

  1. The fixture file's source bytes.
  2. Every transitively-referenced fixture's key (so vm_fixture("derived") calling vm_fixture("base") invalidates when base changes).
  3. Every require()d helper's source bytes (recursively — helpers that require other helpers fold in too).
  4. The kernel and initrd identifier of EVERY profile in provium.toml (sorted by name for determinism).
  5. Every external host-file declared with vm:push_file("…", …) or lab:depends_on_file("…") (path + mtime + size). See External host-file deps.

Edit any of those, and the next run rebuilds the fixture. This is intentional:

  • A fixture builder editing the file → rebuild.
  • A helper module the fixture requires gets edited → rebuild.
  • A different fixture the fixture references gets rebuilt → rebuild this one too.
  • A new kernel image is dropped in → every fixture rebuilds.

Multi-profile cache-key folding means a kernel change on profile B invalidates fixtures even if they only ever boot under profile A. This is a deliberate over-invalidation: it's safer than serving a fixture built against a now-stale kernel.

External host-file deps #

Fixtures routinely push host-side files — a freshly-built binary, a config template, a test corpus — into the guest. The cache key folds those host files in automatically so editing the file on the host invalidates the snapshot.

vm:push_file(host_path, guest_path, opts?) #

Reads host_path on the host and writes its bytes to guest_path in the guest. The host file's identifier (path + mtime + size) is folded into the fixture's cache key by default — rebuild the binary, get a fresh fixture next run.

-- tests/fixtures/uapi-ready.fixture.lua
local vm = provium:vm("uapi", "peios"):boot()
vm:push_file("../peios-uapi/target/release/peios-uapi", "/usr/bin/peios-uapi")
vm:run("/usr/bin/peios-uapi --setup"):assert_ok()
return vm:snapshot()

Relative host_path is resolved against the directory containing the fixture file (or the directory of the require'd helper that calls push_file), not the cwd at invocation time. That keeps things stable regardless of where provium was launched from.

To opt out for a specific call — e.g. a large test corpus you don't want included in the key — pass auto_dep = false:

vm:push_file("../big-corpus.tar", "/data.tar", {auto_dep = false})

The opt-out is detected by static source scanning, so the auto_dep = false must be a literal in the call site. A variable like {auto_dep = x} does NOT opt out (the default is to track).

lab:depends_on_file(host_path) #

Declares an external host-file as a fixture-cache dependency without doing any I/O. Useful when the fixture reads the file on the host side — e.g. a config template parsed in Lua, or a binary the build script invokes locally before pushing post-processed output.

provium:depends_on_file("../templates/sshd_config.in")
local rendered = render_template("../templates/sshd_config.in", {port = 2222})
vm:write_file("/etc/ssh/sshd_config", rendered)
return vm:snapshot()

provium: is the root lab; sub-labs (provium:lab("dc1"):depends_on_file(...)) work too. All declarations on any lab in the fixture fold into the same fixture-level cache key.

Limits of static scanning #

The fold is driven by scanning the fixture and its helpers for literal-string arguments to push_file and depends_on_file. Two cases fall outside that:

  • Variable host paths: vm:push_file(my_path, "/foo") doesn't fold (the scanner can't resolve my_path). Either inline the literal or follow up with an explicit lab:depends_on_file("…").
  • Generated paths: vm:push_file("build/" .. arch, "/bin/foo") doesn't fold either.

The fix is the same in both cases: add a separate literal lab:depends_on_file("…") declaration alongside the dynamic call. If a fixture's deps genuinely can't be expressed as literals, fall back to provium fixture rebuild <name> after host-side changes.

Cache layout #

~/.cache/provium/fixtures/
  c5e6f8….snap            -- single-VM fixture snapshot (sparse, zstd)
  c5e6f8….lock            -- per-key build lock
  abc123….lab/            -- lab-fixture directory
    lab.json
    a.snap
    b.snap
  abc123….lab.lock

Override the cache directory in provium.toml:

[provium]
cache_dir = "/var/cache/provium/fixtures"

Default: ~/.cache/provium/fixtures/.

Build flow #

When a test calls provium:vm_fixture("name"):

flowchart TD
  Start[vm_fixture call] --> KeyComp[Compute key — source + deps + kernels]
  KeyComp --> Lookup{Key in cache?}
  Lookup -->|yes| Restore[Restore from cache]
  Lookup -->|no| Lock[Acquire build lock]
  Lock --> Recheck{Peer built it while we waited?}
  Recheck -->|yes| Restore
  Recheck -->|no| Build[Build under lock]
  Build --> Compress[Sparse + zstd]
  Compress --> Install[Atomic install]
  Install --> Restore
  Restore --> Return[Return VM]

Build under lock ensures only one process builds a given fixture even when 16 test files reference the same fixture. Other files queue on the lock and either restore from the freshly-installed cache (if the holder finished cleanly) or rebuild themselves (if the holder crashed).

fixture_build_started / fixture_build_done events frame each build. fixture_build_waiting fires when a file queues behind a peer; the payload includes held_by_file so dashboards can show "file X is waiting on file Y to build fixture Z."

Atomic install #

Lab fixtures use renameat2(RENAME_EXCHANGE) to swap the freshly-built <key>.lab/ with any existing one. If the kernel doesn't support the syscall, falls back to a sibling-rename + cleanup.

Single-VM fixtures use plain rename after sparse + zstd compression. Both paths leave the cache in a coherent state — readers either see the old version or the new, never half-installed.

Eviction #

LRU eviction runs at every provium startup, before tests dispatch:

  1. Sum file sizes under cache_dir.
  2. Sort entries by access time (atime).
  3. Delete oldest until total is ≤ cache_max_size.

Default cache_max_size: 20 GiB. Override in provium.toml:

[provium]
cache_max_size = "100G"

Each successful restore bumps the entry's atime so frequently-used fixtures stay hot. Without the bump, relatime would let every entry's atime collapse together and eviction would degrade to filesystem order.

Corrupt entries #

If a restore fails (decompression error, version mismatch not caught by the key), the harness:

  1. Prints a warning to stderr naming the fixture.
  2. Evicts the entry.
  3. Falls through to the rebuild path.

This is logged but not fatal — the next restore attempt builds fresh.

CLI management #

provium fixture list #

   23.45MiB  vm   c5e6f8…
   78.12MiB  lab  abc123…

2 entries, 101.57MiB

Each entry shows size, kind (vm or lab), and the cache key. Useful for "what's hot in my cache?"

provium fixture build <path> #

Force-build the named fixture. If the entry is already cached, prints already built: <path> and exits.

provium fixture build fixtures/base

Useful in CI to warm the cache before the test run.

provium fixture rebuild <path> #

Evict and rebuild. Useful when you know the fixture should change but the harness's cache key didn't catch it (rare):

provium fixture rebuild fixtures/base

provium fixture clean #

Wipe the entire cache directory:

provium fixture clean

The next run rebuilds everything from scratch. Slow but safe.

provium fixture stale #

List fixtures whose source / dep / kernel hash doesn't match any cached entry:

provium fixture stale
  tests/fixtures/base.fixture.lua

1 stale fixture(s)

Useful to check "what would the next provium run rebuild?" without running anything.

Performance notes #

Fixture restores are fast — typical single-VM snapshot restore is < 1 second on warm caches because:

  1. The snapshot file is sparse-zstd compressed at build time, so reading and decompressing is dominated by kernel buffer cache hits.
  2. The harness uses renameat2(RENAME_EXCHANGE) for atomic install (no observable in-between state for readers).
  3. Each successful restore bumps atime, so popular fixtures stay LRU-hot.

Fixture builds are slow — they involve actually booting a VM, doing the setup, and snapshotting. Build them once per cache-key change and amortise across every test that references them.

Common patterns #

"Make a clean baseline available everywhere" #

-- tests/fixtures/clean.fixture.lua
local vm = provium:vm("clean", "peios"):boot()
return vm:snapshot()

Then every test starts from a fresh boot without paying the boot cost:

test("…", function(t)
    local vm = provium:vm_fixture("fixtures/clean")
    -- … fresh-boot semantics …
end)

"Stack fixtures to amortise expensive setup" #

-- tests/fixtures/with-corpus.fixture.lua
local vm = provium:vm_fixture("fixtures/clean")
vm:run("seq 1000000 > /srv/corpus"):assert_ok()
return vm:snapshot()

with-corpus is keyed off clean's key — when clean rebuilds, with-corpus rebuilds too. When clean is a cache hit, with-corpus only pays the corpus-build cost.

"Cache a multi-VM topology" #

-- tests/fixtures/two-node-cluster.fixture.lua
local lan = provium:bridge("lan")
local a, b = provium:vm("a", "peios"):boot(), 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")
return provium:snapshot()
test("…", function(t)
    local cluster = provium:lab_fixture("fixtures/two-node-cluster")
    cluster.a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

The whole topology — bridge, both VMs, partition state, attached disks, everything — is one cached unit.

"Pre-warm the cache in CI" #

Add to your CI script:

provium fixture build fixtures/clean
provium fixture build fixtures/with-corpus
provium fixture build fixtures/two-node-cluster
provium tests/  # now every test sees cache hits

Without pre-warming, the first test to reference each fixture pays the build cost serially.

What can invalidate the cache #

ChangeInvalidates
Edit <fixture>.fixture.luaThat fixture only.
Edit a helper that the fixture requiresThe fixture and every fixture that references it.
Edit a fixture that another fixture references via vm_fixture/lab_fixtureBoth.
New kernel or initrd image (any profile)Every fixture.
Change [profiles.<name>].kernel or .initrd pathEvery fixture.
Edit a file declared with vm:push_file or lab:depends_on_fileThe fixture (and any fixture that references it).
provium fixture rebuild / cleanPer command.

Adding a new profile invalidates the cache (the new profile's kernel/initrd are folded in even if no test uses the new profile). This is by design.

See also #

  • Lab reference — vm_fixture, lab_fixture.
  • Snapshot reference — what fixture builders return.
  • provium.toml reference — cache_dir, cache_max_size.
  • The CLI — provium fixture … subcommands.

Pools and parallelism

Provium / Running tests

Provium's dispatcher runs multiple test files in parallel against a shared resource pool. Each file declares what it needs (or accepts the per-file overhead default), and the dispatcher schedules as many as fit. This page covers how to think about that for your test corpus.

The pool #

When provium starts, it builds one pool with two budgets:

BudgetDefaultOverride
Memory80 % of host RAM--mem 16G
vCPUshost online CPUs--cpus 8 (then --cpu-overcommit <multiplier>)

The pool tracks total and available; every dispatch takes from available, every release returns. A pool_state event fires every second with {used, available} so dashboards see live utilisation.

If you want to be conservative on a busy host:

provium tests/ --mem 8G --cpus 4

If you have a beefy CI box and want maximum throughput:

provium tests/ --mem 64G --cpus 32 --cpu-overcommit 1.5

--cpu-overcommit is clamped to [0.5, 8.0]. Default 1.0 (strict — no oversubscription). 1.5 or 2.0 allows oversubscription if the workload tolerates scheduling jitter.

Per-file claims #

Each test file may declare what it needs:

provium:claim({memory = "4G", cpus = 4})

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

The claim is taken at file dispatch and released at file completion. A second :claim errors — lab claim already held; one-shot per lab.

Without a claim, the dispatcher uses the per-file overhead default (50 MiB memory, 0 CPU). A file that boots 3 VMs at 1 GiB each but doesn't claim anything gets through the pool gate immediately and then might OOM the host because the overhead default is way under the actual demand.

Rule of thumb: claim memory equal to the sum of expected VM memory budgets plus a small buffer; claim CPUs equal to the sum of expected VM vCPU budgets.

-- 3 VMs × (2G memory, 2 CPUs each):
provium:claim({memory = "7G", cpus = 7})  -- 6G VMs + 1G buffer; 6 vCPUs + 1

File dispatch flow #

flowchart LR
  Disc[file_discovered] --> Wait{pool has room?}
  Wait -->|no| Block[file_blocked]
  Block --> Wait
  Wait -->|yes| Disp[file_dispatched]
  Disp --> Acq[claim_acquired]
  Acq --> Run[Run tests in file]
  Run --> Comp[file_completed]
  Comp --> Rel[claim_released]

Files queue at the pool until their reservation fits. The order is stable — the dispatcher takes files in discovery order, so a file that doesn't fit blocks subsequent files (FIFO). For now there's no priority or backfill; if you need a specific file to run first, list its path explicitly.

PSI throttling #

On Linux hosts with pressure stall information (PSI) available, Provium spawns a pressure monitor at startup. It polls CPU and memory pressure once a second; when either crosses the threshold (10 % some-pressure averaged over 10 seconds), the dispatcher pauses new file dispatches until pressure drops.

The threshold and poll interval are fixed — they are not configurable from the CLI.

Files that are already in flight continue. The throttling only delays new dispatch.

When PSI throttling is the cause of a file_blocked, the event's reason field is psi_pressure (versus pool_full for "the pool can't afford this file's claim").

KSM tuning #

Kernel Same-page Merging deduplicates identical pages across VMs. When you boot many VMs from the same kernel + initrd, KSM can reclaim significant memory. Provium tunes /sys/kernel/mm/ksm/* at startup unless --no-ksm is passed.

The KSM tuning runs once per provium invocation, setting run = 1, pages_to_scan = 1000, and sleep_millisecs = 20. Best-effort: a one-line summary goes to stderr.

provium: ksm: tuned (3 knobs)

If your host is shared with non-Provium workloads, pass --no-ksm so Provium doesn't change global tuning.

What blocks parallelism #

Several things prevent unlimited parallelism even when the pool has room:

ThingWhy
Fixture build lockOne process at a time per fixture key. Other files queue on fixture_build_waiting.
Pool reservationA file with a 16 G claim won't run alongside other big files until pool has 16 G free.
PSI pressureHigh CPU pressure pauses new dispatches.
--fail-fastStops new dispatch after the first file failure.

Of these, fixture build lock is the most common surprise. If 16 test files all reference fixtures/base and the cache is cold, all 16 queue on the build lock; only one builds, the rest wait. Pre-warm with provium fixture build fixtures/base to amortise.

Inspecting parallelism in a run #

The event stream gives you the full picture:

provium tests/ --save-events events.msgpack

Then walk the stream:

  • file_discovered events at the start tell you the universe.
  • file_dispatched events tell you what actually ran in parallel (count of in-flight = file_dispatched - file_completed).
  • file_blocked events with reason tell you why something queued.
  • pool_state events at 1 Hz give you a usage timeline.

provium-coverage summarises this in its run report; for ad-hoc debugging, parse the msgpack with the snippet in events and coverage.

Tuning patterns #

"I want to maximise throughput" #

provium tests/ --cpus $(nproc) --mem $(awk '/MemTotal/ {printf "%dG", $2/1024/1024 - 4}' /proc/meminfo)

Use everything except 4 GiB of RAM and overcommit CPUs gently:

provium tests/ --cpu-overcommit 1.5

"I want to be conservative on a shared host" #

provium tests/ --cpus 4 --mem 8G --no-ksm

"I want to detect over-subscription" #

Watch the pool_state and file_blocked event streams. Frequent file_blocked with reason = pool_full means files are claiming more than the pool can serve in parallel — either the pool is too small or claims are too generous. Frequent file_blocked with reason = psi_pressure means the host is genuinely overloaded — reduce parallelism (--cpus, --mem) or move other workloads off the host.

"I want to debug a slow run" #

provium tests/ --save-events events.msgpack

Parse the msgpack for the longest file_completed.duration_ns. Then look at that file's test_started / test_passed events to see which test(s) are slow. Cross-reference with vm_spawned events to see how many VMs were involved.

"I want to test for resource leaks" #

The pool's available budget should return to its full value after each claim_released. Watch pool_state over time — if available is drifting down monotonically, something is leaking. The most common culprits:

  • A test that exits via panic without releasing a claim — but the dispatcher releases on file_completed regardless of how it ended, so this shouldn't happen.
  • A bug in the harness — file an issue with the event stream.

VMs vs files #

A common confusion: the pool tracks per-file resources, not per-VM. The dispatcher reserves the file's full claim at dispatch and holds it until file completion, regardless of how many VMs the file actually boots concurrently.

-- This file claims 4G, but only ever has one VM live at a time:
provium:claim({memory = "4G"})

test("a", function(t)
    local vm = provium:vm("v", "peios"):boot()
    vm:shutdown()  -- VM gone, but claim still held
end)

test("b", function(t)
    local vm = provium:vm("v", "peios"):boot()
end)

The claim doesn't release between tests. If you want fine-grained reservation, you'd need a smaller claim and rely on the dispatcher's per-file overhead — but the trade-off is potential OOM if the claim is too small for the actual peak.

For the typical case, claim for the file's worst-case peak.

See also #

  • The CLI — --mem, --cpus, --cpu-overcommit, --no-ksm.
  • Lab reference — lab:claim.
  • Events — pool_state, file_blocked, claim_acquired, claim_released.
  • Fixtures and dependencies — fixture build lock as a parallelism limiter.

provium.toml

Provium / Configuration

provium.toml is the only config file Provium reads. The default location is ./provium.toml; override with --config <path>.

The schema has two top-level keys: [provium] for runner settings, and [profiles.<name>] for per-VM-type tuples. Both are optional individually — an empty provium.toml parses cleanly but won't be useful for anything (no profiles means provium:vm(name, profile) won't find any profile to look up).

Minimum useful config #

[provium]
roots = ["tests"]

[profiles.peios]
kernel  = "/path/to/bzImage"
initrd  = "/path/to/initrd.cpio.gz"
cmdline = "console=ttyS0 quiet"

This declares one test root and one profile. provium tests/ will scan tests/ for *.test.lua, and tests can call provium:vm("name", "peios").

[provium] section #

roots #

[provium]
roots = ["tests", "vendor/upstream-tests"]
TypeDefaultDescription
array of strings[] (empty)Directories scanned for *.test.lua and *.fixture.lua files. Also prepended to package.path so require("helper.module") resolves under any root.

When roots is empty (or unset), provium <paths> scans whichever paths you pass on the CLI; if you also omit those, it scans the current directory.

For most projects, set roots = ["tests"] (or whichever directory contains your tests) so that:

  • provium (no arg) walks the test tree.
  • provium fixture build foo finds tests/foo.fixture.lua.
  • Tests can require("helpers.assert_pingable") and Provium resolves it as tests/helpers/assert_pingable.lua.

cache_dir #

[provium]
cache_dir = "/var/cache/provium/fixtures"
TypeDefaultDescription
path string~/.cache/provium/fixturesWhere fixture snapshots are stored.

Useful for:

  • Per-machine caches on shared hosts (default is per-user, set to a system-wide path for shared CI runners).
  • Fast scratch storage (point at an SSD or tmpfs for build performance).
  • Large dedicated cache (point at a partition with more headroom than ~/.cache).

The directory is created on first build. If the path is unreadable / unwritable at startup, eviction silently skips and the next build attempt errors with the underlying I/O error.

cache_max_size #

[provium]
cache_max_size = "100G"
TypeDefaultDescription
string with K/M/G/T suffix, or bare bytes"20G"LRU eviction target. The cache is allowed to grow beyond this between runs; eviction trims at the next provium startup.

When the cache exceeds the cap, Provium sorts entries by access time and deletes oldest until total size is under the cap. Each successful restore bumps the entry's atime so popular fixtures stay hot.

[profiles.<name>] blocks #

Each [profiles.<name>] block declares one (kernel, initrd, cmdline, guest_os) tuple. Test code looks them up by name: provium:vm("v", "<name>").

You can have any number of profiles; tests pick whichever they need.

kernel #

[profiles.peios]
kernel = "/build/peios/bzImage"
TypeRequiredDescription
path stringyesPath to a bzImage-format kernel image. Booted via QEMU's -kernel option.

Path validation (does the file actually exist?) happens at VM-boot time, not config-load time. This lets a single provium.toml be portable across machines that have different kernel layouts.

initrd #

[profiles.peios]
initrd = "/build/peios/initrd.cpio.gz"
TypeRequiredDescription
path stringyesPath to an initramfs. Booted via QEMU's -initrd.

By default, Provium injects the provium-agent binary at /sbin/provium-agent by concatenating a small overlay cpio onto your initrd at launch (the kernel unpacks concatenated gzip cpios into a single rootfs). The agent boots as PID 1 and forks immediately: the child becomes the vsock listener, the parent execs your initrd's /init, which therefore takes over PID 1. In this chained layout the agent deliberately mounts nothing first — your init owns the mount and pivot sequence exactly as it would alone. (Only on an agent-only initrd, with no user /init, does the agent perform the pseudo-FS mounts itself.) Userspace runs as it would have on its own; the agent runs alongside as PID 2.

Three consequences:

  • Your initrd doesn't need to know about Provium. A vanilla distro initramfs, a buildroot image, or a from-scratch cpio with just /init will all work.
  • Your /init runs as PID 1. If your tests need PID-1 init duties (signals, child reaping), they happen in your init as before.
  • The merged initrd is content-hash cached under <scratch_root>/agent-overlay-cache/<sha256>.cpio.gz. Subsequent boots of the same (initrd, overlay) pair pay nothing.

To opt out (e.g. when your initrd already bundles an agent at the path Provium would set), set inject_agent = false on the profile.

inject_agent #

[profiles.peios]
inject_agent = false
TypeDefaultDescription
booltrueWhen true, Provium concatenates the agent overlay onto initrd at launch and appends rdinit=/sbin/provium-agent to the kernel cmdline. Set false to use the initrd as-is.

If your cmdline already pins a different rdinit=PATH, the launch errors with a clear conflict message rather than silently overriding. Either remove the conflicting rdinit=, or set inject_agent = false.

agent_overlay_path #

[profiles.peios]
agent_overlay_path = "/usr/local/share/provium/agent-overlay.cpio.gz"
TypeDefaultDescription
path stringunsetOverride the path to the agent overlay cpio. Useful for distribution-installed Provium where the overlay isn't co-located with the binary.

When unset, Provium tries (in order): the PROVIUM_OVERLAY env var, then <provium-binary-dir>/../share/provium/agent-overlay.cpio.gz, then walks up the binary's directory tree looking for dist/agent-overlay.cpio.gz (covers in-development runs from target/). Set this field — or the env var — when none of those apply.

cmdline #

[profiles.peios]
cmdline = "console=ttyS0 quiet"
TypeRequiredDescription
stringone of cmdline / cmdline_fileInline kernel command line. May be empty or omitted when cmdline_file is set — the two compose. A profile with neither a non-empty cmdline nor a cmdline_file is rejected with profile \` has empty cmdline and no `cmdline_file`; set at least one`.

vm:boot({kernel_cmdline = "..."}) overrides the whole command line for one boot.

Provium always appends console=ttyS0 if absent. The kernel happily uses multiple console= directives, so any user-set values are preserved alongside. The reason: the QEMU command wires the serial port into Provium's console capture, so the kernel's printk needs to land there for failure diagnostics to be visible. Without console=ttyS0, PID 1's /dev/console may resolve to a device QEMU doesn't capture, and writes to stderr can fail and crash userspace.

Common additions:

  • loglevel=7 — verbose kernel logging for debugging.
  • nokaslr — for reproducible kernel addresses in panic dumps.
  • panic=1 — panic immediately rather than hanging on unrecoverable errors.

cmdline_file #

[profiles.peios]
cmdline_file = "../peiso/out/root/boot/cmdline"
cmdline      = "loglevel=7"   # optional; appended after the file
TypeRequiredDescription
path stringone of cmdline / cmdline_filePath to a file whose contents are the base command line — typically an image builder's generated cmdline. Read at VM-boot time; resolved relative to the current directory, like kernel/initrd.

All whitespace in the file — including newlines — is collapsed to single spaces, then the inline cmdline (if any) is appended after. Because the kernel applies last-wins semantics to most repeated parameters (init=, loglevel=, …), an inline token overrides the file's value for those.

The point is to stop the command line from drifting. If a builder bakes init=/usr/bin/protoinit into the image's cmdline and you hand-copy that into cmdline, the two silently diverge the next time the builder changes. Pointing cmdline_file at the builder's output means Provium reads the authoritative value every boot. See Dynamic profiles.

guest_os #

[profiles.peios]
guest_os = "peios"
TypeDefaultDescription
string"peios"Guest OS identifier. v1 only supports "peios".

Validated at config load time. A profile with guest_os = "linux" (or any other value) errors with profile \` has guest_os = `linux`, only `peios` is supported in v1`.

The field exists to future-proof the schema for other ports — when there's a Windows agent, this would be how a profile selects it.

build #

[profiles.peios]
build        = "peiso build manifests/peios.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/initrd.img"
cmdline_file = "{out}/root/boot/cmdline"
TypeDefaultDescription
stringunsetShell command (run with sh -c) that produces this profile's boot artifacts. Runs once before any VM boots. A non-zero exit aborts the run.

The literal token {out} — in build and in the path fields — expands to this profile's build-output directory (see build_out), so the command's --out and the kernel/initrd/cmdline_file Provium later reads are the same path and cannot drift.

Provium tracks no staleness: the command runs every invocation. Making rebuilds cheap when nothing changed is the builder's job, not Provium's. Skip the hook with --no-build, or run it without booting via provium prepare. Full treatment on Dynamic profiles.

build_out #

[profiles.peios]
build_out = "/tmp/provium-builds/peios"
TypeDefaultDescription
path string$XDG_CACHE_HOME/provium/builds/<profile>/The directory {out} expands to.

When unset, the default base is resolved like the fixture cache — $PROVIUM_BUILD_DIR, then $XDG_CACHE_HOME/provium/builds, then ~/.cache/provium/builds, then /tmp/provium-builds — with the profile name appended. Provium creates the directory before running build but never wipes it: the build command owns its contents (so it can keep its own incremental-build caches there).

Multiple profiles #

You can declare any number of [profiles.<name>] blocks; tests pick one by name per VM. Patterns for multi-profile setups (debug builds, cross-version testing, feature-flag gating) are on Profiles.

Important: the fixture cache key folds in EVERY profile's kernel + initrd identifier. Adding a new profile invalidates the entire fixture cache. This is intentional: a new kernel could behave differently, so existing fixtures are no longer trustworthy. See Profiles — What happens to the cache when profiles change.

Configuration loading #

StepWhat happens
1. Readprovium.toml is read from --config <path> (default ./provium.toml).
2. ParseTOML is parsed. Parse errors include the file path.
3. ValidateEach profile is validated (a non-empty cmdline or a cmdline_file; guest_os = "peios").
4. ExpandThe {out} token in each profile's build command and path fields is replaced with that profile's resolved build-output directory.
5. UseThe Config struct is wrapped in an Arc and passed to every file runner.

Errors:

ErrorCause
read \`: `File missing or unreadable.
parse \`: `Malformed TOML.
invalid config in \`: `Validation failed (empty cmdline, unsupported guest_os, etc.).

All three abort the run with exit code 2.

Worked example #

[provium]
roots          = ["tests", "internal-tests"]
cache_dir      = "/srv/provium-cache"
cache_max_size = "200G"

[profiles.peios]
kernel   = "/srv/peios-builds/latest/bzImage"
initrd   = "/srv/peios-builds/latest/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet panic=1"
guest_os = "peios"

[profiles.peios-mainline]
kernel   = "/srv/peios-builds/mainline/bzImage"
initrd   = "/srv/peios-builds/mainline/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet panic=1"
guest_os = "peios"

[profiles.peios-debug]
kernel   = "/srv/peios-builds/debug/bzImage"
initrd   = "/srv/peios-builds/debug/initrd.cpio.gz"
cmdline  = "console=ttyS0 debug loglevel=7 nokaslr panic=1"
guest_os = "peios"

This declares three profiles (production, mainline, debug), centralises the cache on a 200 GiB dedicated mount, and scans both tests/ and internal-tests/.

See also #

  • Project structure — the broader project layout.
  • VM reference — boot_opts.kernel_cmdline overrides.
  • Profiles — patterns for using multiple profiles.

Profiles

Provium / Configuration

A profile is one named (kernel, initrd, cmdline, guest_os) tuple in provium.toml. Tests pick a profile by name when they create a VM. This page covers the practical patterns.

The configuration field reference is on provium.toml. A profile can also build its own artifacts before booting rather than pointing at files already on disk — see Dynamic profiles.

When you need more than one profile #

For most projects, one profile is enough — peios, pointing at the latest build. You'd add more when:

  • You're testing across kernel versions (peios-stable, peios-mainline).
  • You want a debug build available for diagnosing failures (peios-debug).
  • You're gating tests on a feature flag in the kernel (peios-prefeatx, peios-postfeatx).
  • You're testing a port to a new architecture (peios-arm64).

Declaring multiple profiles #

[profiles.peios]
kernel   = "/build/peios/bzImage"
initrd   = "/build/peios/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"

[profiles.peios-debug]
kernel   = "/build/peios-debug/bzImage"
initrd   = "/build/peios-debug/initrd.cpio.gz"
cmdline  = "console=ttyS0 debug loglevel=7 nokaslr"

[profiles.peios-stable]
kernel   = "/build/peios-stable/bzImage"
initrd   = "/build/peios-stable/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"

Tests pick:

provium:vm("v",        "peios")
provium:vm("debug-v",  "peios-debug")
provium:vm("stable-v", "peios-stable")

A test can mix profiles in the same file — useful for compatibility testing:

test("stable client can talk to mainline server", function(t)
    local lan    = provium:bridge("lan")
    local server = provium:vm("server", "peios"):boot()
    local client = provium:vm("client", "peios-stable"):boot()
    lan:attach({server, client})
    server:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
    client:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

    -- Run a real protocol against the mismatched pair.
    server:run_async("python3", {args = {"-m", "http.server", "8080"}})
    wait_until(function()
        return server:run("ss -ltn | grep :8080"):ok()
    end, {timeout = "5s"})
    client:run("curl -s http://10.0.0.1:8080/"):assert_ok()
end)

Profile choice patterns #

Default vs debug #

The most common split: a fast peios build for the default loop, and a peios-debug build with loglevel=7, nokaslr, and (probably) KASAN/UBSAN enabled. Use the debug profile when reproducing a kernel bug:

test("debug build reveals the leak", {tags = {"slow", "debug"}}, function(t)
    local vm = provium:vm("v", "peios-debug"):boot()
    vm:run("…workload that leaks…")
    local r = vm:run("dmesg | grep -i 'BUG:'")
    -- KASAN / UBSAN reports show up here in the debug build.
end)

Cross-version compatibility #

Two profiles, one per release line. Ship a small handful of cross-version tests:

test("rolling upgrade: stable client + mainline server", function(t)
    -- … as above …
end)

test("rolling upgrade: mainline client + stable server", function(t)
    -- … inverse …
end)

Tag these cross-version and gate via --no-tag cross-version in the dev loop. CI runs them on a nightly cadence with --include-slow.

Feature-flag gating #

Two profiles built from the same source with one feature toggled:

[profiles.peios-prefeatx]
kernel  = "/build/peios-prefeatx/bzImage"   # FEATX disabled
# …

[profiles.peios-postfeatx]
kernel  = "/build/peios-postfeatx/bzImage"  # FEATX enabled
# …

Tests that exercise FEATX-specific behaviour pick peios-postfeatx; regression tests run against both.

test("featx changes API behaviour", function(t)
    local before = provium:vm("v1", "peios-prefeatx"):boot()
    local after  = provium:vm("v2", "peios-postfeatx"):boot()

    local r1 = before:run("…")
    local r2 = after:run("…")
    t:assert_neq(r1.stdout, r2.stdout)
end)

Per-VM cmdline overrides #

The profile's cmdline is the default; kernel_cmdline in vm:boot(opts) overrides it for one VM:

local vm = provium:vm("v", "peios")
vm:boot({kernel_cmdline = "console=ttyS0 quiet maxcpus=1 isolcpus=0"})

This is useful for one-off testing of cmdline-sensitive features. For cmdline configurations you use repeatedly, declare a dedicated profile.

Default profile selection #

A few CLI subcommands take an optional profile arg — when omitted, they fall back to "the first profile in provium.toml sorted by name":

SubcommandBehaviour
provium repl --fixture <path> (no profile)First profile by sorted name.
provium fixture build <path>Uses the first profile's kernel/initrd as the cache-key kernel inputs.

Warning

With more than one profile configured, provium fixture build folds only that first profile into the key, while the test runner folds every profile — so a pre-warmed entry may not be the one the runner looks up. Pre-warming is reliable on single-profile configs.

To make the default predictable, name your most common profile so it sorts first alphabetically. aaa-default is ugly but it works; peios is fine when it's your only profile.

What happens to the cache when profiles change #

The fixture cache key folds in the kernel and initrd identifiers of every profile in provium.toml, sorted by name for determinism (the full key model is on fixtures and dependencies). Practical consequences for profile changes specifically:

ActionCache effect
Edit a kernel image (any profile)Every fixture invalidates.
Edit an initrd image (any profile)Every fixture invalidates.
Add a new profileEvery fixture invalidates (the new profile's kernel/initrd are folded in).
Remove a profileEvery fixture invalidates.
Rename a profileUsually invalidates everything (the fold order follows profile names).
Edit cmdline on a profileCache is unaffected (cmdline isn't in the key).
Change cache_dirCache is unaffected — the new dir is just empty.

This is deliberately conservative. A new profile means a new kernel could behave differently, so the existing fixtures might be subtly stale. Rather than guess, the harness rebuilds.

If you need to add a profile without invalidating the cache, you can't. Either accept the rebuild or use a sibling provium.toml with --config alt-config.toml.

Multi-arch profiles (preview) #

In v1, the QemuVmm backend invokes qemu-system-x86_64 exclusively — there's no per-profile arch field yet. To run on aarch64, you'd have to swap the binary at the harness level (not currently exposed).

When multi-arch lands, the profile is the natural place for an arch = "x86_64" / arch = "aarch64" field.

See also #

  • provium.toml reference — every field.
  • VMs and profiles — provium:vm(name, profile, opts?).
  • Fixtures and dependencies — what triggers a fixture rebuild.

Dynamic profiles

Provium / Configuration

A static profile points at artifacts that already exist on disk:

[profiles.peios]
kernel  = "/build/peios/bzImage"
initrd  = "/build/peios/initrd.cpio.gz"
cmdline = "console=ttyS0 quiet"

That leaves a gap. You build the image with one tool, then run Provium with another, and nothing connects the two — so it's easy to test last week's image because you forgot to rebuild. A dynamic profile closes the gap: the profile owns a build command, Provium runs it, and the VM boots the fresh result.

This is the same idea as cmdline_file — let the test consume the authoritative build instead of a hand-copied snapshot — applied to the whole image. This page covers both.

Cmdline from the builder #

Start with the smaller case. Image builders usually emit a kernel command line of their own — peiso, for instance, writes one to out/root/boot/cmdline. If you copy that string into your profile's cmdline, the two drift the moment the builder changes it (a new init=, a new console). Point cmdline_file at the builder's file instead:

[profiles.peios]
kernel       = "../peiso/out/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "../peiso/out/initrd.img"
cmdline_file = "../peiso/out/root/boot/cmdline"

Provium reads the file at boot, collapses its whitespace to single spaces, and uses it as the command line. You can still add tokens inline — they're appended after the file, so they win for last-wins kernel parameters:

cmdline_file = "../peiso/out/root/boot/cmdline"
cmdline      = "loglevel=7"     # verbose, on top of whatever the builder set

No manual copy means nothing to forget to update.

Building the whole image #

The build field takes a shell command. Provium runs it — once, before any VM boots — and only then reads the profile's kernel / initrd / cmdline_file:

[profiles.peios-full]
build        = "peiso build manifests/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/initrd.img"
cmdline_file = "{out}/root/boot/cmdline"

Now provium builds peios-full before the suite runs, and tests boot exactly what was just built:

local vm = provium:vm("v", "peios-full"):boot()

The command runs with sh -c, from Provium's working directory, inheriting your environment and streaming its output straight to the terminal. Reference inputs (the manifest above) relative to the suite so the suite stays portable across checkouts — there is deliberately no "build working directory" knob to tie it to one machine's layout.

{out}: one directory, no drift #

Look again at the example: {out} appears in the build command's --out and in every path field. That's the point. {out} expands — once, when the config loads — to this profile's build-output directory, so the place the build writes and the places Provium reads are guaranteed to be the same directory. You can't update one and forget the other, because there's only one.

Where does {out} point? By default, a per-profile directory under the Provium cache:

$XDG_CACHE_HOME/provium/builds/<profile>/     # e.g. ~/.cache/provium/builds/peios-full/

Set build_out to pin it somewhere specific — but you still only write the path once:

[profiles.peios-full]
build_out    = "/tmp/provium-builds/peios-full"
build        = "peiso build manifests/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
# …

Provium creates the directory before running the build, but never wipes it — the build command owns its contents, so it's free to keep its own incremental-build caches there.

When the build runs #

You runWhat gets built
provium (the test suite)Every profile that declares a build, up front, before the scheduler starts.
provium console <profile>Just that profile.
provium repl <profile>Just that profile.
provium prepare [profile]That profile, or — with no argument — every profile with a build. No VM boots.

The test runner builds all dynamic profiles rather than only the ones a run will touch, because tests choose their profiles at runtime from Lua (provium:vm(name, profile)) — Provium can't know in advance which a given run needs. If you keep several dynamic profiles and want to build only one, use provium prepare <profile> followed by provium --no-build.

Two controls shape this:

  • provium prepare builds without booting. It even skips the pre-flight checks (/dev/kvm, networking, CAP_NET_ADMIN), so you can build an image on a machine that isn't set up to run VMs.
  • provium --no-build skips the hook entirely for one run — for when you know the artifacts are already current and just want to boot.

No staleness — that's the builder's job #

Provium does not try to decide whether your image is up to date. It runs the build command every invocation and trusts the builder to be cheap when nothing has changed. Tracking inputs, hashing sources, and skipping unnecessary work is what build tools are for; reimplementing that inside a test harness would only get it subtly wrong.

The practical consequence: if your builder does a full rebuild every time, provium pays that cost every time. That's usually fine — Provium tends to be the last gate you run, where a clean build is what you want anyway — but if it bites, make the builder incremental (or use --no-build between source changes). Don't expect Provium to shortcut it.

Fixtures rebuild when the image changes #

Provium's fixture cache keys each snapshot partly on the kernel and initrd's path, size, and modification time. A dynamic profile rebuilds its image every run, which usually changes those timestamps — so fixtures captured against the old image are treated as stale and rebuilt. That's correct (a freshly built kernel can behave differently), but it does mean a dynamic profile plus a large fixture tree pays a fixture rebuild each run on top of the image build. If that's too slow for the inner loop, --no-build keeps the image — and therefore its fixtures — warm between source changes.

Failures abort — never fall through #

A build command that exits non-zero stops the run:

provium: building profile `peios-full` → /home/you/.cache/provium/builds/peios-full
… builder output …
provium: profile `peios-full`: build command failed (exit status: 1)

This matters because a half-finished build often leaves the previous run's artifacts in place — they'd pass an existence check and boot cleanly, and you'd be testing a stale image while believing the build succeeded. Provium refuses to do that: a failed build is a failed run (exit code 2), full stop.

Worked example #

A self-contained suite that builds its own image. The manifest lives in the suite, so a fresh clone can provium with nothing else set up:

# test-suite/provium.toml
[provium]
roots = ["tests"]

[profiles.peios-full]
build        = "peiso build manifests/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/initrd.img"
cmdline_file = "{out}/root/boot/cmdline"
test-suite/
├── provium.toml
├── manifests/
│   └── peios-full.toml        # the image recipe, versioned with the tests
└── tests/
    └── smoke.test.lua

Day-to-day:

provium                       # build the image, then run the whole suite
provium --no-build            # skip the build; boot the last-built image
provium prepare               # build the image, don't run anything
provium console peios-full    # build, then drop to the guest's console

See also #

  • provium.toml reference — the build, build_out, and cmdline_file fields.
  • Profiles — patterns for static and multi-profile setups.
  • CLI reference — provium prepare and --no-build.

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.