Writing tests
Single-page view · as markdown
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. = "10s" -- file-default per-test timeout
test
test
Two things to know up front:
- Each
test()body runs in its own scope. Theprovium: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-levellocal vm = provium:vm("v", "peios"):boot()) and look it up by name (provium:vm("v")) or capture the userdata as a Lua local. - Each test gets a fresh
tcontext.t.nameandt.metaare 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:
| Assertion | Use 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
Use t:assert_raises(fn) if you want the inverse — "this should raise":
test
Skipping #
Three ways to skip:
Inline (t:skip(reason)) #
test
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
test
The body never runs. Cleaner than inline when the test is permanently disabled or pending an unrelated change.
File-scope (todo("reason")) #
todo
test
test
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
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:
| Key | Purpose |
|---|---|
slow = true | Skip 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
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
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. = "30s"
test -- 30s
test -- 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. = true
local vm = provium::
vm: -- snapshot baseline includes this
test
test
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. = true
test
One-VM-many-tests, ordered #
When tests build on each other, leave reset_between_tests off and let state accumulate:
local vm = provium::
test
test
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::
return vm:
-- tests/uses-corpus.test.lua
test
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, pluswait_untilandtodo. - 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:
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:
Everything else about a boot — kernel command line, determinism seeds, injected files — is passed to vm:boot(opts) instead:
vm:
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::
: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::
local b = provium::
Or batch-boot via the lab:
provium:
provium:
provium: -- 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:
[]
= "/build/peios/bzImage"
= "/build/peios/initrd.cpio.gz"
= "console=ttyS0 quiet"
= "peios"
Each profile names a (kernel, initrd, cmdline) tuple. A test picks which profile to use by name:
provium: -- uses [profiles.peios]
provium: -- uses [profiles.peios-debug]
You can have any number of profiles. Common patterns:
| Pattern | Profiles |
|---|---|
| Test against multiple kernel versions | peios-stable, peios-mainline |
| Compare optimised and debug builds | peios, peios-debug |
| Test pre/post a feature flag | peios-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:
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
Snapshots #
local s = vm: -- writes to a tempfile
local s = vm: -- 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
Restoring #
Restore from a Snapshot userdata or a bare path string:
vm:
vm: -- from snapshot userdata
vm: -- 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:
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:
local before = vm:
vm:
Reset and power-button #
vm:reset() warm-reboots the guest — same VM, same RAM image initially, then init re-runs. Stays in Booted.
vm:power_button() sends ACPI power-button. The guest's init handles it as a graceful shutdown signal (typically: stop services, sync filesystems, kernel halts). Ends in Shutdown.
test
Multi-VM topologies #
The two-VM pattern is the workhorse of networking tests:
local lan = provium:
local a = provium::
local b = provium::
lan:
a:
b:
test
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:
r:
t:
Two call shapes:
Shell form: vm:run(string) #
vm:
vm:
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: -- canonical
vm: -- 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:
vm:
vm:
Combine freely:
local r = vm:
t:
t:
env_clear = true makes the guest see ONLY the keys you supplied:
vm:
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: -- TimedOut
vm: -- Same
vm: -- Same
vm: -- 5s
vm: -- 1 minute
When the timeout fires, the agent kills the process and returns a RunResult with status = "timed_out", timed_out = true, and exit_code = -2. Use r.timed_out to disambiguate from a clean exit with code -2.
local r = vm:
if r. not r:
RunResult fields and helpers #
Most tests only need three things from a RunResult:
local r = vm:
r: -- raise if not ok; message includes status, stdout, stderr
r. -- captured stdout (bytes)
r: -- 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:
-- … do other things …
proc:
local r = proc:
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:
proc:
proc:
proc:
local r = proc:
t:
Streaming stdout / stderr #
local proc = vm:
local out = proc:
out:
-- now exercise the server
See streams and tails for the full stream API.
Signals #
proc: -- defaults to SIGTERM
proc: -- SIGKILL by number
proc: -- by name
proc: -- SIGUSR1
proc: -- alias for kill(); reads better for non-fatal signals
Signals are accepted by friendly name (term, kill, usr1, …), with a sig prefix (sigterm), or as a bare integer. The full recognised-name list is in the Process reference.
Inspecting the process #
proc: -- live kernel PID inside the guest
proc: -- opaque agent-side handle id
proc: -- non-blocking poll
pid() calls into the agent every time. handle() is a stable in-memory id that never changes.
Waiting #
proc: -- wait forever
proc: -- wait at most 5 seconds
proc: -- 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:
local w2 = vm:
-- Two writers in parallel.
local p1 = w1:
local p2 = w2:
p1::
p2::
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
"Run a server, hit it, tear down" #
test
"Compose multiple ops in one round trip" #
local results = vm:
-- 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:
local body = vm:
local meta = vm:
vm:read_file(path) #
Returns the entire file as a Lua string. Errors on agent-side read failure (ENOENT, EACCES, etc.).
local content = vm:
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:
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:
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:
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:
print -- 10
print -- POSIX mode bits
print -- "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:mkdir(path, opts?) #
Create a directory:
vm:
vm:
vm: -- 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:
h:
h:
local s = h:
h:
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:
The full mode-table reference (including append and exclusive / O_EXCL) is on vm:open_file.
Reading #
local h = vm:
local first = h: -- up to 64 bytes
local rest = h: -- drain to EOF
h:
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:
local n = h:
-- n is 11 (bytes actually written, may be less than #data on partial write)
h:
Seek and tell #
local h = vm:
h: -- absolute offset 10
h: -- relative +5 from current
h: -- 1 byte before EOF
h: -- current offset
tell() reports the authoritative agent-side position, not a host-side cache — the File reference explains how.
Closing #
h:
h: -- second close is fine; idempotent
h: -- 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: -- u64 handle id
vm:
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:
vm::
local line = stream:
t:
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:
h:
local stream = h:
-- 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:
local stream = vm:
Common patterns #
Writing then reading back #
test
Asserting permission bits #
Lua 5.4 has no 0o… literal. Use decimal or hex:
test
Tailing a log while the test acts #
test
Listing then filtering #
test
Files inside a worker #
worker:open_file(path, mode) allocates the file under the worker's namespace. The returned File auto-registers with the test scope:
local w = vm:
local h = w:
h:
h:
Otherwise the API is identical.
Batch I/O for low latency #
For many small ops back-to-back, batch them in one round trip:
local results = vm:
Each entry in results is {ok=value} or {err=msg}. A failure on one op doesn't short-circuit the rest. See VM batch for the per-op return shape.
See also #
- VM reference —
read_file,write_file,stat,mkdir,listdir,unlink,rename,open_file,tail_file,fd_stream. - File handle reference — every method on the File userdata.
- Streams and tails — patterns for tail streams.
Disks and fault injection
Provium / Writing tests
Provium's disk support has two goals: give the test direct sector-level access to the backing image, and inject faults that exercise the guest's error-handling paths.
The exhaustive method reference is on Disk.
Attaching a disk #
local img = "/tmp/test.img"
-- Pre-create a backing file; Provium does not auto-create.
io.::
local vm = provium::
local disk = vm:
The two opts that matter for fault-injection work are id (names the disk so you can re-look it up via vm:disk(id)) and image (the backing file — required for read_sectors / write_sectors). The full opts table, types, and defaults are in the Disk reference.
Reading and writing sectors #
Sectors are 512 bytes throughout. Offsets and counts are in sectors, not bytes.
-- Read sector 0 (the first 512 bytes).
local sec0 = disk:
-- Read 4 sectors starting at sector 100 (bytes 51200..53247).
local block = disk:
assert
-- Write at sector 50.
disk:
Without a backing image, both ops error with a no backing image — disk:with_image required message.
Fault injection #
Three modes:
| Mode | Effect |
|---|---|
eio_read | Every read_sectors short-circuits to EIO. |
eio_write | Every write_sectors short-circuits to EIO. |
slow | Every 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
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
The 50 ms delay is fixed per call; it is not currently configurable from test code.
Combine modes #
test
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: -- {"eio_read", "slow"}
disk: -- false
Clearing #
disk:
local r = disk: -- succeeds
Detaching a disk #
disk:
local ok = pcall
assert -- "disk is detached"
disk:detach() issues a best-effort QMP device_del against the parent VM and marks the local handle detached; after that, sector ops error. The exact behaviour for disks that were never QMP-added is in the Disk reference.
Common patterns #
"Does the guest retry after a transient EIO?" #
test
"Does the filesystem remount read-only after EIO?" #
test
"Does the guest panic on EIO at boot?" #
test
Multiple disks per VM #
local data = vm:
local logs = vm:
-- Inject EIO on data only; logs is unaffected.
data:
Use vm:disk(id) to look up an already-attached disk:
local data = vm:
data:
Caveats #
- Sector size is fixed at 512 bytes. Tests that need 4 KiB sectors should expect their guest to layer that on top.
read_sectorsandwrite_sectorsgo 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 modelledsizefromattach_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.
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:
local a = provium::
local b = provium::
lan: -- atomic; either all attach or none do
a:
b:
-- a can reach b.
a::
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:
local data = provium:
local a = provium::
local b = provium::
mgmt: -- both VMs on mgmt
data: -- both VMs on data too
-- a:nic("mgmt") and a:nic("data") return separate Nic handles.
local mgmt_nic = a:
local data_nic = a:
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: -- A↔B traffic dropped both ways
lan: -- restore
Symmetric partitions are graph-state — they install drop rules at boot via nft and lift cleanly.
Directional #
lan: -- only A→B dropped; B→A still flows
lan:
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: -- every pair partitioned
lan: -- every partition lifted
Inspect #
if lan:
Impairments #
Three knobs: latency, drop rate, bandwidth limit. Each accepts either a scalar (whole-bridge) or a directional table.
Latency #
lan: -- 50 ms one-way to every flow
lan: -- 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: -- ~10 % loss, both ways
lan: -- recorded for A→B
Bandwidth limit #
lan: -- 1 Mbit/s, both ways
lan: -- 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:
lan:
lan:
Reset #
lan: -- 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: -- current whole-bridge latency
lan: -- current whole-bridge drop rate
lan: -- 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:
local r = a:
-- r:ok() is false; a is isolated
lan:
a::
lan:is_isolated(vm) returns true if the VM is currently isolated.
NICs #
local nic = a: -- by bridge name
local nic = a: -- by guest-name index
local nic = lan: -- equivalent
The Nic gives you per-NIC capabilities the bridge can't. The ones you'll use most:
nic: -- per-NIC traffic counters, guest's perspective
nic: -- link-down via QMP set_link(false)
nic: -- 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:
a:
local frames = cap:
local pcap = table.
-- 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:
local cap = nic: -- 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:
vm::
lan:
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:
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
Test latency-sensitive code #
test
Test packet loss tolerance #
test
Inspect packet flow with capture #
test
See also #
- Bridge reference — every method on the Bridge userdata.
- Nic reference — per-NIC handle.
- Streams reference — what
bridge:capture()andnic: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 #
| Source | Type | Use for |
|---|---|---|
vm:tail_file(path, opts?) | Tail | Following a file as it grows. |
vm:fd_stream(fd_or_file) | Tail | Following an open file handle. |
file:tail_stream() | Tail | Following a file from its current cursor. |
proc:stdout_stream() / proc:stderr_stream() | Tail | Following an async process's output. |
bridge:capture() | Capture | Sniffing every packet on a bridge. |
nic:capture() | Capture | Sniffing one VM's TAP. |
console:read() | ConsoleStream | Reading 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:
vm::
local frame = stream:
print -- "Jan 1 00:00:00 v: event\n"
:read_until(pattern, timeout?) — read until a substring #
local line = stream:
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:
-- 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:
-- chunks is a Lua array of strings
local body = table.
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 #
| Scenario | Use |
|---|---|
| "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
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: -- start at end (default)
vm: -- replay from byte 0
vm: -- 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:
a:
local pcap = table.
cap:
-- Analyse inside a guest without touching the host disk.
local r = a:
print
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:
local stream = console:
stream:
console:
stream:
console:
stream:
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:
local out = proc:
out:
-- 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:
h: -- start at current EOF
local stream = h:
-- 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:
local stream = vm:
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:
- Close the streams explicitly before
vm:snapshot(). - 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:
vm::
stream:
"Race a process boot against its readiness signal" #
local proc = vm:
local out = proc:
out:
-- safe to hit the server now
"Capture pcap during a specific operation" #
local cap = lan:
a::
local pcap = table.
cap:
"Drive an interactive prompt over the console" #
local console = vm:
local stream = console:
stream:
console:
stream:
"Confirm nothing weird snuck through" #
local cap = lan:
vm::
local frames = cap:
cap:
local pcap = table.
local r = vm:
t:
See also #
- Streams reference — every method, EOF semantics, type-specific notes.
- Console reference —
console:readandconsole: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::
test
-- `local_vm` is shutdown silently here
test
The rules:
| Operation | What 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::
test
The intent: if you wanted the file-scope VM, use the lookup form (provium:vm("shared")). If you wanted a fresh independent VM, pick a different name.
Federation sub-labs are isolated. A sub-lab created via provium:lab("dc1") has no parent chain — dc1.web doesn't fall through to find a sibling DC's "web". This is intentional; federation models distinct sites.
Things that don't walk parents (each by design):
vm_names/bridge_names/members— return only the local scope's contents.boot/shutdown/pause/resume— batch ops on the local scope's VMs.snapshot/restore— operate on the local scope plus its sub-labs (downward, not upward).claim,barrier— file-scope coordination primitives; test-scope claims/barriers are isolated.
Per-test scope vs reset_between_tests #
These solve different problems and compose well together — they're not alternatives.
| Per-test scope (default, automatic) | reset_between_tests = true (opt-in) | |
|---|---|---|
| What it isolates | New declarations made inside a test() body. | Mutable state of file-scope resources. |
| Mechanism | Test-scope sub-Lab; auto-shutdown at test end. | Snapshot after file setup; restore between tests. |
| Per-test cost | Booting the test-scope VMs you declared. | Snapshot restore (cheaper than full boot, slower than nothing). |
| What it doesn't help with | File-scope VMs accumulating cruft across tests. | Name collisions across tests for ad-hoc VMs. |
The typical heavy test file uses both:
provium. = true
-- File-scope setup: expensive cluster build, snapshot baseline.
local lan = provium:
local web = provium::
local db = provium::
lan:
web:
db:
db: -- baseline schema
test
test
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_testswhen 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 = falseAND 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_fixtureto cache an entire topology.
local dc1 = provium:
dc1:
dc1:
dc1::
local dc2 = provium:
dc2:
dc2::
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: -- 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:
local sub = provium:
sub: -- v is now in sub
provium: -- 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: -- {"v", "v2"}
provium: -- {"lan"}
provium: -- {"dc1", "dc2"}
provium: -- [{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 every VM in the root lab
provium: -- shutdown every VM
provium:
provium:
Useful when a test sets up the topology declaratively and wants to bring it up atomically:
local lan = provium:
local a = provium:
local b = provium:
lan:
provium: -- boots a and b together
For per-sub-lab control:
local dc1 = provium:
dc1::
dc1::
-- 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:
test
test
The claim sits across the file's lifetime; it's released at file end. A second :claim errors — lab claim already held; one-shot per lab. The accepted field shapes and the no-pool behaviour are in the Lab reference.
Why claim? The dispatcher won't oversubscribe — it tracks total RAM and CPU budget across files and only schedules a file when its claim plus the per-file overhead fits. A file that needs 4 VMs at 2 GiB each should claim ~10 GiB so it doesn't get scheduled alongside other heavy files and OOM the host.
-- For a 3-VM, 2-CPU-each, 1-GiB-each test:
provium: -- 3 GiB + per-file overhead, 6 + 1 vCPU
Barriers #
lab:barrier(name, count, timeout?) is an N-arrival rendezvous: it blocks the caller until count callers have hit the same name-keyed barrier, then returns true for all of them. On timeout it returns false — it does not raise. The default timeout, count lock-in, and round-reuse semantics are in the Lab reference.
-- count = 1 is satisfied immediately: a labelled checkpoint.
provium:
-- An unmet count times out and returns false rather than raising.
local ok = provium:
if not ok
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: -- to a tempdir, returns LabSnapshot
local snap = provium: -- to that path, returns the path string
The LabSnapshot userdata's accessors are in the Snapshot reference. Restore with:
provium: -- from LabSnapshot userdata
provium: -- 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:
local a = provium::
local b = provium::
lan:
a:
b:
return provium:
-- tests/uses-cluster.test.lua
test
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
local dc1 = dc
local dc2 = dc
provium: -- boot every VM
File-level resource claim #
provium:
test
Cached cluster fixture #
-- fixture builder
return provium: -- after building the topology
-- test usage
local cluster = provium:
local a = cluster. -- 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:
local v = sub::
v:
local snap = sub:
test
test
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:
- Streams (Tails, Captures, ConsoleStreams).
- Processes (
vm:run_async,worker:run_async). - Files (
vm:open_file,worker:open_file). - Workers.
- Bridges.
- VMs.
This is implemented as a Lua-side registry that every register_resource call appends to. Resources are closed in priority order, then within a priority by registration order.
You don't usually need to call :close yourself — the walker fires it automatically. Calling explicitly is fine (the methods are idempotent) and useful when the resource's lifetime is bounded by a clear point in the test.
See also #
- Lab reference — every method.
- provium global — file-scope configuration globals.
- VM reference, Bridge reference — what lives in a lab.
- Pool and parallelism — how the dispatcher uses claims.