# Writing tests

---

# Writing tests with test() and t

_Provium / Writing tests_

> How to register tests, organise a file, use assertions, skip cleanly, log diagnostic data, and pick per-test metadata.

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](/provium/reference/test-framework.md) and [meta tags](/provium/reference/meta-tags.md).

## Anatomy of a test file

```lua
-- 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](/provium/writing-tests/labs-and-scope.md).

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

```lua
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":

```lua
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)`)

```lua
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 = …}`)

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

```lua
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:

```lua
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:

| 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](/provium/reference/meta-tags.md) for the full reference.

```lua
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:

```lua
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:

```lua
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](/provium/reference/test-framework.md#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:

```lua
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:

```lua
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:

```lua
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")`:

```lua
-- 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](/provium/running-tests/fixtures-and-dependencies.md) for the cache lifecycle.

## See also

- [Test framework reference](/provium/reference/test-framework.md) — every method on `t`, plus `wait_until` and `todo`.
- [Meta tags reference](/provium/reference/meta-tags.md) — every well-known meta key.
- [Labs and scope](/provium/writing-tests/labs-and-scope.md) — `lab:claim`, `lab:barrier`, `reset_between_tests`.

---

# VMs and profiles

_Provium / Writing tests_

> Create, boot, snapshot, restore, pause, and reset VMs; how profiles map names to kernel and initrd choices, and how boot opts split between create and boot.

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](/provium/reference/vm.md).

## Creating a VM

```lua
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](/provium/writing-tests/labs-and-scope.md) for the full rules.

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

```lua
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:

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

```lua
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:

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

Or batch-boot via the lab:

```lua
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`:

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

```lua
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:

| 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](/provium/running-tests/fixtures-and-dependencies.md).

## 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](/provium/reference/vm.md#state-machine).

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](/provium/writing-tests/labs-and-scope.md).)

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

```lua
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](/provium/reference/vm.md#boot-opts).

## 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](/provium/reference/vm.md#accessors).

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

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

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

Returns a [Snapshot](/provium/reference/snapshot.md) 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:

```lua
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:

```lua
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:

```lua
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](/provium/reference/clock.md).

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

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

```lua
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:

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

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](/provium/writing-tests/labs-and-scope.md).

## See also

- [VM reference](/provium/reference/vm.md) — every method, every option.
- [Snapshot reference](/provium/reference/snapshot.md) — snapshot/lab-snapshot userdata.
- [provium.toml reference](/provium/configuration/provium-toml.md) — profile configuration.
- [Bridges and impairments](/provium/writing-tests/bridges-and-impairments.md) — wiring VMs together.

---

# Running commands inside the guest

_Provium / Writing tests_

> Patterns for vm:run — shell form vs direct exec, env, cwd, stdin, timeouts — plus vm:run_async process handles, signals, and workers.

Provium gives you several ways to run commands inside a guest. This page is the practical guide; the canonical method reference is on [VM](/provium/reference/vm.md).

## Sync exec: `vm:run`

The default. Returns a [RunResult](/provium/reference/vm.md#runresult) with `exit_code`, `stdout`, `stderr`, `status`, `signal`, `timed_out`.

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

Two call shapes:

### Shell form: `vm:run(string)`

```lua
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})`

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

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

Combine freely:

```lua
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:

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

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

```lua
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:

```lua
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](/provium/reference/vm.md#runresult).

## Async exec: `vm:run_async`

Returns a [Process](/provium/reference/process.md) userdata immediately. The agent does NOT auto-kill; you control the lifetime.

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

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

```lua
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](/provium/writing-tests/streams-and-tails.md) for the full stream API.

### Signals

```lua
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](/provium/reference/process.md#prockillsig).

### Inspecting the process

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

```lua
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](/provium/reference/process.md#procwaittimeout) explains why a literal-zero timeout is a footgun.

## Workers: parallel commands in the same VM

`vm:spawn_worker()` returns a [Worker](/provium/reference/worker.md) — a sub-agent connection. Useful for driving the same VM from multiple threads of control:

```lua
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?)`](/provium/reference/lab.md#labbarriername-count-timeout) is a host-side rendezvous and can't be reached from inside a guest — see [Labs and scope — Barriers](/provium/writing-tests/labs-and-scope.md#barriers).

## Common patterns

### "Did the fixture build correctly?"

```lua
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"

```lua
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"

```lua
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](/provium/reference/vm.md) — every method.
- [Process reference](/provium/reference/process.md) — async-process surface.
- [Worker reference](/provium/reference/worker.md) — concurrent agent connections.
- [Streams and tails](/provium/writing-tests/streams-and-tails.md) — `proc:stdout_stream` / `proc:stderr_stream`.

---

# Files and handles

_Provium / Writing tests_

> Guest-side file I/O — read_file and write_file vs open_file, mode tables, seek and tell, tail streams, stat, listdir, mkdir, unlink, and rename.

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](/provium/reference/file-handle.md) userdata.

## High-level: `read_file` / `write_file` / `stat`

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

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

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

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

```lua
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):

```lua
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`](/provium/reference/lab.md#labdepends-on-filehost-path) with a literal string to declare them explicitly. See [Fixtures and dependencies — External host-file deps](/provium/running-tests/fixtures-and-dependencies.md#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:

```lua
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](/provium/reference/vm.md#vmstatpath). 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:

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

### `vm:mkdir(path, opts?)`

Create a directory:

```lua
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](/provium/reference/file-handle.md) userdata that you can read, write, seek, and close.

```lua
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):

```lua
-- 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](/provium/reference/vm.md#vmopen-filepath-mode-table).

### Reading

```lua
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](/provium/reference/file-handle.md#eof-semantics) in the File reference.

### Writing

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

```lua
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](/provium/reference/file-handle.md#filetell) explains how.

### Closing

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

```lua
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](/provium/reference/streams.md).

```lua
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](/provium/reference/vm.md#vmtail-filepath-opts).

### `file:tail_stream()`

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

```lua
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):

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

## Common patterns

### Writing then reading back

```lua
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:

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

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

```lua
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:

```lua
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:

```lua
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](/provium/reference/vm.md#vmbatchfn) for the per-op return shape.

## See also

- [VM reference](/provium/reference/vm.md) — `read_file`, `write_file`, `stat`, `mkdir`, `listdir`, `unlink`, `rename`, `open_file`, `tail_file`, `fd_stream`.
- [File handle reference](/provium/reference/file-handle.md) — every method on the File userdata.
- [Streams and tails](/provium/writing-tests/streams-and-tails.md) — patterns for tail streams.

---

# Disks and fault injection

_Provium / Writing tests_

> How to attach disks, read and write sectors directly, and inject EIO and slow I/O faults to exercise guest-side error recovery.

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](/provium/reference/disk.md).

## Attaching a disk

```lua
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](/provium/reference/disk.md#constructing).

## Reading and writing sectors

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

```lua
-- 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:

| 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

```lua
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](/provium/reference/clock.md) gives you a sub-millisecond time source (`os.time()` only has 1-second resolution):

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

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

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

### Clearing

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

## Detaching a disk

```lua
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](/provium/reference/disk.md#diskdetach).

## Common patterns

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

```lua
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?"

```lua
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?"

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

```lua
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:

```lua
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](/provium/reference/disk.md) — every method, every error message.
- [VM reference](/provium/reference/vm.md) — `vm:attach_disk`, `vm:disk`.

---

# Bridges and impairments

_Provium / Writing tests_

> Wire VMs into bridges, partition them, inject latency, drop rate, and bandwidth limits, isolate VMs, capture pcap, and reach the outside world via uplink.

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](/provium/reference/bridge.md) and [Nic](/provium/reference/nic.md).

> [!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

```lua
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](/provium/reference/bridge.md#membership).

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

## Multi-bridge topologies

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

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

```lua
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](/provium/reference/bridge.md#partitions).

### Whole-bridge

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

### Inspect

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

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

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

### Bandwidth limit

```lua
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](/provium/reference/bridge.md#bridgebandwidth-limitbps-or-table).

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

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

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

```lua
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:

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

```lua
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:

```lua
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](/provium/reference/nic.md).

## Packet capture

Two scopes:

### Bridge-wide capture

```lua
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](/provium/reference/streams.md) 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](/provium/reference/bridge.md#bridgecapture).

### Per-NIC capture

```lua
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)

```lua
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)

```lua
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](/provium/reference/bridge.md#l3-routing-preview).

## Common patterns

### Test split-brain recovery

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

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

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

```lua
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](/provium/reference/bridge.md) — every method on the Bridge userdata.
- [Nic reference](/provium/reference/nic.md) — per-NIC handle.
- [Streams reference](/provium/reference/streams.md) — what `bridge:capture()` and `nic:capture()` return.

---

# Streams and tails

_Provium / Writing tests_

> The three stream types — when to use next, read_until, expect, or drain, how EOF works, and how snapshots interact with live streams.

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](/provium/reference/streams.md).

## 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](/provium/reference/streams.md#common-methods); 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.

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

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

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

```lua
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](/provium/reference/streams.md#common-methods).

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

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

```lua
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](/provium/reference/streams.md#tail-only-behaviour).

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

```lua
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](/provium/reference/streams.md#capture-only-notes).)

## ConsoleStream: bytes from the chardev

```lua
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](/provium/reference/streams.md#consolestream-only-notes).

## Process streams

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

```lua
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:

```lua
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:

```lua
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"

```lua
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"

```lua
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"

```lua
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"

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

### "Confirm nothing weird snuck through"

```lua
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](/provium/reference/streams.md) — every method, EOF semantics, type-specific notes.
- [Console reference](/provium/reference/console.md) — `console:read` and `console:expect`.
- [VM reference](/provium/reference/vm.md) — `vm:tail_file`, `vm:fd_stream`.
- [File handle reference](/provium/reference/file-handle.md) — `file:tail_stream`.

---

# Labs and scope

_Provium / Writing tests_

> Sub-labs for multi-VM topologies, resource claims against the pool, barriers, whole-lab snapshot and restore, and cached lab fixtures.

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](/provium/reference/lab.md).

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

```lua
-- 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:

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

```lua
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 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:

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

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

```lua
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:

```lua
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](/provium/reference/lab.md#labincluderesource-or-list).

`lab:remove` is [graph-state only](/provium/writing-tests/bridges-and-impairments.md#declared-vs-realised) — 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

```lua
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):

```lua
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:

```lua
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:

```lua
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:

```lua
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](/provium/reference/lab.md#resource-claims).

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.

```lua
-- 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](/provium/reference/lab.md#labbarriername-count-timeout).

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

```lua
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](/provium/reference/snapshot.md). Restore with:

```lua
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:

```lua
-- 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()
```

```lua
-- 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](/provium/running-tests/fixtures-and-dependencies.md).

## Common patterns

### Multi-DC topology

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

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

```lua
-- 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:

```lua
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](/provium/reference/lab.md) — every method.
- [provium global](/provium/reference/provium-module.md) — file-scope configuration globals.
- [VM reference](/provium/reference/vm.md), [Bridge reference](/provium/reference/bridge.md) — what lives in a lab.
- [Pool and parallelism](/provium/running-tests/pools-and-parallelism.md) — how the dispatcher uses claims.
