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

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.timeout = "10s"        -- file-default per-test timeout

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

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

Two things to know up front:

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

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

Naming tests #

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

test: duplicate name `boots` in this file

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

Assertions #

The t context exposes:

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

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

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

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

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

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

Skipping #

Three ways to skip:

Inline (t:skip(reason)) #

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

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

Declarative ({skip = …}) #

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

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

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

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

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

Logging diagnostic data #

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

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

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

Per-test metadata #

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

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

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

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

Polling with wait_until #

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

test("nginx comes up", function(t)
    vm:run("systemctl start nginx"):assert_ok()
    local pid = wait_until(function()
        local r = vm:run("pidof nginx")
        if r:ok() then return r.stdout:match("%d+") end
    end, {timeout = "30s", interval = "200ms", desc = "nginx running"})
    t:assert(pid)
end)

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

File-default timeouts #

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

provium.timeout = "30s"

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

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

Reset-between-tests #

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

provium.reset_between_tests = true

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

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

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

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

Common patterns #

One-test-per-VM #

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

provium.reset_between_tests = true

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

One-VM-many-tests, ordered #

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

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

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

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

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

Fixture-backed setup #

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

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

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

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

See also #

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

VMs and profiles

Provium / Writing tests

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

The exhaustive reference is on VM.

Creating a VM #

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

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

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

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

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

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

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

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

Booting #

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

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

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

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

Or batch-boot via the lab:

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

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

Profiles #

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

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

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

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

You can have any number of profiles. Common patterns:

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

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

Lifecycle methods #

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

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

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

Boot opts in detail #

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

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

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

Querying VM state #

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

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

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

Snapshots #

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

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

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

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

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

Restoring #

Restore from a Snapshot userdata or a bare path string:

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

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

Determinism patterns #

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

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

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

Pausing for inspection #

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

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

Reset and power-button #

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

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

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

Multi-VM topologies #

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

local lan = provium:bridge("lan")
local a   = provium:vm("a", "peios"):boot()
local b   = provium:vm("b", "peios"):boot()
lan:attach({a, b})
a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

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

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

See also #

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

Running commands inside the guest

Provium / Writing tests

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

Sync exec: vm:run #

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

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

Two call shapes:

Shell form: vm:run(string) #

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

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

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

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

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

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

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

Environment, cwd, stdin #

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

Combine freely:

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

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

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

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

Timeouts #

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

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

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

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

RunResult fields and helpers #

Most tests only need three things from a RunResult:

local r = vm:run("…")

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

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

Async exec: vm:run_async #

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

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

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

Stdin pipe #

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

Streaming stdout / stderr #

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

See streams and tails for the full stream API.

Signals #

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

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

Inspecting the process #

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

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

Waiting #

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

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

Workers: parallel commands in the same VM #

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

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

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

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

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

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

Common patterns #

"Did the fixture build correctly?" #

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

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

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

"Compose multiple ops in one round trip" #

local results = vm:batch(function(b)
    b:write_file("/tmp/a", "1")
    b:write_file("/tmp/b", "2")
    b:read_file("/tmp/a")
    b:stat("/tmp/missing")
end)
-- results[3].ok == "1"
-- results[4].err contains "No such file"

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

See also #

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

Files and handles

Provium / Writing tests

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

High-level: read_file / write_file / stat #

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

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

vm:read_file(path) #

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

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

vm:write_file(path, data) #

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

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

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

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

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

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

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

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

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

vm:stat(path) #

Returns a table:

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

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

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

vm:listdir(path) #

Returns an array of {name, entry_type} tables:

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

vm:mkdir(path, opts?) #

Create a directory:

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

vm:unlink(path) #

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

vm:rename(from, to) #

Atomic rename within the guest filesystem.

Low-level: vm:open_file #

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

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

Mode table #

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

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

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

Reading #

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

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

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

Writing #

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

Seek and tell #

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

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

Closing #

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

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

Raw fd #

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

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

Tailing files #

Two ways to tail:

vm:tail_file(path, opts?) #

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

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

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

file:tail_stream() #

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

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

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

vm:fd_stream(fd_or_file) #

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

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

Common patterns #

Writing then reading back #

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

Asserting permission bits #

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

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

Tailing a log while the test acts #

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

Listing then filtering #

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

Files inside a worker #

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

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

Otherwise the API is identical.

Batch I/O for low latency #

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

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

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

See also #

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

Disks and fault injection

Provium / Writing tests

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

The exhaustive method reference is on Disk.

Attaching a disk #

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

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

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

Reading and writing sectors #

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

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

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

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

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

Fault injection #

Three modes:

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

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

Inject EIO #

test("guest sees EIO on read", function(t)
    -- … attach disk with backing image …
    disk:fault_inject("eio_read")
    local ok, err = pcall(function() disk:read_sectors(0, 1) end)
    t:assert(not ok)
    t:assert(tostring(err):find("EIO"))
end)

Inject slow I/O #

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

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

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

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

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

Combine modes #

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

Concurrent injection during I/O #

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

Inspecting state #

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

Clearing #

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

Detaching a disk #

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

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

Common patterns #

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

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

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

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

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

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

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

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

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

Multiple disks per VM #

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

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

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

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

Caveats #

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

See also #

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

Bridges and impairments

Provium / Writing tests

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

The exhaustive method reference is on Bridge and Nic.

Note

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

Declared vs realised #

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

Wiring up #

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

a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

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

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

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

Multi-bridge topologies #

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

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

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

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

Partitions #

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

Symmetric #

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

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

Directional #

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

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

Whole-bridge #

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

Inspect #

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

Impairments #

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

Latency #

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

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

Drop rate #

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

Bandwidth limit #

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

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

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

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

Reset #

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

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

Inspect #

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

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

Isolation #

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

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

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

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

NICs #

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

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

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

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

Packet capture #

Two scopes:

Bridge-wide capture #

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

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

Per-NIC capture #

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

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

nic:capture() errors if the VM hasn't been booted yet (the per-VM TAP doesn't exist):

nic:capture: vm `a` has no TAP on bridge `lan` (not booted?). Call lab:boot() / vm:boot() first.

Uplink (NAT to the outside world) #

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

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

L3 routing (preview) #

lan:route(other_bridge)

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

Common patterns #

Test split-brain recovery #

test("application recovers after partition heals", function(t)
    local lan = provium:bridge("lan")
    local a, b = provium:vm("a", "peios"):boot(), provium:vm("b", "peios"):boot()
    lan:attach({a, b})
    a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
    b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

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

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

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

Test latency-sensitive code #

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

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

Test packet loss tolerance #

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

Inspect packet flow with capture #

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

See also #

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

Streams and tails

Provium / Writing tests

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

The exhaustive method reference is on Streams.

What returns what #

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

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

Operations #

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

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

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

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

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

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

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

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

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

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

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

:drain(timeout?) — collect everything available #

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

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

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

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

Choosing between next, read_until, expect, drain #

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

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

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

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

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

EOF semantics #

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

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

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

Tail-specific: starting position #

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

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

Capture: pcap bytes #

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

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

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

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

ConsoleStream: bytes from the chardev #

local console = vm:console()
local stream  = console:read()

stream:expect("login:", "30s")
console:write("root\n")
stream:expect("Password:", "5s")
console:write("toor\n")
stream:expect("# ", "5s")

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

Process streams #

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

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

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

File streams #

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

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

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

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

Snapshots and live streams #

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

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

Two ways out:

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

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

Common patterns #

"Wait for a log line" #

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

"Race a process boot against its readiness signal" #

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

"Capture pcap during a specific operation" #

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

"Drive an interactive prompt over the console" #

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

"Confirm nothing weird snuck through" #

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

See also #

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

Labs and scope

Provium / Writing tests

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

The exhaustive method reference is on Lab.

Per-test scope #

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

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

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

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

The rules:

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

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

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

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

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

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

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

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

Per-test scope vs reset_between_tests #

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

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

The typical heavy test file uses both:

provium.reset_between_tests = true

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

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

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

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

When to use which:

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

Why sub-labs #

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

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

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

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

Anonymous sub-labs #

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

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

Membership: include and remove #

You can move resources between labs:

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

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

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

Listing members #

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

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

Batch lifecycle #

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

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

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

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

For per-sub-lab control:

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

Resource claims #

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

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

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

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

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

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

Barriers #

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

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

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

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

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

Snapshot and restore #

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

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

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

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

Lab fixtures #

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

-- tests/fixtures/cluster.fixture.lua
local lan = provium:bridge("lan")
local a = provium:vm("a", "peios"):boot()
local b = provium:vm("b", "peios"):boot()
lan:attach({a, b})
a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")
return provium:snapshot()
-- tests/uses-cluster.test.lua
test("cluster has both VMs reachable", function(t)
    local cluster = provium:lab_fixture("fixtures/cluster")
    cluster.a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)

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

Common patterns #

Multi-DC topology #

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

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

File-level resource claim #

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

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

Cached cluster fixture #

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

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

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

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

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

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

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

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

Auto-close ordering #

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

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

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

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

See also #

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

Peios Learn — documentation for the Peios project.

Built with Trail.