Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail Ckclackatoo 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 features the binary needs (lua for the test framework, local-agent for the in-process VMM the runner uses) are on by default; --no-default-features exists only for building provium-host as a library without them.

The binary lands at target/release/provium. It is not usable on its own: every VM boot needs the agent overlay, a small cpio carrying a static provium-agent that Provium concatenates onto the guest's initrd at launch. Build it once:

rustup target add x86_64-unknown-linux-musl   # the agent is linked statically
scripts/build-overlay.sh                      # also needs cpio and gzip

That writes dist/agent-overlay.cpio.gz, which a binary run from target/ finds by itself. Optionally, install both onto your PATH:

cargo install --path provium-host --bin provium
scripts/build-overlay.sh -o ~/.cargo/share/provium/agent-overlay.cpio.gz

An installed binary looks for the overlay in <bindir>/../share/provium/. Anywhere else, point at it with PROVIUM_OVERLAY=/path/to/agent-overlay.cpio.gz or the profile's agent_overlay_path. Without it every boot fails with could not find agent-overlay.cpio.gz.

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 and QEMU snapshot-layout epoch 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, and does not return until the guest is answering again, so the next line of the test can use the VM straight away. A reset that returns is a guest that came back; one that does not is an error rather than a VM that quietly stops responding.

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). Returns in Shutdown after confirmation. If the guest does not finish within 60 seconds after accepting the request, the call errors without forcing it off; see the VM reference.

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

Each command runs in its own process group, and the timeout SIGKILLs that whole group. This matters most for the shell form: sh commonly forks its command rather than exec'ing it, so the work you asked for is a grandchild of the agent. If it survived the kill it would keep the captured stdout and stderr pipes open, and vm:run would stay blocked until it finished on its own — long past the timeout you set.

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.

The signal goes to the process you spawned, not to the process group it leads — proc:signal("usr1") reaches that one process, as you asked. So a shell that backgrounded its work keeps that work running. To take the whole tree down, give proc:wait a timeout: that path SIGKILLs the group.

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 #

A worker is a separate guest process with its own file table, and there is no worker-scoped File: worker:open_file is rejected by the agent, because a descriptor opened in the worker could not be reached by file:read and friends, which act through the parent agent.

Decide which of two things you actually need:

  • The file must be opened under the worker's credentials — a permission test, say. Open it from inside the worker with worker:syscall(openat, …), and read, write and close it with further syscalls, so the descriptor lives and dies in the worker process. The Worker reference has a complete example.
  • The test just needs to see or seed the file. Use the VM handle: vm:open_file, vm:read_file, vm:write_file. The worker's identity is not involved, and the File auto-registers with the test scope as usual.

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 three goals: put a block device in front of the guest, 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 #

A disk the guest can see is declared before the boot, because the guest's firmware and initramfs look for block devices long before there is an agent to call anything.

For one test — a blank disk to install onto, a filesystem you have damaged on purpose:

-- `scratch` asks Provium for a blank disk of that size. It creates the
-- file, owns it, and removes it with the VM.
local vm = provium:vm("v", "peios"):boot({
    disks = {{scratch = "1M", id = "target"}},
})
local disk = vm:disk("target")

To start from contents you have prepared — a seeded filesystem, a registry hive captured from an earlier boot — copy a template. Each VM gets its own copy, and the template itself is never written, so a fixture survives the test that used it:

local vm = provium:vm("v", "peios"):boot({
    disks = {{template = "fixtures/seeded.img", id = "target"}},
})

path is the third form and means something different: a file you own, taken verbatim and never created. A path that does not exist fails the launch naming the disk and the path, so a typo stays an error instead of quietly becoming a blank disk.

local vm = provium:vm("v", "peios"):boot({
    disks = {{path = "out/prepared.img", id = "target", readonly = true}},
})

For every test in a suite — a boot medium, an image that is the system under test — put it in the profile instead, beside kernel and initrd:

[profiles.peios]
disks = [{ path = "{out}/peios.iso", id = "medium", readonly = true }]

Either way the guest gets a virtio-blk-pci device, named /dev/vda, /dev/vdb, … in attachment order, with the profile's disks first. Both forms are documented in full under disks and Boot disks.

Important

vm:attach_disk({id = "extra", image = "out/extra.img"}) hot-plugs a disk after a managed QEMU VM has booted. QEMU reserves eight runtime disk ports at launch; when all are occupied, attachment reports a capacity error. A detached port becomes reusable after guest removal completes and its old block node is cleaned up. Without an image, or before boot, attach_disk only records a host-side attachment for sector access. Use the boot forms above for disks that must exist when firmware or the initramfs starts.

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

Cutting the power #

Everything above is host-side: fault_inject changes what read_sectors and write_sectors do, which is the test's view of the image rather than the guest's. For the question a write-ahead log actually poses — did what the guest fsynced survive, and did what it did not fsync correctly vanish? — the disk has to be mediated:

local vm = provium:vm("v", "peios"):boot({
    disks = {{scratch = "512M", id = "state", mediated = true}},
})

-- … the guest writes and flushes …

vm:disk("state"):power_cut()  -- drop everything it never flushed
vm:reset()                    -- and let the reboot clear its caches

Provium serves a mediated disk over an NBD server of its own and holds each write until the guest flushes it, which is what makes the cut exact. Killing the VM is not a substitute: unflushed data written through an ordinary drive sits in the host's page cache and outlives the process, so the test would find everything intact having proven nothing.

power_cut on a disk that was not booted mediated fails, naming the disk — a power cut that silently did nothing is exactly the failure this is here to prevent.

Making the guest's own I/O fail #

A power cut is one fault a mediated disk can serve. The rest make the guest's reads and writes fail or stall while it is running, which is how you exercise the error paths rather than the recovery path.

local vm = provium:vm("v", "peios"):boot({
    disks = {{scratch = "512M", id = "state", mediated = true}},
})
local disk = vm:disk("state")

test("the database survives its disk dying mid-transaction", function(t)
    -- The disk dies three commands from now — flushes included.
    disk:fail_after(3)

    local r = vm:run("sqlite3 /mnt/state/db 'insert into t values (1)'")
    t:assert(r.exit_code ~= 0, "the insert should have failed")

    disk:clear_policy()   -- and the disk works again
end)

Every verb is listed on the Disk reference. Two of them are worth keeping straight, because they model genuinely different hardware failures:

  • fail_writes() refuses new writes but still honours flushes — a disk that will not take data.
  • fail_after(n) counts flushes too, so it can fail an fsync: the guest is told that data it was promised was durable is gone. That is the fault a write-ahead log has to survive, and the one fail_writes deliberately cannot produce.

The fail_after counter starts from the call that arms it, not from boot. By the time a test runs, the guest has already issued hundreds of commands mounting its filesystem, so a count from boot would be unusable.

These are the guest's faults. fault_inject above is the test's, and the two sets are cleared separately — disk:clear_policy() for these, disk:clear_faults() for those.

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. This also addresses disks declared at launch. Guest removal is asynchronous, so wait for the device to disappear if the next assertion depends on its absence. 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 vm = provium:vm("v", "peios"):boot({
    disks = {
        {path = "/tmp/data.img", id = "data"},
        {path = "/tmp/logs.img", id = "logs"},
    },
})

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

Attachment order is device order in the guest, so data is /dev/vda and logs is /dev/vdb — or /dev/vdb and /dev/vdc if the profile already attached one of its own.

Use vm:disk(id) to look a disk up again anywhere later in the test:

vm:disk("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).
  • barrier — a file-scope coordination primitive; test-scope barriers are isolated.
  • claim — there is one claim per file wherever it is called from, so it neither walks parents nor needs to: a claim made inside a test is the file's claim.

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? Two reasons. The dispatcher won't oversubscribe — it tracks total RAM and CPU budget across files and only takes a claim when it fits, so a file that needs 4 VMs at 2 GiB each is not scheduled alongside other heavy files to OOM the host. And a claim is the file's whole VM budget: every boot in the file draws from it instead of reserving from the pool, so a claimed file never waits for a VM slot mid-file. Files that reserve per boot can deadlock a run — each holding one VM while it waits for another — and the pool refuses the boot that would complete such a cycle; see pools and parallelism.

Claim the file's peak: the most VMs alive at once, each counted as its memory plus 100 MiB of VMM overhead and its vCPUs. A boot past the claim fails at once rather than waiting.

-- Never more than 3 VMs alive at once, 1 GiB and 2 vCPUs each:
provium:claim({memory = "3300M", cpus = 6})  -- 3 × (1G + 100M overhead); 3 × 2 vCPUs

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

Keeping a Lua reference to a test-scope resource does not extend its lifetime. For example, a stream opened inside a test on a file-scope VM is closed when that test ends; the VM remains available to later tests. Cleanup continues through the remaining resources if an individual close fails.

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.

The timeout is a budget for the file's own work. Time the file spends queued in the resource pool — at its provium:claim, or at a vm:boot() when it has no claim — is not counted; see pools and parallelism.

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

  1. The fixture file's source bytes, wire-protocol version and QEMU snapshot-layout epoch.
  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.

The QEMU layout epoch invalidates cached fixtures when the generated machine topology changes. Launch disks now use individual PCIe root ports, so fixtures from the previous layout rebuild automatically. Separately saved snapshots from that layout must be recreated; PCI addresses have changed.

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 reserved whole, when the call runs, and released at file completion. A second :claim errors — lab claim already held; one-shot per lab.

A claim is the file's whole VM budget. Every vm:boot() in the file — at file scope, inside a test, in a sub-lab — takes its memory and vCPUs out of the claim and touches the pool not at all, so a claimed file never queues once its claim is in. A boot the claim cannot cover fails at once, with needs … but the file's claim is … with … already in use; raise provium:claim to the file's peak, rather than falling back to the pool and waiting.

Without a claim, the dispatcher admits the file on the per-file overhead default (50 MiB memory, 0 CPU) and every boot reserves from the pool on its own, at boot time. A boot reserves what the VM will run with: its declared memory and cpus, or the launch defaults of 512 MiB and one vCPU where it declares neither, plus 100 MiB of VMM overhead. That is the only way an unclaimed file is kept from oversubscribing the host, and it has a cost, explained under Deadlock below.

Rule of thumb: claim the file's peak — the most VMs it ever has alive at once — as the sum of their memory budgets plus 100 MiB of VMM overhead each, and the sum of their vCPUs. Nothing is gained by claiming more, and a boot past the claim fails.

-- Never more than 3 VMs alive at once, each 2G and 2 CPUs:
provium:claim({memory = "6300M", cpus = 6})  -- 3 × (2G + 100M overhead); 3 × 2 vCPUs

Deadlock #

A file that reserves per boot holds what it has while it waits for more. Picture a testset where every file boots one VM at file scope and then, inside a test, boots a second: once enough files are running to hold the pool's whole CPU budget between them, every one of them is waiting for a second VM and none will ever release its first. That is a hold-and-wait deadlock, and it is what a large unclaimed testset does on a host with fewer cores than files. Every chapter passes when run alone, because a few files can never exhaust the pool; the whole set stalls.

The pool sees this coming. It knows what every file holds and whether that file is parked waiting, so when a boot would leave every holder waiting with nothing free that any of them could use, that boot fails immediately instead of parking:

vm `second`: cannot boot: the pool is deadlocked: 12 files hold 13.2 GiB and 12 cpus of its
25.5 GiB and 12 cpus and every one of them is waiting for more, while the 12.3 GiB and 0 cpus
still free serves none of the 12 waiting requests (this one wants 1.1 GiB and 1 cpu); declare
this file's peak with provium:claim so it is scheduled whole and no boot waits mid-file

The file whose boot closed the cycle takes the failure; the others are served as it winds down. The remedy is the one the message names: claim. A claimed file reserves everything it will need before it boots anything, so it is never a holder waiting for more.

Without the verdict, a deadlocked run only ends when the per-file timeouts fire and lab.shutdown() kills the VMs the stalled files were holding — which shows up as a cascade of VM is shutdown; create a new one failures in the tests that follow, and looks like host contention rather than scheduling. With --timeout 0 it never ends at all.

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 claimed file is accounted per file: the claim is held whole from the moment it is taken until file completion, however many VMs are alive at any moment, and each boot draws from it rather than from the pool.

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

test("a", function(t)
    local vm = provium:vm("v", "peios"):boot()
    vm:shutdown()  -- its share goes back to the claim, which the pool still holds whole
end)

test("b", function(t)
    local vm = provium:vm("v", "peios"):boot()  -- draws from the claim again
end)

The claim doesn't release between tests. An unclaimed file is accounted per VM instead — each boot reserves from the pool and each shutdown releases — which is finer-grained but is what makes a deadlock possible when many such files run together.

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

File timeouts and queueing #

The per-file timeout (--timeout, default five minutes) is a budget for the file's own work. Time a file spends parked in the pool — at its claim, or at a boot when it has no claim — is excused: the deadline moves out by exactly as long as the file has waited, a wait still in progress included. A file is never killed for queueing, only for being slow once it has what it asked for.

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 stringone of kernel / rootPath 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.

May be omitted when root names a composed Peios root, in which case the kernel is found inside it.

root #

[profiles.kernel-only]
root = "{out}/root"
TypeRequiredDescription
path stringone of kernel / rootA composed Peios root — what peiso root writes. The kernel is taken from usr/lib/modules/<release>/vmlinuz-<release> inside it.

Set this instead of kernel when the profile's build composes a root, so the profile does not also have to know where in that root a kernel lands. A kernel version bump then moves nothing in the config. kernel, when set, wins.

Resolution happens at VM-boot time, after the build has run — which is the only order that works, since the build is what produces the root. Two kernels in one root is an error rather than a choice made quietly: set kernel to say which.

initrd #

[profiles.peios]
initrd = "/build/peios/initrd.cpio.gz"
TypeRequiredDescription
path stringnoPath to an initramfs. Booted via QEMU's -initrd. Omit it to boot the agent overlay alone — see No initrd at all.

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.

No initrd at all #

Omitting initrd means the VM has no userspace of its own: the agent overlay is the whole initramfs. Nothing is concatenated and nothing is cached — the overlay file is handed to QEMU as it stands. It carries the agent but no /init, so the agent comes up as PID 1 in its standalone mode and performs the pseudo-FS mounts itself.

That is the shape a kernel conformance suite wants, where the point is that nothing but the kernel is reachable from a test. It also saves such a project vendoring an initrd cpio it would never otherwise need.

inject_agent = false with no initrd is rejected: without injection there is no overlay to stand in for the missing file, and so nothing to boot.

disks #

[profiles.peios]
disks = [
  { path = "{out}/peios.iso", id = "medium", readonly = true },
]
TypeDefaultDescription
array of tables[]Block devices attached to the guest for the whole of every boot under this profile, in order. Each emits a -drive if=none / -device virtio-blk-pci pair, so the guest sees them as /dev/vda, /dev/vdb, … by position.

Per disk:

KeyTypeDefaultDescription
pathpath stringrequiredHost path of the backing image. Provium never creates one — a missing file fails the launch with disk \`: no image at ``. Rebased against the profile's directory and {out}-expanded like kernelandinitrd`.
idstringdisk<N> by positionThe QEMU drive id, and the name vm:disk(id) looks the attachment up under.
readonlyboolfalseAttach read-only, so a guest write is refused rather than modifying the image every later boot in the run then reads.
mediatedboolfalseServe the disk through Provium rather than handing QEMU the file, so disk:power_cut() can discard exactly what the guest never flushed. See below.

Put a disk here when the image is part of the system under test rather than part of a test. A boot medium an initramfs scans for is a sibling of initrd and root: every test boots the same one, and the profile's build already owns keeping it fresh. Put it in vm:boot({disks = …}) when it varies per test — a blank disk to install onto, a deliberately damaged filesystem.

A disk cannot come from vm:attach_disk, which runs after boot. Anything the guest's firmware or initramfs does with a block device happens long before there is an agent to call that, so a medium the boot itself depends on has to be on the bus at the guest's first instruction.

Ids are one namespace across the profile's disks and the boot's, because they end up on one QEMU command line. A duplicate within the profile is rejected when the config loads; a boot disk reusing a profile id fails the launch with disk id \` is already attached by the profile`. Profile disks are always attached first, so the medium a profile boots from keeps its device name no matter what a test adds.

Mediated disks #

A disk with mediated = true still reaches the guest as an ordinary virtio-blk device. What changes is who is underneath it: Provium serves the image over an NBD server of its own and holds each write until the guest flushes it, so disk:power_cut() drops precisely the writes that were never made durable.

Killing the VM is not a substitute for that. Unflushed data written through an ordinary drive sits in the host's page cache, which outlives the process — so a test that killed QEMU would find everything intact and report durability it had never demonstrated.

The drive is attached cache=none as a consequence rather than as a tuning choice: a read QEMU could satisfy from its own cache is a read Provium never sees.

Mediation is opt-in per disk. power_cut on a disk without it fails naming the disk, rather than quietly doing nothing.

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.

agent_boot_timeout #

[profiles.peios]
agent_boot_timeout = 8.0
TypeDefaultDescription
float (seconds)30How long vm:boot waits for the in-VM agent to answer before declaring the boot failed. Must be positive.

Thirty seconds is a generous ceiling for a guest that is going to come up, and a long time to wait for one that is not. Lower it on a profile whose guest boots quickly and whose tests deliberately boot configurations that cannot reach an agent — an init that halts, an image with no init at all — because for those the timeout is not a safety margin but the length of every passing test. vm:boot's agent_timeout overrides it for a single boot.

Raise it instead for a guest that legitimately takes longer than thirty seconds: a large image, a slow filesystem check, a host under heavy parallel load.

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; vm:boot({kernel_cmdline_append = "..."}) keeps it and adds tokens after it.

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 = "../dist/peios-experimental-2026.8/root/usr/share/live-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 iso specs/peios.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-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.

from_dir #

[profiles]
from_dir = "profiles"

Reads profiles from a directory instead of writing them inline. Each subdirectory holding a profile.provium.toml is one profile, named after the directory:

provium.toml
profiles/
  kernel-only/
    profile.provium.toml
  full-system/
    profile.provium.toml

gives the profiles kernel-only and full-system. A subdirectory without that file is not a profile and is skipped rather than rejected, so a profile directory may keep whatever else it needs beside its config. A from_dir that names a directory holding no profile at all is an error.

The file's contents are the fields above — everything from [profiles.<name>] except the name, which the directory supplies.

Adding a testset becomes mkdir, with nothing central to keep in sync. That matters most for a repository whose whole purpose is holding many of them.

from_dir and inline blocks may both appear. A discovered profile whose name collides with an inline one is an error, not a silent winner.

What a discovered profile's paths are relative to #

Its own directory — both the paths it names and the directory its build runs in. An inline profile keeps resolving against Provium's current directory, as it always has.

This is the rule that makes a profile directory self-contained: it can be moved, copied or renamed and still mean what it says. It also means the same relative path means the same thing whether it is written in the shared config or in a profile beside it, which is not true if everything resolves against one directory.

{out} paths are exempt, since a build-output directory is absolute and has nothing to do with where the profile lives.

# profiles/kernel-only/profile.provium.toml
#
# Both specs are named relative to this directory, and the command runs
# here, so `../../peiso.toml` is the repository's.
# `--out` must not already exist, and Provium never wipes {out} —
# clearing it is the build's own job.
build   = "rm -rf {out}/root && peiso root ../../peiso.toml peiso.toml --out {out}/root"
root    = "{out}/root"
cmdline = "console=hvc0 quiet panic=1"

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 and kernel_cmdline_append.
  • Profiles — patterns for using multiple profiles.

Profiles

Provium / Configuration

A profile names what a VM boots — a kernel, an initramfs, a command line, a guest OS — 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)

One directory per profile #

Profiles do not have to be written in provium.toml at all. [profiles] from_dir reads them from a directory, one subdirectory each:

[provium]
roots = ["tests"]

[profiles]
from_dir = "profiles"
profiles/
  kernel-only/
    profile.provium.toml     # the profile
    peiso.toml               # whatever else it needs, beside it
  full-system/
    profile.provium.toml
    peiso.toml

Everything a discovered profile names is relative to its own directory, and its build runs there. So a profile directory is self-contained: it can be moved, copied or renamed without rewriting what is inside it, and adding one is mkdir rather than an edit to a central list.

Prefer this when profiles carry files of their own — an image spec, a package manifest, a vendored initrd — and especially when the repository's purpose is holding many of them. Prefer inline blocks when a profile is three lines pointing at paths that already exist.

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.

Booting only a kernel #

A profile that names no initrd boots the agent overlay alone: the agent is PID 1 and there is no userspace at all. Paired with a root composed to hold nothing but the kernel, that gives a VM in which the only thing a test can reach is the kernel — so a failure is attributable to it and not to something in userspace that happened to be installed.

# profiles/kernel-only/profile.provium.toml
build   = "./build.sh {out}"
root    = "{out}/root"
cmdline = "console=hvc0 quiet panic=1"

The build is a script beside the profile rather than a peiso root one-liner because Provium runs it on every invocation: --out must not already exist, Provium never wipes {out}, and composing unconditionally can cost far more than the tests do.

No kernel either: Provium finds it inside the composed root. Tests then drive the kernel directly, through the agent's syscall interface rather than through any command:

test("stratafs refuses a duplicate stratum", function(t)
    local vm = provium:vm("v", "kernel-only"):boot()
    -- … mount(2) via the agent, assert on errno …
end)

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.

To add a parameter rather than restate the whole line, kernel_cmdline_append keeps the profile's tokens and puts yours after them:

vm:boot({kernel_cmdline_append = "maxcpus=1"})

Where an appended token repeats one the profile already sets, the kernel takes the last occurrence, so the appended value wins.

Default profile selection #

One subcommand takes an optional profile arg and falls back to "the first profile in provium.toml sorted by name" when you omit it:

SubcommandBehaviour
provium repl --fixture <path> (no profile)Boots the first profile by sorted name.

That is a boot-time choice and nothing more. It does not narrow the fixture cache key: the test runner, provium fixture build, provium fixture rebuild, provium fixture stale and provium repl --fixture all fold every profile's kernel and initrd into the key, so they address the same cache entry however many profiles you have configured. Pre-warming in CI with provium fixture build warms the entry the runner then reads.

To make the REPL 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 — a peiso build, for instance, carries the one its boot package ships at root/usr/share/live-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       = "../dist/peios-experimental-2026.8/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "../dist/peios-experimental-2026.8/root/system/boot/initramfs.cpio.zst"
cmdline_file = "../dist/peios-experimental-2026.8/root/usr/share/live-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 = "../dist/peios-experimental-2026.8/root/usr/share/live-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 iso specs/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-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, inheriting your environment and streaming its output straight to the terminal. It runs from Provium's working directory — or, for a profile discovered via from_dir, from that profile's own directory. Either way, reference inputs (the manifest above) relative to that directory, 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 iso specs/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.

Making a builder incremental #

Some builders have no incremental mode to reach for. peiso root is one: it composes from scratch and refuses a --out that already exists, so the obvious command has to clear its own output first —

build = "rm -rf {out}/root && peiso root shared.toml peiso.toml --out {out}/root"

— and that is a full compose on every provium run, however small the change. Where the tests themselves take a second, the build can be most of a minute of it.

The fix is a script beside the profile that decides for itself:

#!/bin/sh
# ./build.sh {out}
set -eu
out="$1"; root="$out/root"; stamp="$out/.build-stamp"

# Whatever the composition depends on. Name, size and mtime rather than
# content: a rebuilt package always moves one of them, and hashing a
# kernel package on every test run would defeat the point.
fingerprint() { cat shared.toml peiso.toml; ls -lL ../../pkgs/out/; }

new=$(fingerprint | sha256sum)
[ -d "$root" ] && [ "$new" = "$(cat "$stamp" 2>/dev/null)" ] && exit 0

rm -f "$stamp"          # an interrupted compose must not leave a stamp
rm -rf "$root"          # claiming the tree beside it is current
peiso root shared.toml peiso.toml --out "$root"
printf '%s' "$new" > "$stamp"
build = "./build.sh {out}"

A discovered profile runs its build in its own directory, so ./build.sh and the paths inside it mean what they say where they are written.

Two details matter. Remove the stamp before the work rather than only writing it after, so a compose interrupted halfway is not mistaken for a current one on the next run. And keep the fingerprint cheap enough to run unconditionally — it is paid on every invocation, including the ones that do nothing.

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 peiso spec lives in the suite, so a fresh clone can provium with nothing else set up:

# my-suite/provium.toml
[provium]
roots = ["tests"]

[profiles.peios-full]
build        = "peiso iso specs/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-boot/cmdline"
my-suite/
├── provium.toml
├── specs/
│   └── peios-full.toml        # the image spec, 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
                       ├─:wakeup()→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>. Default 512M, and the default is what the pool is charged when nothing is declared.
cpuslab:vm optsintvCPU count. Hands to QEMU as -smp <n>. Default 1, charged to the pool like memory.
kernel_cmdlinevm:boot optsstringReplaces the profile's cmdline.
kernel_cmdline_appendvm:boot optsstringTokens appended after the resolved command line (the profile's, or kernel_cmdline). Use it to add one parameter — maxcpus=1, a module option — without restating the profile's line; where a token repeats one already present, the kernel takes the last, so the appended one wins.
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, mode=int}Files to place in the guest's filesystem before init runs. mode is the permission mask and defaults to 0644; pass 0x1ed (0755) for something the guest will execute. See Injected files.
disksvm:boot optsarray of {id=string, readonly=bool, mediated=bool}, each naming exactly one of path=string, scratch=size, template=stringBlock devices attached for this boot only, after the profile's own. id defaults to boot<N> by position; readonly and mediated default to false. path is yours and is never created; scratch and template are made and owned by Provium. See Boot disks.
agent_timeoutvm:boot optsnumber (seconds)How long to wait for the agent on this boot, overriding the profile's agent_boot_timeout.

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

Injected files #

Each entry is written into the guest's root filesystem before its init runs, with the parent directories it needs. A path that already exists in the image is replaced, so a test can substitute a configuration file, a script, or a whole hook set without rebuilding the profile.

They arrive as one more cpio archive appended to the initrd, which is how the kernel is willing to receive them: an initrd is a sequence of archives unpacked in order into the same rootfs, and the last writer of a path wins. Injected files are appended after the agent overlay, so they win over both the profile's initrd and the agent's.

Two consequences follow from that mechanism, and neither is a limitation worth working around:

  • They land in the initrd's rootfs, not in whatever filesystem the guest's init later mounts as its real root. For a profile whose initrd is the whole system this is the same thing. For one that pivots to a disk or a squashfs, an injected file is what the initramfs stage sees, which is usually exactly the point — it is how you hand an early boot stage a different configuration.
  • They are placed before any userspace runs, so a guest cannot race them, and equally they cannot depend on anything a guest process does.

Paths are taken relative to the root whether or not they carry a leading slash. A path containing . or .. is rejected when the boot options are parsed, with the offending path named.

Boot disks #

Each entry becomes a virtio-blk-pci device present from the guest's first instruction, so the firmware and the initramfs can both see it — which is the difference between these and vm:attach_disk, whose attachment is created after the guest is already running.

Boot disks are attached after the profile's disks, so the profile's keep their device names: a test's first disk is /dev/vdb on a profile that already attaches a medium at /dev/vda. Ids share one namespace with the profile's, so a boot disk reusing a profile id fails the launch rather than silently displacing it.

Each entry names exactly one source, and the choice decides who owns the file:

KeyMeans
pathA file you own. Taken verbatim and never created: a path that does not exist fails the launch naming the disk and the path, so a typo stays an error rather than a silently blank disk.
scratchA blank image Provium creates at this size — "512M", "2G", or a plain byte count, the same grammar --mem takes. Raw and sparse, so it costs nothing until the guest writes to it.
templateA copy of a file you have prepared. Each VM gets its own copy and the template is never written, so a fixture survives the test that used it.

A scratch or template image belongs to its VM: isolated from every other VM, removed when the VM goes, and surviving vm:reset() — a warm reboot against the same backing files — so a test can cut the power and ask what came back. Naming two sources, or none, fails the boot.

An entry may also set mediated = true, which changes not where the bytes come from but who serves them: Provium puts itself between the guest and the image and holds each write until the guest flushes it, so disk:power_cut() discards exactly what was never made durable. It combines with any of the three sources above, and is described in full under disks.

Once booted, vm:disk(id) returns a Disk handle for any disk attached this way, for sector access and fault injection.

local vm = provium:vm("v", "peios")
vm:boot({
    disks = {
        {path = "/tmp/blank.img", id = "target"},
    },
})
vm:disk("target"):fault_inject("eio_write")

Lifecycle methods #

vm:boot(opts?) #

Boots the guest. Returns self so calls chain (provium:vm("a", "peios"):boot()). The optional opts table (kernel_cmdline, kernel_cmdline_append, rng_seed, initial_time, files, disks, agent_timeout) merges per field into the VM's pending boot options. Errors if already booted. Under the dispatcher the boot reserves the VM's memory (plus 100 MiB of VMM overhead) and vCPUs first — the declared memory and cpus, or 512 MiB and one vCPU where the VM declared neither — from the file's claim when it has one, otherwise from the pool — and errors with cannot boot: … when the boot is past the claim, or when reserving it from the pool would deadlock the run.

Boots that are expected to fail are asserted through the error this raises. A guest whose init halts, or never starts, never reaches the agent, so vm:boot fails once the agent timeout lapses — and its message carries the tail of the guest console, which is the only record such a boot leaves. Wrap the call in pcall and assert on that text, and set agent_timeout (or the profile's agent_boot_timeout) low enough that waiting for it is not the bulk of the run.

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.

Returns once the guest is serving its agent again, so the VM is usable on return and the next vm:run needs no waiting of its own. Errors if the guest has not come back within two minutes.

The waiting is not a convenience. A reset severs the agent connection by definition — the guest it was talking to no longer exists — and a call that returned before the guest was back left the caller to meet that severed connection. Provium treats a connection-class failure as the VM having died, and refuses every later operation on that basis, so the first post-reset call would strand the VM permanently even though the guest was moments away from answering. Waiting here absorbs the gap where it belongs.

A consequence worth knowing when reading a failure: because the call waits, a vm:reset() that returns is a guest that rebooted and came back. One that errors names which of those did not happen.

vm:is_suspended() #

Returns whether QEMU reports that the guest is in ACPI S3, without waking it. Requires Booted, the same lifecycle state as wakeup; host-side Paused is a different state and is rejected. A running guest returns false. Backends without suspend-state support return an unsupported-operation error. Monitor failures propagate as errors, so polling cannot mistake a dead VM for a guest that has not suspended yet.

Use this query to wait for S3 when a test needs to leave the guest asleep for a measured interval before waking it. The query uses the monitor and remains available while the guest agent is suspended. Guest RPCs do not: a timer in another awake VM can supply the interval.

vm:wakeup() #

Wakes a guest that suspended itself — one that wrote mem to /sys/power/state and is sitting in ACPI S3 with its vCPUs halted. Sends QMP system_wakeup; the guest resumes where it stopped. Errors if the VM is not in Booted (a suspended guest is still Booted from the host's side: its vCPUs stopped, not its VMM), and errors if the guest is not actually suspended — QEMU refuses to wake a running guest. Retrying wakeup resumes the guest at the first opportunity; it does not guarantee any minimum time asleep.

The suspend itself is the guest's own act, and the syscall that performs it does not return until the wake — issue it through vm:syscall_async or a worker, wake from the host, then await it:

local pending = worker:syscall_async(sys.NR.write, ...)   -- "mem" → /sys/power/state
wait_until(function() return vm:is_suspended() end, {timeout = 5})
timer_vm:clock():sleep("2s")  -- a separate, awake VM
vm:wakeup()
pending:await()

vm:power_button() #

Sends ACPI power-button to the guest and waits for QEMU to confirm a new guest-initiated shutdown. Errors if not in Booted. On success, the state is Shutdown and the VM's scheduler reservation is released. The captured console remains readable, so a test can also inspect the guest's shutdown messages.

After the monitor accepts the request, Provium waits up to 60 seconds for the guest to finish shutting down. A guest may ignore ACPI: if that wait expires, the call errors, leaves the VM in Booted, and does not force it off. This is distinct from a failed monitor command or lost connection, which can mark the VM Dead. Booted after a timeout does not prove that the guest is responsive or that a late shutdown cannot still happen.

Only a fresh SHUTDOWN event with guest=true and reason="guest-shutdown" confirms completion. A previous event, guest reset or host-initiated termination is not confirmation of graceful poweroff.

vm:power_button()
assert(vm:state() == "shutdown")
local shutdown_log = vm:console():read_log()

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. On expiry the agent SIGKILLs the command's process group, so anything it forked dies with it.
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 disk to a running VM and returns a Disk userdata for sector access and fault injection against its backing file. Given an image on a booted or paused VM, the guest gets a real virtio-blk-pci device it can see; without an image, or on a VM that has not booted, the attachment is recorded host-side only, which is all sector access and fault injection need. What it cannot do is be there from the guest's first instruction — firmware and the initramfs look for block devices long before there is an agent to call anything — so a disk the guest must find at boot belongs in the profile's disks or in vm:boot({disks = …}). Opts: {id=string, size=int, image=string, readonly=bool}. Defaults: id = attached-<vm_name>, size = 4 GiB, image = no backing file, readonly = false.

Managed QEMU VMs have eight reusable runtime disk ports in addition to their launch disks. An occupied pool returns a capacity error; a detach request does not free a port until the guest completes removal. See the Disk reference for capacity, cleanup and retry behavior.

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

CIDs are unique across provium processes running side by side on one host: each process counts from a start scattered by its pid, and a CID is reserved by creating its per-VM scratch directory (/tmp/provium-qemu/vm-<cid>) atomically before QEMU launches, so two processes can never hand one guest CID to two VMs.

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. The claim is per file, not per lab: one made from a sub-lab or from inside a test is the file's claim. 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 call blocks until the pool can afford the claim, and errors with lab claim exceeds total pool budget if it never could. Time spent blocked here is not charged to the file's timeout.

Once held, the claim is the file's VM budget: every vm:boot() and lab:boot() in the file takes its memory (plus 100 MiB of VMM overhead per VM) and vCPUs out of the claim, gives them back on shutdown, and never reserves from the pool. A boot the claim cannot cover errors — vm x: cannot boot: needs … but the file's claim is … with … already in use; raise provium:claim to the file's peak — rather than waiting. A VM that declares no memory or cpus counts at its launch defaults, 512 MiB and one vCPU. Without a claim, each boot reserves from the pool on its own; see pools and parallelism for what that costs.

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 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:disk("id")Handle for a disk the VM booted with. Errors if absent.
vm:attach_disk({id=…, size=…, image=…})Hot-plugs into a running VM — see below.

A disk the guest can see is declared before it boots: in the profile's disks when the image belongs to the system under test, or in vm:boot({disks = …}) when it belongs to one test. vm:disk(id) then returns a handle for it.

vm:attach_disk adds a disk to a machine that is already running. With an image, on a booted or paused VM, the guest gets a real virtio-blk-pci device; without one — or before the VM boots — it records the host-side attachment alone, which is all sector access and fault injection address. The one thing it cannot do is be present from the guest's first instruction, so a device the guest must find at boot belongs in the forms above.

Managed QEMU VMs reserve eight additional PCIe root ports at launch for runtime disks. These are separate from the ports for profile and boot disks. A full pool returns a capacity error before adding another block node. Guest removal is asynchronous: disk:detach() does not immediately free a port, and a paused guest cannot complete removal until it resumes. A later attachment checks QEMU's PCI inventory, removes the old block node for a device that has disappeared, and reuses that port. If all ports are still occupied, wait for removal and retry. Creation and cleanup errors are reported, not treated as successful attachment. The pool is bounded per VM, not a limit on the total number of attach/detach cycles.

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, and so does the guest-visible device.
readonlyboolfalseAttach the hot-plugged device read-only.

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:power_cut() #

Discard every write the guest has not flushed. What is left is exactly what it made durable.

Unlike the fault modes above — which change what read_sectors and write_sectors do, and so act on the test's view of the image — this one acts on the guest's own I/O. It requires a disk booted with mediated = true; on any other disk it errors naming the disk, rather than quietly succeeding and letting a test conclude that data survived a crash which never happened.

The guest's page cache is untouched and still holds what it wrote, so read back after a reboot rather than before — otherwise the answer comes from the guest's memory instead of from the disk.

local vm = provium:vm("v", "peios"):boot({
    disks = {{scratch = "512M", id = "state", mediated = true}},
})

-- … the guest writes, and flushes some of it …

vm:disk("state"):power_cut()  -- everything unflushed is gone
vm:reset()                    -- and the reboot clears the guest's caches

Faults the guest can see #

Everything reached through disk:fault_inject changes what read_sectors and write_sectors do — the test's view of the image. The verbs below are served into the guest's own I/O, so the guest is what sees them fail.

They need a disk booted with mediated = true. On any other disk each errors naming the disk and the missing mediated, rather than quietly doing the weaker host-side thing: a test that watched its guest sail through an error the guest never actually saw would report that as resilience.

MethodEffect on the guest's I/O
disk:fail_reads()Every read returns EIO.
disk:fail_writes()Every write returns EIO. Flushes still work.
disk:fail_after(n)The nth command and every one after it returns EIO — reads, writes and flushes.
disk:fail_range(sector, count)Any command whose bytes overlap count sectors from sector returns EIO.
disk:delay(ms)Every command sleeps ms before it is served.
disk:clear_policy()Disarm all of the above.
disk:fault_policy()Read back what is armed, as a table.

Rules accumulate: disk:fail_reads() then disk:delay(20) gives a disk that is both slow and failing reads, the way bridge:add_latency and bridge:drop_rate compose. Only disk:clear_policy() removes them — disk:clear_faults() clears the host-side set and leaves these untouched.

fail_after is the one that can fail an fsync #

fail_writes refuses writes and leaves flushes working, which models a disk rejecting new data. fail_after counts flushes too, so it is the rule that can tell a guest its durable data is gone — the fault a write-ahead log actually has to survive.

The counter resets every time a policy is armed, so fail_after(1) means the first command after that call, not the first since boot. A booted guest has already issued hundreds, and a counter running from the server's start could never be aimed at the test's own workload. n must be at least 1.

Ranges fail whole commands #

A command overlapping the range fails entirely rather than partially: one NBD reply carries one error, so half a read is not expressible. count must be at least 1 — an empty range overlaps nothing, so it would arm a fault that could never fire, which is indistinguishable from a passing test.

fail_range never catches a flush, which carries no offset or length. Failing a flush is fail_after's job alone.

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.

Disks declared in the profile or in vm:boot({disks = …}) are addressable by their disk ID. Detaching them requests removal of the corresponding guest device. Removal is asynchronous and requires guest cooperation: the local detached flag does not prove that the guest has finished removing it. A test that depends on removal should wait for the guest device to disappear before continuing.

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() #

Pull the cable: take the VM's TAP off the bridge and issue a QMP set_link(false) against the matching netdev, so the guest sees its carrier go and nothing reaches it. The TAP itself is kept, because QEMU holds it open as the NIC's backend, and the VM stays a member of the bridge. Without a VM userdata at construction time (bare-string bridge:nic("name") form), the QMP step is skipped. On a bridge that is not realised yet there is no TAP, and the call is graph-state only: the VM is detached from the bridge.

Use bridge:detach(vm) to take a VM off a bridge for good: it deletes the TAP, so the NIC has nothing to talk through for the rest of the VM's life.

nic:reconnect() #

Reverse of :disconnect(). Plugs the TAP back into the bridge, brings it up, and issues a set_link(true), so the guest sees its carrier return.

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

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 — why there is no worker:open_file, and the syscall form that opens a file inside a worker.
  • 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.

When the timeout fires, the agent SIGKILLs the whole process group the process leads, so anything it forked or backgrounded goes with it. Without that, a surviving descendant would hold the captured stdout and stderr pipes open and :wait() would block past its timeout waiting for them to close.

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

The signal reaches the process you spawned only, not the process group it leads: a non-fatal signal is meant for the process you named. If the command is a shell that backgrounded its work, that work survives — use proc:wait(timeout), which SIGKILLs the group, to take the tree down.

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.

Not available for a process a worker spawned: its output is captured in the worker, and the worker channel cannot carry a stream. proc:wait returns it.

proc:close() #

Auto-close hook. If :wait() already happened, this is a no-op. Otherwise, sends SIGTERM to the process, then waits up to 2 seconds for exit. That wait is a proc:wait timeout, so a process still alive after 2 seconds takes its whole group with it when the agent escalates to SIGKILL.

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.

A worker is a real, separate guest process: a re-exec'd copy of the agent that the main agent relays ops to over a socket pair. Its syscalls run with its own credentials — its own kernel token, privileges and process security block — so a test can stand up two distinct security principals in one VM: a caller and the target of a process check, an unprivileged caller against a privileged operation, two peers on a socket. Handles a worker allocates live in its own table; the main agent routes them, so from the test's side they look like any other handle.

Constructing #

Workers also run as separate host processes when using the local-agent backend. The provium executable handles the worker protocol itself, so this backend does not need a separate installed guest-agent binary. Rust applications embedding LocalAgentVmm must select a worker-capable executable with LocalAgentVmm::new().with_worker_executable(path) if their own executable does not implement that protocol. The path may name provium or provium-agent.

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 child is forked and exec'd by the worker, so it starts life with the worker's credentials: whatever token the worker installed, whatever privileges it adjusted, is what the new program runs under. That is what makes exec-time behaviour testable from a principal the test minted — a token surviving or being relabelled at exec, a descriptor without FD_CLOEXEC crossing into the new image, a child's identity after its parent impersonated. proc:pid() gives the child's pid for the main agent to inspect from outside.

proc:wait, proc:kill, proc:pid, proc:status and the stdin methods all work; the main agent relays them to the worker, and the worker performs them — so they run with the worker's credentials at that moment. A worker that is impersonating another user, or has installed a lowered token, may find proc:kill on its own child refused with EPERM; revert or restore before reaping. proc:stdout_stream() and proc:stderr_stream() do not work — a stream owns its connection for its lifetime and the worker channel is a request/reply pipe — so read a worker-spawned child's output from the RunResult that proc:wait returns.

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. Joining the worker first orphans any child still running: it is reparented to PID 1 and the Process handle no longer reaches it.

worker:open_file(path, mode_table) — rejected #

There is no worker-scoped File. The call errors with worker_open_file: not supported for a process-isolated worker, and the reason is structural: a worker is a separate guest process with its own file table, so a descriptor opened there would be out of reach of file:read, file:write and file:close, which act through the parent agent.

Open files from inside the worker with worker:syscall instead, so the descriptor is created, used and closed in the worker's own process under the worker's credentials:

-- x86-64 syscall numbers: openat = 257, write = 1, close = 3.
local AT_FDCWD, O_WRONLY_CREAT_TRUNC = -100, 0x241
local r = w:syscall(257, {
    args = { AT_FDCWD, 0, O_WRONLY_CREAT_TRUNC, tonumber("644", 8) },
    bufs = { "/tmp/from-worker\0" },   -- NUL-terminated path
    ptrs = { 1 },                       -- buf 1 → arg slot 1
})
local fd = r.ret
w:syscall(1, { args = { fd, 0, 2 }, bufs = { "hi" }, ptrs = { 1 } })
w:syscall(3, fd)

If the file only needs to be read or written by the test, and the worker's identity does not matter, vm:open_file on the VM is the simpler call.

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

A timeout does not consume a partial Tail frame or mark the stream as EOF. The next read resumes the same frame, including when the timeout arrived partway through its length prefix or payload. This applies to file tails, file-descriptor streams, and process stdout/stderr streams. Interrupted transport reads are retried automatically.

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 timeout. 0 disables. Accepts "500ms", "30s", "10m", "2h". Time queued in the pool is not counted.
--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. The executable and arguments are quoted for a POSIX shell, preserving spaces, embedded quotes, shell metacharacters, and empty arguments.

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.