# Reference

---

# provium global

_Provium / Reference_

> The `provium` global is the entry point to every Provium API. It is itself a Lab, so every method documented on Lab is reachable directly off provium.

The `provium` global is installed onto every test file's Lua state. It is the root [Lab](/provium/reference/lab.md) and the only API surface a test author needs to import — there is no `require`.

```lua
provium:vm("a", "peios")              -- create a VM in the root lab
provium:bridge("lan")                 -- create a bridge in the root lab
provium:lab("subset")                 -- create a sub-lab
provium:vm_fixture("base")            -- restore a fixture into a VM
provium:lab_fixture("ha-pair")        -- restore a fixture into a sub-lab
provium:claim({memory="4G", cpus=2})  -- file-level resource reservation
provium:boot()                        -- batch-boot every VM in the lab
provium:shutdown()                    -- batch-shutdown every VM in the lab
provium:snapshot()                    -- whole-lab snapshot
provium:restore(snap)                 -- whole-lab restore
provium:pack(...) / provium:unpack(...)  -- Lua 5.4 string.pack/unpack
```

See [Lab reference](/provium/reference/lab.md) for the canonical enumeration of these methods. This page documents only the things that are unique to the top-level `provium` global.

## File-scope configuration globals

Two top-level Lua globals on the file's `provium` table change harness behaviour for every test in the file:

### `provium.reset_between_tests`

```lua
provium.reset_between_tests = true

test("a", function(t) provium:vm("v", "peios"):boot() end)
test("b", function(t) provium:vm("v", "peios"):boot() end)  -- v was reset
```

When set to `true` at file scope, Provium takes a baseline snapshot of the root lab right after the file's top-level chunk finishes (and before the first test runs). After every test's body returns, the lab is restored to that baseline. Each `test()` runs against an identical starting state.

Mutually exclusive with file-scope open streams. If the top-level chunk opens a stream (`vm:tail_file`, `vm:console():read()`, `bridge:capture()`, etc.) and `reset_between_tests = true`, the file errors at chunk-load time with an explicit "would auto-snapshot a live stream" message naming the stream type and creation site.

### `provium.timeout`

```lua
provium.timeout = "30s"

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

File-default per-test timeout. Per-test `meta.timeout` wins. Accepts numeric seconds or a duration string with `ms`/`s`/`m`/`h` suffix. When a per-test timeout fires, the watchdog tears the entire root lab down (see [time and timeouts](/provium/reference/test-framework.md#time-and-timeouts) for the scope-limitation note).

## Dot-sugar lookup

`provium.<name>` is shorthand for whichever named resource matches `<name>` in the root lab. The lookup order is:

| Reserved keys | Resolves to |
|---|---|
| `provium.pack`, `provium.unpack` | Lua's standard `string.pack` / `string.unpack`. Reserved against shadowing. |
| `provium.vm_fixture`, `provium.lab_fixture` | Functions equivalent to the `:vm_fixture(...)` / `:lab_fixture(...)` methods. |

After reserved keys, the lookup tries:

1. A VM declared by `provium:vm("<name>", ...)` — returns the VM userdata.
2. A bridge declared by `provium:bridge("<name>")` — returns the Bridge userdata.
3. A sub-lab declared by `provium:lab("<name>")` — returns the LabUd userdata.
4. `nil` if nothing matches.

Examples:

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

-- Later in the test:
provium.lan:attach({provium.a, provium.b})
provium.a:boot()
if provium.dc1 then provium.dc1:boot() end
```

The dot form is a strict lookup. It does not create new resources; calling `provium.unknown` returns `nil`. Use the method form (`provium:vm("name", "profile")`) to create.

## Fixture sugar

`provium.vm_fixture` and `provium.lab_fixture` are reserved keys that resolve to the same callable surface as the `:vm_fixture(...)` / `:lab_fixture(...)` methods. Both call shapes work:

```lua
local vm  = provium:vm_fixture("base")     -- method form
local vm2 = provium.vm_fixture("base")     -- function form (same effect)
```

Use whichever reads better in context. The function form is convenient inside a `wait_until` predicate or a higher-order helper that captures the function rather than the lab.

## Binary helpers (`pack` / `unpack`)

`provium:pack(fmt, ...)` and `provium:unpack(fmt, s)` are pass-throughs to Lua 5.4's `string.pack` / `string.unpack`. They exist on the `provium` global so test code that uses binary protocols doesn't have to reach into `string.*`:

```lua
local frame = provium:pack(">I4 I4 s2", id, seq, payload)
local id, seq, payload = provium:unpack(">I4 I4 s2", frame)
```

The full Lua 5.4 format-string grammar applies. See the [Lua reference manual on string.pack](https://www.lua.org/manual/5.4/manual.html#6.4.2) for the format specifiers.

## See also

- [Lab reference](/provium/reference/lab.md) — every method `provium` carries through inheritance.
- [VM reference](/provium/reference/vm.md) — what `provium:vm(...)` returns.
- [test framework reference](/provium/reference/test-framework.md) — `test()`, `t`, `wait_until`, `todo`.

---

# json

_Provium / Reference_

> The `json` global — encode and decode JSON between Lua values and strings, backed by serde_json.

`json` is a top-level Lua global. Two methods, both pure host-side functions (no VM round-trip).

## `json.encode(value)`

Serialise a Lua value to a JSON string. Tables, numbers, booleans, strings, and `nil` round-trip naturally.

```lua
json.encode({a = 1, b = "x"})        -- '{"a":1,"b":"x"}'
json.encode({10, 20, 30})            -- '[10,20,30]'
json.encode(true)                    -- 'true'
json.encode(nil)                     -- 'null'
json.encode({a = 1, b = nil})        -- '{"a":1}'  (b isn't in the table — Lua semantics)
json.encode({[1]=1, [3]=3})          -- '[1,null,3]'  (array padded to max int key)
```

Array vs object detection: a table whose keys are positive integers (`1..=N`, possibly with gaps) encodes as a JSON array of length `max(key)`; gaps emit `null`. Anything else encodes as an object. Mixed tables (`{1, 2, name = "x"}`) fall into the object case, with integer keys stringified.

Encoding errors (cycles, function/userdata values, non-string-coercible keys) raise a Lua error.

## `json.decode(string)`

Parse a JSON string into a Lua value.

```lua
local t = json.decode('{"x": 42}')
print(t.x)                           -- 42

local t = json.decode('{"a": {"b": ["c", "d"]}}')
print(t.a.b[2])                      -- "d"
```

### Number precision

JSON integers in `[i64::MIN, i64::MAX]` decode to Lua integers exactly — full 64-bit precision, no rounding through `f64`. Integers above `i64::MAX` (i.e. u64-only values, up to `0xFFFFFFFFFFFFFFFF`) preserve their bit pattern via a wrap-cast: `0xFFFFFFFFFFFFFFFF` decodes to `-1`, exactly matching Lua 5.4's own `tonumber("0xFFFFFFFFFFFFFFFF")`. Bitwise ops still work correctly on the result (the bits are the bits).

Non-integer JSON (`3.14`, `1e10`) decodes to Lua's float type as expected.

You can drop the "emit as hex string and `tonumber()` it" workaround that older code used — large unsigned values now round-trip through `json.decode` directly.

### Null handling

Lua tables can't hold `nil` as a value — assigning `nil` to a key removes it. We follow the standard Lua JSON-library convention (dkjson, lua-cjson):

| Input | Lua result | Notes |
|---|---|---|
| `null` | `nil` | Top-level scalar. |
| `{"k": null}` | A table with no `k` key. | `t.k == nil`, `next(t)` doesn't yield `k`. |
| `[1, null, 3]` | `{1, nil, 3}` — array-with-hole. | `t[2] == nil`; `#t` is implementation-defined per Lua. |

This is lossy versus the original `"key exists but is null"`: round-tripping `decode` → `encode` drops null fields. Tests that need to distinguish `null` from absent should keep the source string and re-check.

Decoding errors raise a Lua error with the parse position.

## Typical use

Reading config the guest emitted:

```lua
local body = vm:read_file("/var/state/peios/state.json")
local state = json.decode(body)
t:assert_eq(state.ready, true)
```

Building a request body for a test client:

```lua
local body = json.encode({op = "write", key = "k", value = "v"})
vm:run("curl", {"-X", "POST", "-d", body, "http://localhost:8080/api"}):assert_ok()
```

## Performance

`encode` and `decode` are pure-host serde_json calls — microseconds for typical config-sized inputs. No VM round-trip, no agent involvement. Safe to call from inside tight test loops.

---

# VM

_Provium / Reference_

> The VM userdata — lifecycle, run and run_async, file I/O, syscalls and ioctls, disks, NICs, console, clock, workers, batch, snapshots, and RunResult.

A VM userdata wraps one guest. You get one from `lab:vm("name", "profile")` (or the dot-sugar `lab.<name>` after creation), and every operation against the guest dispatches through it.

VM handles are cheap to clone — passing them around or storing them in tables doesn't duplicate state.

## Constructing

| Source | Returns |
|---|---|
| `lab:vm("name", "profile")` | New VM in `lab`, in the `Created` state. |
| `lab:vm("name", "profile", opts)` | Same, with sizing opts (`memory`, `cpus`). See [boot opts](#boot-opts). |
| `lab:vm("name")` | Lookup the VM previously declared with that name. Errors if absent. |
| `lab.name` | Same lookup as `lab:vm("name")`, returns `nil` on miss. |
| `provium:vm_fixture("path")` | Restored from a cached fixture; already booted. |

## State machine

```
Created ──:boot()──> Booted ──:pause()──> Paused
                       │                    │
                       ├─:reset()→Booted    ├─:resume()→Booted
                       ├─:power_button()──> Shutdown
                       └─:shutdown()─────> Shutdown
```

`vm:state()` returns `"created"`, `"booted"`, `"paused"`, `"shutdown"`, or `"dead"`. Operations against a VM in the wrong state error cleanly with the offending state in the message.

## Boot opts

Boot opts split between the two call sites. The sizing keys go in the third arg to `lab:vm(name, profile, opts)` — the scheduler and QEMU need them when the VM is declared. The boot-shaping keys go to `vm:boot(opts)` and merge per field into the VM's pending boot options.

| Key | Where | Type | Description |
|---|---|---|---|
| `memory` | `lab:vm` opts | int (bytes) or string `"512M"`/`"2G"` | VM memory cap. Hands to QEMU as `-m <size>`. |
| `cpus` | `lab:vm` opts | int | vCPU count. Hands to QEMU as `-smp <n>`. |
| `kernel_cmdline` | `vm:boot` opts | string | Replaces the profile's `cmdline`. |
| `rng_seed` | `vm:boot` opts | int (u64) | Seeds the guest's `virtio-rng`. Use for determinism. |
| `initial_time` | `vm:boot` opts | int or float (seconds since epoch) | Sets the guest's wall clock at boot. |
| `files` | `vm:boot` opts | array of `{path=string, content=string}` | Files to inject into the guest's filesystem before init runs. |

Example:

```lua
local vm = provium:vm("v", "peios", {memory = "2G", cpus = 4})
vm:boot({
    rng_seed = 0xdeadbeef,
    initial_time = 1700000000,
    files = {
        {path = "/etc/hostname", content = "v"},
    },
})
```

## Lifecycle methods

### `vm:boot(opts?)`

Boots the guest. Returns `self` so calls chain (`provium:vm("a", "peios"):boot()`). The optional `opts` table (`kernel_cmdline`, `rng_seed`, `initial_time`, `files`) merges per field into the VM's pending boot options. Errors if already booted.

Emits a `vm_spawned` event after the guest is up and the agent has handshaken. The event payload carries `{file, vm_name, profile, memory_bytes, cid}`.

### `vm:pause()`

Pauses the guest's vCPUs. Returns nothing. Errors if not in `Booted`.

### `vm:resume()`

Resumes a paused guest. Returns nothing. Errors if not in `Paused`.

### `vm:shutdown()`

Tears down the guest. Idempotent in the sense that `Created`, `Booted`, `Paused`, and `Dead` all transition to `Shutdown`. Emits a `vm_shutdown` event with `{file, vm_name, duration_ns}`.

### `vm:reset()`

Warm reboot — guest re-runs its boot path. Errors if not in `Booted`. The post-reset state is still `Booted`.

### `vm:power_button()`

Sends ACPI power-button to the guest. Triggers a graceful shutdown sequence. Errors if not in `Booted`. Post-call state is `Shutdown`.

### `vm:close()`

Auto-close hook used by the resource walker. Idempotent. Calls `:shutdown()` if not already in `Shutdown`. Test code rarely calls this directly — the harness fires it for every VM at file end (or per-test if `provium.reset_between_tests = true`).

## Snapshot and restore

### `vm:snapshot(path?)`

Take a snapshot. With an explicit `path`, writes there; without, writes to a tempfile. Returns a [Snapshot](/provium/reference/snapshot.md) userdata wrapping the path.

```lua
local s = vm:snapshot()
local s2 = vm:snapshot("/tmp/before-mutation.snap")
```

### `vm:restore(snap_or_path)`

Restore from a `Snapshot` userdata (preferred) or a bare path string. Errors if not in `Created` or `Shutdown`.

```lua
vm:shutdown()
vm:restore(s)
```

## Layer-1 ops

Layer-1 ops are the high-level "do it like SSH" primitives. They dispatch through the agent's exec / file / stat handlers.

### `vm:run(cmd_or_args, opts?)`

Run a command. Two forms:

- `vm:run("echo hi")` — runs through `/bin/sh -c "echo hi"`. Shell metacharacters work.
- `vm:run("echo", {"hi"})` — direct exec. No shell. The second arg is a positional-arg array.
- `vm:run("echo", {args={"hi"}, env={K="v"}, cwd="/tmp", stdin="…", env_clear=true, timeout="5s"})` — opts form. Auto-detected by the presence of a recognised opts key (`env`, `env_clear`, `cwd`, `stdin`, `timeout`, `timeout_ms`, `args`); a table with none of them is treated as the legacy array. Mixed array entries with `args=` are not allowed; use one form or the other.

Returns a [RunResult](#runresult) userdata.

| Opts key | Type | Description |
|---|---|---|
| `args` | array of strings | Direct positional args (only used with the table-as-opts form). |
| `env` | string→string map | Environment variables. |
| `env_clear` | bool | When true, the guest sees only `env`; otherwise env merges with the agent's environment. |
| `cwd` | string | Working directory inside the guest. |
| `stdin` | string | Bytes piped to the process's stdin. |
| `timeout_ms` | int | Hard wall-clock timeout in milliseconds. |
| `timeout` | int / float / string | Same, expressed as seconds or `"5s"` / `"500ms"` / `"2m"` / `"1h"`. Negative or NaN errors. |

### `vm:run_async(cmd, opts?)`

Like `:run`, but returns a [Process](/provium/reference/process.md) userdata immediately. The agent does not auto-kill; you control the lifetime via `proc:kill()` / `proc:wait()`. Passing `timeout` here is rejected — use `proc:wait(timeout)` instead.

### `vm:read_file(path)`

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

### `vm:write_file(path, data)`

Replaces the file's contents with `data`. Creates the file if absent. Returns nothing.

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

Read a file from the host filesystem and write its bytes to `guest_path` in the guest. Inside a fixture, the host file is folded into the fixture's cache key automatically — rebuilding the host file (typical case: a binary under test) invalidates the snapshot. Relative `host_path` is resolved against the directory of the file containing the call. Pass `{auto_dep = false}` to skip the auto-fold for a single call. See [files and handles](/provium/writing-tests/files-and-handles.md#vmpush-filehost-path-guest-path-opts) and [fixtures and dependencies](/provium/running-tests/fixtures-and-dependencies.md#external-host-file-deps).

### `vm:stat(path)`

Returns a table:

| Field | Type | Description |
|---|---|---|
| `size` | int (bytes) | File size. |
| `mtime` | float (seconds since epoch) | Modification time. |
| `mtime_ns` | int (ns since epoch) | Same, full precision. |
| `perm` | int | POSIX mode bits (≤ 4095, i.e. `0o7777`). |
| `entry_type` | string | One of `"file"`, `"directory"`, `"symlink"`, `"fifo"`, `"socket"`, `"block_device"`, `"char_device"`, `"other"`. |

### `vm:listdir(path)`

Returns an array of `{name=string, entry_type=string}` tables.

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

Create a directory. Opts: `{parents=bool, perm=int}`. With `parents=true`, intermediate directories are created (`mkdir -p`). `perm` is the POSIX mode for the new directory.

### `vm:unlink(path)`

Remove a file or empty directory.

### `vm:rename(from, to)`

Atomic rename within the guest filesystem.

### `vm:open_file(path, mode_table)`

Returns a [File](/provium/reference/file-handle.md) userdata.

The mode table accepts:

| Key | Type | Effect |
|---|---|---|
| `read` | bool | Open for reading. |
| `write` | bool | Open for writing. |
| `create` | bool | Create if absent. |
| `truncate` | bool | Truncate to zero bytes on open. |
| `append` | bool | Append-only writes. |
| `exclusive` | bool | Combine with `create=true` to require the file not already exist (`O_EXCL`). |
| `perm` | int | POSIX mode for newly-created files. |

At least one of `read`, `write`, or `append` must be true; an empty mode table errors at open time with a pointer.

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

Open a streaming subscription to a file. Returns a [Tail](/provium/reference/streams.md) userdata. Opts: `{start = "beginning" | "end" | <offset>}` (`"start"` is accepted as an alias for `"beginning"`). Default is `"end"` — only bytes appended after the call are streamed. `<offset>` may be a non-negative integer (absolute byte offset), a negative integer (N bytes before EOF — provium stats and resolves), or a finite float (truncated toward zero, same sign semantics).

### `vm:fd_stream(fd_or_file)`

Open a streaming subscription to an existing file handle. Accepts either an integer handle id (from `file:fd()`) or a `File` userdata directly. Returns a [Tail](/provium/reference/streams.md).

## Layer-0 ops

Layer-0 ops are direct kernel-level primitives — `syscall(2)` and `ioctl(2)`. Use these to exercise driver paths or to test syscall semantics directly.

### `vm:syscall(nr, ...)`

Two call shapes:

- **Integer-only:** `vm:syscall(nr, a1, a2, a3, a4, a5, a6)` — up to 6 integer arguments.
- **Table form:** `vm:syscall(nr, {args={a1, a2, …}, bufs={"…", …}, ptrs={1, 3}, nested={…}})`. Buffers are byte arrays; `ptrs[i]` says which arg slot (0-indexed) `bufs[i]` should be spliced into. The buffer's address is filled in for the kernel.

**Nested pointers.** `nested` is a list of `{parent=N, child=M, offset=K}` entries (`parent`/`child` are **1-based** indices into `bufs`). Before the arg-slot `ptrs` are applied, the agent writes `bufs[M]`'s address into `bufs[N]` at byte `offset` (an 8-byte native-endian pointer). This is how you pass a **struct argument that itself contains a pointer** — a buffer pointing at another buffer. Use it for ioctls invoked via raw `syscall(SYS_ioctl, fd, cmd, &struct)` whose struct has an inner output-buffer pointer (a query/info ioctl), or for syscalls whose argument struct embeds pointers (`kacs_access_check`-style). Each pointed-at buffer is returned post-call in `out_bufs` at its own index.

```lua
-- ioctl(fd, QUERY, &args) where struct kacs_query_args { u32 class; u32 buf_len; u64 buf_ptr; }
-- buf_ptr (offset 8) must point at the output buffer.
local args = string.pack("<I4I4I8", PRIVS_CLASS, 4096, 0)  -- buf_ptr filled in by nested
local out  = string.rep("\0", 4096)
local r = vm:syscall(SYS_ioctl, {
    args   = { fd, QUERY_CMD, 0 },     -- arg slot 2 receives the struct pointer
    bufs   = { args, out },            -- buf 1 = struct, buf 2 = output
    ptrs   = { 2 },                    -- buf 1 → arg slot 2
    nested = { { parent = 1, child = 2, offset = 8 } },  -- buf 2's addr → buf 1 @ offset 8
})
local data = r.out_bufs[2]             -- the QUERY output buffer
```

Returns a table:

| Field | Type | Description |
|---|---|---|
| `ret` | int | Syscall return value. |
| `result` | int | Same as `ret` (alias for clarity). |
| `errno` | int | Errno if the syscall returned negative; 0 otherwise. |
| `out_bufs` | array of strings | Post-syscall contents of the supplied `bufs` (including nested-pointer target buffers). Useful for read-style syscalls. |

The same `bufs` / `ptrs` / `nested` options are available on [`worker:syscall`](/provium/reference/worker.md#workersyscallnr-).

### `vm:ioctl(fd, cmd, data?, opts?)`

Direct `ioctl(2)`. `fd` and `cmd` are integers. `data` is an optional byte string passed as the third arg's pointer. `opts.bufs` and `opts.ptr_offsets` work like `:syscall`'s `bufs` / `ptrs`.

Returns `{ret, result, out_data, out_bufs}`. `out_data` is the post-call value of `data` (for ioctls that write back through the same buffer).

## Hypervisor resources

### `vm:nic(name)`

Returns a [Nic](/provium/reference/nic.md) userdata for the named bridge attachment, or for a guest-style interface name (`eth0`, `enp0s3`, `ens3`). Guest-name lookup maps to the Nth attached bridge sorted by bridge name.

### `vm:disk(id)`

Returns a [Disk](/provium/reference/disk.md) userdata for an already-attached disk. Errors if no disk with that id.

### `vm:attach_disk(opts?)`

Attaches a new disk and returns a [Disk](/provium/reference/disk.md) userdata. Opts: `{id=string, size=int, image=string}`. Defaults: `id` = `attached-<vm_name>`, `size` = 4 GiB, `image` = no backing file.

## Console and clock

### `vm:console()`

Returns a [Console](/provium/reference/console.md) userdata.

### `vm:clock()`

Returns a [Clock](/provium/reference/clock.md) userdata.

## Workers

### `vm:spawn_worker(opts?)`

Spawns a sub-agent connection so test code can dispatch concurrent ops against the same guest. Returns a [Worker](/provium/reference/worker.md) userdata. Opts are reserved for future use; passing `{thread=true}` errors with a future-work pointer.

## Batch operations

### `vm:batch(fn)`

Collect multiple ops inside `fn(b)` and dispatch them as one wire round-trip. `b` exposes:

- `b:run(...)` (with the same shape as `vm:run`)
- `b:read_file(path)`
- `b:write_file(path, data)`
- `b:stat(path)`
- `b:listdir(path)`
- `b:mkdir(path, opts)`
- `b:unlink(path)`
- `b:rename(from, to)`
- `b:syscall(nr, a, b, …)` — integer-args form only; the table form is rejected here.

Returns a Lua array of `{ok=value}` or `{err=msg}` entries, one per op. A failure on op N does not short-circuit the rest of the batch.

```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 = {{ok=nil}, {ok=nil}, {ok="1"}, {err="No such file or directory"}}
```

## Accessors

### `vm:name()` — Returns the VM's name as given to `lab:vm`.
### `vm:profile()` — Returns the profile name.
### `vm:state()` — Returns `"created"`, `"booted"`, `"paused"`, `"shutdown"`, or `"dead"`.
### `vm:cid()` — Returns the assigned vsock CID, or `nil` before boot.
### `vm:is_quiescent()` — `true` if no in-flight ops, no open files or streams.
### `vm:open_file_count()` — Number of files currently open through the agent.
### `vm:open_stream_count()` — Number of streams currently active (tails, fd-streams, console-reads, captures).

## RunResult

The return value of `vm:run(...)` (and `worker:run(...)`, `proc:wait()`).

Fields:

| Field | Type | Description |
|---|---|---|
| `exit_code` | int | Exit code. `-1` for signalled, `-2` for timed_out. Use `signal` and `timed_out` to disambiguate. |
| `stdout` | string | Captured stdout. |
| `stderr` | string | Captured stderr. |
| `status` | string | `"exited"`, `"signalled"`, `"timed_out"`. |
| `timed_out` | bool | `true` if the timeout fired. |
| `signal` | int or nil | Signal number when status is `signalled`, else nil. |

Methods:

- `result:ok()` — convenience for `result.exit_code == 0`.
- `result:assert_ok()` — raises if not OK; the error message includes status, stdout, and stderr (with signal name when signalled).

## See also

- [Lab](/provium/reference/lab.md) — what creates and contains VMs.
- [Process](/provium/reference/process.md) — what `vm:run_async` returns.
- [Worker](/provium/reference/worker.md) — what `vm:spawn_worker` returns.
- [Disk](/provium/reference/disk.md), [Nic](/provium/reference/nic.md) — hypervisor-side handles.
- [Console](/provium/reference/console.md), [Clock](/provium/reference/clock.md) — guest-side accessors.
- [Streams](/provium/reference/streams.md) — what `vm:tail_file` and `vm:fd_stream` return.

---

# Bridge

_Provium / Reference_

> The Bridge userdata — wire VMs together, partition, inject latency and drops, limit bandwidth, capture packets, isolate VMs, and NAT outbound traffic.

A Bridge represents one Linux bridge with TAP-interface attachments. It is created via `lab:bridge("name")` and lazily realised — the host-side bridge and per-VM TAPs come up the first time any attached VM boots.

## Constructing

| Source | Returns |
|---|---|
| `lab:bridge("name")` | New bridge. |
| `lab:bridge("name", opts)` | Same. Opts are reserved for future use. |
| `lab:bridge("name")` (already created) | The same bridge. |
| `lab.<name>` | Same lookup as `lab:bridge("name")` for declared bridges. Returns nil otherwise. |

A bridge is a graph object until a VM attached to it boots; only at that point does the host-side bridge interface exist.

## Membership

### `bridge:attach(vm)` / `bridge:attach({vms…})`

Records that `vm` is on this bridge. When `vm` is booted the host installs a TAP, slaves it to the bridge, and adds a `-netdev tap` flag to the QEMU launch.

Accepts:

- A single VM userdata — `lan:attach(a)`.
- A bare string — `lan:attach("a")`. Graph-state only; no VM handle is wired, so future link-down via `bridge:detach(vm)` will not drive QMP.
- An array — `lan:attach({a, b, c})`. Validated atomically: a bad type at index N fails before any element is recorded.

### `bridge:detach(vm)`

Remove `vm` from the bridge. When `vm` is a VM userdata, also issues a QMP `set_link(false)` against the matching netdev so the guest sees a link-down event.

### `bridge:members()`

Returns an array of attached VM names.

## Partitions

Partitions are network-layer drops between specific pairs of VMs. Two forms:

### `bridge:partition(a, b)` (symmetric)

Drop both directions of A↔B traffic. Accepts VM userdata or bare strings. Graph-state only — drops install when each VM is booted.

### `bridge:partition({from=A, to=B})` (directional)

Drop only A→B traffic. B→A continues to flow. Both endpoints must already be attached and booted; otherwise it errors with a "call bridge:attach first" pointer (the rule would otherwise install onto a non-existent TAP).

### `bridge:unpartition(a, b)` / `bridge:unpartition({from=A, to=B})`

Undo the matching partition. The directional unpartition does NOT require the endpoints to still be attached — if you `:detach()` a VM and then `:unpartition` it, the call is a clean no-op rather than an error (detach already cleared the directional rule).

### `bridge:partition_all()`

Drop every pair of attached VMs. Equivalent to calling `bridge:partition` for every pair.

### `bridge:restore_all()`

Undo every partition on the bridge.

### `bridge:is_partitioned(a, b)`

Returns `true` if A↔B is currently partitioned (either symmetric or directional from A to B).

## Impairments

Impairments install `tc` qdiscs on the bridge or per-TAP. All three accept either a scalar (whole-bridge) or a directional table.

### `bridge:add_latency(ms_or_table)`

Whole-bridge form: `bridge:add_latency(50)` adds 50 ms of one-way delay to every packet through this bridge.

Directional form: `bridge:add_latency({from=A, to=B, ms=50})` records 50 ms for the A→B pair. Realisation is per **source**: each VM's outbound `(from, *)` pairs collapse — worst case per axis across latency, drop, and bandwidth — into one qdisc chain on that VM's TAP, so the delay in practice applies to everything leaving `A`. Endpoint-attachment check applies (see Partitions).

### `bridge:drop_rate(pct_or_table)`

Whole-bridge: `bridge:drop_rate(10)` drops ~10 % of packets via netem.

Directional: `bridge:drop_rate({from=A, to=B, p=10})`. Same per-source realisation as directional latency.

### `bridge:bandwidth_limit(bps_or_table)`

Whole-bridge: `bridge:bandwidth_limit(1_000_000)` caps the bridge to 1 Mbit/s via `tc tbf`.

Directional: `bridge:bandwidth_limit({from=A, to=B, bps=500_000})` installs an HTB-root + one rate-limited class on `A`'s source TAP, with a `netem` child when latency/drop is also set for the same source. Bits per second, not bytes. The `to` is graph-recorded but the realisation shapes every packet leaving the source TAP — HTB at the bridge layer can't select by destination MAC. Multiple `(from=A, to=*)` pairs collapse to `max(bps)` on `A`'s TAP so no pair is over-shaped.

Whole-bridge bandwidth does **not** combine with whole-bridge latency/drop on the same bridge: when netem is also configured, the latency/drop shaping is what's realised and the bandwidth cap is recorded in the graph but not enforced (combined netem+tbf needs class-based HTB — out of scope for v1). For combined shaping use the directional form on each source.

### `bridge:add_directional_latency({from=A, to=B, ms=N})`

Explicit directional form of `:add_latency`. Same effect as the table form above.

### `bridge:directional_drop_rate({from=A, to=B, p=N})`

Explicit directional form of `:drop_rate`.

### `bridge:reset()`

Tear down every installed netem / tbf qdisc and clear every partition. Isolation, uplink, and L3 routes are preserved — they're topology, not impairments.

### Introspection

- `bridge:latency_ms()` — Current whole-bridge latency.
- `bridge:drop_rate_pct()` — Current whole-bridge drop rate.
- `bridge:bandwidth_bps()` — Current whole-bridge bandwidth cap.

## Isolation

Isolation puts a single VM behind a hairpin filter so it cannot reach any other VM on the bridge (but the bridge itself remains alive).

### `bridge:isolate(vm)` / `bridge:unisolate(vm)`

Toggle isolation for one VM. Accepts a VM userdata or a bare string.

### `bridge:is_isolated(vm)`

Returns `true` if `vm` is currently isolated on this bridge.

## L3 routing (preview)

### `bridge:route(other_bridge_or_list)`

Records that traffic should be routed to `other_bridge`. **Graph-state only in v1.** No nft forward rules are installed — the call records the intent but cross-bridge IP traffic does not actually flow yet. The first call to `:route()` per bridge prints a one-shot warning to stderr so test authors notice at the call site rather than chasing dropped packets.

### `bridge:routes()`

Returns the array of bridge names this bridge is currently routed to.

## Uplink

Uplink installs an nft NAT masquerade rule between the bridge and the host's default-route interface, so VMs on the bridge can reach the outside world.

### `bridge:enable_uplink()` / `bridge:disable_uplink()`

Enable / disable NAT for this bridge. Both can fail if the host doesn't have a default-route interface or if nft commands fail; the error includes the underlying detail.

## NICs and capture

### `bridge:nic(vm)`

Returns a [Nic](/provium/reference/nic.md) bound to the (bridge, vm) pair. When `vm` is a VM userdata, the Nic carries the VM handle through so `nic:disconnect()` / `nic:reconnect()` can drive QMP `set_link`. With a bare string, the Nic is [graph-state only](/provium/writing-tests/bridges-and-impairments.md#declared-vs-realised).

### `bridge:capture()`

Spawn `tcpdump -i <bridge> -U -w -` and return a [Capture](/provium/reference/streams.md) stream. Reads pcap bytes off the tcpdump child's stdout. Requires `tcpdump` on `PATH` plus `CAP_NET_RAW` (in addition to `CAP_NET_ADMIN`).

The capture holds a guard that pins the bridge's `active_captures` counter, so `vm:snapshot()` can detect "stream live, snapshot would race" and refuse.

## Auto-close

### `bridge:close()`

Auto-close hook used by the resource walker. Tears down host-side networking — TAPs, qdiscs, nft tables, the bridge itself. Idempotent.

## Accessors

- `bridge:name()` — Returns the bridge's name.

## See also

- [Nic](/provium/reference/nic.md) — per-(bridge, vm) handle for link-state and per-NIC capture.
- [Streams](/provium/reference/streams.md) — what `bridge:capture()` returns.
- [Lab](/provium/reference/lab.md) — bridges live in a lab.

---

# Lab

_Provium / Reference_

> The Lab userdata — VMs, bridges, and sub-labs, plus claims, barriers, whole-lab snapshots, and fixtures. The provium global is the root Lab.

A Lab is the unit of resource ownership in Provium. The `provium` global is the root Lab; you can carve out sub-labs with `provium:lab("name")`. Both shapes expose the same surface, so this page is the canonical reference.

## Membership

### `lab:vm(name, profile?, opts?)`

Three call shapes:

- `lab:vm("name")` — lookup. Errors if no VM with that name has been declared.
- `lab:vm("name", "profile")` — create.
- `lab:vm("name", "profile", opts)` — create with sizing opts (`memory`, `cpus`). See [VM boot opts](/provium/reference/vm.md#boot-opts) for the split between creation-time and boot-time keys.

Returns a [VM](/provium/reference/vm.md) userdata.

### `lab:bridge(name, opts?)`

Two call shapes:

- `lab:bridge("name")` — lookup or create. If the bridge already exists in the lab, returns it; otherwise creates it.
- `lab:bridge("name", opts)` — same. Opts are reserved.

Returns a [Bridge](/provium/reference/bridge.md) userdata.

### `lab:lab(name?)`

Create a sub-lab. Without a name, an anonymous name is generated (`__lab_0`, `__lab_1`, …). Returns a Lab userdata. Sub-labs share the parent's config, VMM, event sink, and pool, but have their own membership graph.

```lua
local dc1 = provium:lab("dc1")
dc1:vm("a", "peios")
dc1:vm("b", "peios")
dc1:bridge("lan"):attach({dc1.a, dc1.b})
```

### `lab:depends_on_file(host_path)`

Declare an external host-file as a fixture-cache dependency. The call itself is a no-op at runtime — its purpose is to be picked up by the cache-key scanner so that editing `host_path` invalidates the fixture next run.

```lua
provium:depends_on_file("../templates/sshd_config.in")
```

Relative paths are resolved against the directory of the file containing the call. The path must be a string literal at the call site; non-literal paths (variables, concatenation) are not detected. Folds path + mtime + size into the cache key. See [Fixtures and dependencies — External host-file deps](/provium/running-tests/fixtures-and-dependencies.md#external-host-file-deps).

Files pushed into the guest via [`vm:push_file`](/provium/reference/vm.md#vmpush-filehost-path-guest-path-opts) already track themselves — `depends_on_file` is for files the fixture reads on the host side.

### `lab:include(resource_or_list)`

Add an existing resource (VM, Bridge, or sub-Lab) to this lab. Accepts:

- A single userdata — `lab:include(other_lab.shared_vm)`.
- An array — `lab:include({a, b, c})`.

Names must not already be taken in `lab` (errors with `DuplicateVmName` / `DuplicateBridgeName`). Reserved names (`pack`, `unpack`, `vm_fixture`, `lab_fixture`) are also rejected. When called from a test scope, names that already exist in a parent scope (the file root) also error to avoid silent shadowing — see [labs and scope](/provium/writing-tests/labs-and-scope.md#per-test-scope).

### `lab:remove(name_or_userdata)`

Remove a resource. Accepts a bare name (tries vm → bridge → sub-lab in order) or a userdata. Removal is [graph-state only](/provium/writing-tests/bridges-and-impairments.md#declared-vs-realised) — the underlying VM, bridge, or sub-lab is NOT shut down or unrealised.

### `lab:members()`

Returns an array of `{kind=string, name=string}` entries listing every direct child of the lab. `kind` is `"vm"`, `"bridge"`, or `"sub_lab"`.

### `lab:vm_names()` / `lab:bridge_names()` / `lab:sub_lab_names()`

Arrays of names of each kind directly in this lab (does not recurse into sub-labs).

### `lab.<name>` (dot sugar)

Looks up a named resource. Lookup order:

1. Reserved keys: `pack`, `unpack`, `vm_fixture`, `lab_fixture`.
2. VM with that name (walks parent scope chain on miss).
3. Bridge with that name (walks parent scope chain on miss).
4. Sub-lab with that name (walks parent scope chain on miss).
5. Returns `nil`.

The dot form does not create. Use `lab:vm("name", "profile")` for that. The parent-scope walk applies when this lab is a per-test scope (the runner sets `provium` to a test-scope LabUd whose parent chain includes the file root). Federation sub-labs created via `lab:lab(name)` have an empty parent chain and don't fall through to siblings.

## Batch lifecycle

Each method here applies to every direct VM child of the lab. They do not recurse into sub-labs.

### `lab:boot()`

Boot every VM in the lab. Returns `self` so you can chain.

### `lab:shutdown()`

Shutdown every VM.

### `lab:pause()` / `lab:resume()`

Pause / resume every VM. Errors if any VM is in the wrong state for the transition.

## Resource claims

Each test file may make at most one claim against the dispatcher's resource pool. The claim sits across the entire file's lifetime; it is released at file end (or scope-walker end).

### `lab:claim({memory=…, cpus=…})`

Reserve resources from the pool. Either field may be omitted (treated as zero). Both are accepted as integers; `memory` may also be a string with `K`/`M`/`G` suffix.

| Field | Type | Effect |
|---|---|---|
| `memory` | int (bytes) or string `"512M"` / `"2G"` | Memory budget. |
| `cpus` | int | vCPU budget. |

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

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

A second `:claim` call on the same file errors — `lab claim already held; one-shot per lab`. When no pool is wired (REPL / single-file ad-hoc runs) the call still records the one-shot constraint but is otherwise a no-op.

The claim emits a `claim_acquired` event when it goes through; a matching `claim_released` fires at file end.

## Barriers

### `lab:barrier(name, count, timeout?)`

Block until `count` callers have hit the same `name`-keyed barrier. Returns `true` when the barrier releases and `false` on timeout — it does not raise. Default timeout is 60 seconds. The timeout argument accepts a number (seconds) or nil. The count is locked in by the first arrival; a later call with a mismatching count for the same name returns `false` with a host-side warning. Barriers are reusable across rounds under the same name.

```lua
-- count = 1: satisfied immediately.
assert(provium:barrier("checkpoint", 1))

-- Unmet count: times out and returns false.
assert(not provium:barrier("pair", 2, 0.1))
```

Meeting a `count > 1` requires a second concurrent host-side caller. Test files run single-threaded, and workers run guest processes (which can't call `barrier`), so a `count > 1` barrier currently times out in an ordinary test file; the multi-caller form is aimed at thread-mode workers, which aren't supported yet. See [Labs and scope — Barriers](/provium/writing-tests/labs-and-scope.md#barriers).

## Snapshot and restore

### `lab:snapshot(dir?)`

Take a whole-lab snapshot. With an explicit `dir` arg, writes there and returns the dir path as a string. Without args, writes to a fresh tempdir and returns a [LabSnapshot](/provium/reference/snapshot.md) userdata.

### `lab:restore(dir_or_snapshot)`

Restore from a directory path or a `LabSnapshot` userdata. Reads `lab.json` for the per-VM index plus each VM's snapshot file under the same dir.

## Fixtures

### `lab:vm_fixture(path)`

Build (or restore from cache) a single-VM fixture. `path` is the test-root-relative path of a `*.fixture.lua` file with the `.fixture.lua` suffix omitted.

```lua
local vm = provium:vm_fixture("base")
local vm = provium:vm_fixture("setups/networked")
```

The cached snapshot is hashed by source bytes + every transitive `vm_fixture`/`lab_fixture` reference + every `require()`d helper + every profile's kernel and initrd identifier. Editing any of those invalidates the cache for that fixture (and every fixture that transitively depends on it).

Returns a [VM](/provium/reference/vm.md) userdata in the `Booted` state.

### `lab:lab_fixture(path)`

Build (or restore from cache) a multi-VM lab fixture. The fixture's chunk must end with `return provium:snapshot()`. Cached as a directory `<key>.lab/` containing the per-VM snapshots and `lab.json`.

Returns a Lab userdata wrapping a fresh sub-lab into which the fixture has been restored.

## Binary helpers

### `lab:pack(fmt, ...)` / `lab:unpack(fmt, s)`

Pass-throughs to Lua 5.4's `string.pack` / `string.unpack`. Available so test code that processes binary protocols doesn't need to reach into `string.*`. Same call shape and semantics as the standard library.

## Accessors

- `lab:name()` — Returns the lab's name. The root lab is named `"provium"`.

## See also

- [provium global](/provium/reference/provium-module.md) — file-scope configuration globals on top of the root Lab.
- [VM](/provium/reference/vm.md), [Bridge](/provium/reference/bridge.md) — what lives in a lab.
- [Snapshot](/provium/reference/snapshot.md) — what `lab:snapshot()` returns.
- [Fixtures and dependencies](/provium/running-tests/fixtures-and-dependencies.md) — for the cache-key model and rebuild triggers.

---

# Disk

_Provium / Reference_

> The Disk userdata — read and write sectors directly, inject EIO and slow-I/O faults, and detach the disk from the live guest.

A Disk wraps one virtio-blk disk attached to a VM. It exposes block-level operations and a fault-injection surface useful for exercising I/O error paths in the guest.

## Constructing

| Source | Returns |
|---|---|
| `vm:attach_disk({id="vda", size=…, image="…"})` | New disk attached to `vm`. |
| `vm:disk("id")` | Lookup of an already-attached disk. Errors if absent. |

`vm:attach_disk` opts:

| Field | Type | Default | Description |
|---|---|---|---|
| `id` | string | `"attached-<vm_name>"` | Disk identifier within the VM. |
| `size` | int (bytes) | `4 GiB` | Modelled disk size. Used for `:size()` when no image is attached. |
| `image` | string (path) | none | Backing file. Sector ops require an image. |

Disks are 512-byte sectors throughout. The `read_sectors` and `write_sectors` ops express offsets and counts in sectors.

## Methods

### `disk:size()`

Returns the disk's size in bytes. When a backing image is attached, returns the live `stat()` size of the image file (so a test that resized the underlying file with `truncate` / `fallocate` reads honest output). Without an image, returns the modelled size from `attach_disk`.

### `disk:read_sectors(offset, n)`

Read `n` sectors starting at sector `offset`. Returns the bytes as a Lua string. Errors if:

- The disk is detached (`disk:read_sectors: disk is detached`).
- No backing image (`disk:read_sectors: no backing image — disk:with_image required`).
- An `eio_read` fault is active (`disk:read_sectors: EIO (fault_inject)`).
- The host-side `read` fails (passes the underlying error through).

Honours active faults:

- `eio_read` — short-circuits to EIO before any I/O.
- `slow` — sleeps 50 ms per call. After the sleep, re-checks for `eio_read` so a concurrent fault injection during the sleep still takes effect. After the actual I/O, checks one more time for the same reason.

### `disk:write_sectors(offset, data)`

Write `data` (a Lua string) starting at sector `offset`. The data does not have to be a multiple of 512 bytes; it is written verbatim from the offset. Returns nothing.

Errors and fault handling mirror `read_sectors`:

- Detached → error.
- No backing image → error.
- `eio_write` active before the I/O → EIO.
- `slow` → 50 ms sleep, with `eio_write` re-checks after the sleep and after the I/O.

### `disk:fault_inject(mode)`

Activate a fault. Valid modes: `"eio_read"`, `"eio_write"`, `"slow"`. Unknown modes error with the valid list (`disk:fault_inject: unknown mode \`X\` (valid: eio_read, eio_write, slow)`).

Multiple modes can be active simultaneously. When `slow` and the matching `eio_*` are both active at call time, the EIO check runs first — the call errors immediately, without the 50 ms delay. (The post-sleep re-checks only matter for an EIO fault set after the call started.)

### `disk:clear_faults()`

Clear every active fault. Subsequent reads / writes succeed normally.

### `disk:active_faults()`

Returns a Lua array of the currently-active fault mode names. Useful for tests that need to assert the harness state.

### `disk:detach()`

Mark the disk as detached and issue a best-effort QMP `device_del` against the parent VM. Subsequent `read_sectors` / `write_sectors` error. The QMP call is best-effort: a disk that was never QMP-added (host-bookkeeping-only attach) silently surfaces "Device not found" but the local detached flag is still set.

### `disk:is_detached()` / `disk:id()`

Accessors. `:id()` returns the disk identifier as given to `attach_disk`.

## Example: full fault-injection pattern

```lua
test("guest sees EIO and recovers after clear", function(t)
    local img = "/tmp/test-image"
    -- Pre-create the backing file.
    local f = io.open(img, "w"); f:write(string.rep("\0", 512 * 1024)); f:close()

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

    -- Confirm baseline read works.
    local body = disk:read_sectors(0, 1)
    t:assert_eq(#body, 512)

    -- Inject EIO.
    disk:fault_inject("eio_read")
    local ok, err = pcall(function() disk:read_sectors(0, 1) end)
    t:assert(not ok)
    t:assert(tostring(err):find("EIO"))

    -- Clear and confirm reads recover.
    disk:clear_faults()
    body = disk:read_sectors(0, 1)
    t:assert_eq(#body, 512)
end)
```

## See also

- [VM](/provium/reference/vm.md) — disks come from `vm:attach_disk` / `vm:disk`.
- [Disks and fault injection](/provium/writing-tests/disks-and-fault-injection.md) — patterns for exercising I/O error paths.

---

# Nic

_Provium / Reference_

> A Nic userdata is a per-(bridge, vm) handle. Use it to read NIC counters, capture per-NIC pcap, or simulate link-down / link-up on a single attachment.

A Nic represents one virtual NIC: the binding between a VM and a Bridge. You get one from either side of that pair (`vm:nic(bridge_name)` or `bridge:nic(vm)`), and it gives you per-NIC observability and link control.

## Constructing

| Source | Returns |
|---|---|
| `vm:nic("bridge_name")` | NIC for the named bridge attachment. |
| `vm:nic("eth0")` | NIC for the Nth attached bridge sorted by bridge name (`eth<N>`, `enp0s<N>`, `ens<N>`). |
| `bridge:nic(vm)` | NIC for the (bridge, vm) pair. When `vm` is a userdata, the link-state ops drive QMP. |

The guest-name lookup (`eth0`) is deterministic per build but may not match the kernel's actual device-probe order on every distro. For portable tests, prefer `vm:nic("bridge_name")` and `bridge:nic(vm)`.

## Methods

### `nic:counters()`

Returns a table of per-NIC counters read from `/sys/class/net/<tap>/statistics/`. The counters are presented from the **guest's perspective**: the host's `tx_bytes` on the TAP is what the guest received, so it appears as `rx_bytes` in the table.

| Field | Description |
|---|---|
| `rx_bytes` | Bytes the guest received (host TAP's `tx_bytes`). |
| `tx_bytes` | Bytes the guest sent (host TAP's `rx_bytes`). |
| `rx_packets` | Packets received. |
| `tx_packets` | Packets sent. |
| `errors` | Sum of host TAP's `rx_errors` and `tx_errors`. |

Returns zeros when the host TAP isn't yet realised (VM not booted). If you need to assert a NIC has produced traffic, gate the read on `vm:state() == "booted"` or `wait_until` on a non-zero counter.

### `nic:capture()`

Spawn `tcpdump -i <tap>` on this NIC's host TAP interface. Returns a [Capture](/provium/reference/streams.md) stream. Errors if the VM hasn't been booted yet (the per-VM TAP doesn't exist):

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

The capture pins the bridge's `active_captures` counter, so `vm:snapshot()` can detect a live capture and refuse the snapshot (no half-captured pcap).

Compare with `bridge:capture()` which captures the whole bridge, not a single NIC.

### `nic:disconnect()`

Detach the NIC from the bridge **and** issue a QMP `set_link(false)` against the matching netdev so the guest sees a link-down event. Without a VM userdata at construction time (bare-string `bridge:nic("name")` form), the QMP step is skipped — the call is [graph-state only](/provium/writing-tests/bridges-and-impairments.md#declared-vs-realised).

### `nic:reconnect()`

Reverse of `:disconnect()`. Re-attaches the NIC and issues a `set_link(true)`.

### `nic:vm_name()` / `nic:bridge()`

Accessors. `:vm_name()` returns the VM's name; `:bridge()` returns the bridge's name.

## Example: link-down recovery

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

    -- Baseline.
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()

    local nic = a:nic("lan")
    nic:disconnect()
    -- Pings should fail now.
    local r = a:run("ping -c 1 -W 1 10.0.0.2")
    t:assert(not r:ok())

    nic:reconnect()
    -- And succeed after reconnect.
    a:run("ping -c 1 -W 1 10.0.0.2"):assert_ok()
end)
```

## See also

- [Bridge](/provium/reference/bridge.md) — bridge-wide controls (partition, impairments, capture).
- [Streams](/provium/reference/streams.md) — what `nic:capture()` returns.
- [Bridges and impairments](/provium/writing-tests/bridges-and-impairments.md) — patterns for using NIC and bridge handles together.

---

# File

_Provium / Reference_

> A File userdata wraps an open file descriptor inside the guest. Use it to read, write, seek, tell, fd-to-int, tail, and close. Mirrors POSIX semantics.

A File is an open guest-side file handle, returned by `vm:open_file(path, mode)` or `worker:open_file(path, mode)`. Once closed, all ops error with `file is closed`. Idempotent: closing twice is safe.

## Constructing

| Source | Returns |
|---|---|
| `vm:open_file(path, mode)` | New file handle in the guest. |
| `worker:open_file(path, mode)` | Same but allocated under a worker's namespace. |

Mode table fields: `read`, `write`, `create`, `truncate`, `append`, `exclusive`, `perm`. See [VM open_file](/provium/reference/vm.md#vmopen-filepath-mode-table) for the full mode-table reference.

At least one of `read`, `write`, or `append` must be true.

## Methods

### `file:read(n)`

Read up to `n` bytes from the current cursor. Returns the bytes as a Lua string. Returns the empty string at EOF (POSIX `read` semantics — never errors on EOF).

```lua
local f = vm:open_file("/etc/hostname", {read=true})
local s = f:read(64)
```

### `file:read_all()`

Drain the file from the current cursor to EOF. Returns the bytes as a Lua string. Internally chunked at 64 KiB. At EOF, returns the empty string.

### `file:write(data)`

Write `data` (a Lua string of bytes) at the current cursor. Returns the number of bytes actually written (which may be less than `#data` on a partial write — POSIX semantics).

### `file:seek(offset, whence?)`

Reposition the cursor. `whence` defaults to `"set"`; valid values: `"set"`, `"cur"`, `"end"`. Anything else errors with `seek: whence must be set/cur/end, got \`X\``.

Returns the new absolute offset.

### `file:tell()`

Returns the current cursor position. This authoritatively re-reads from the agent (a no-op `Seek(Cur, 0)`) rather than trusting the host-side cursor cache, so `tell()` stays honest when a previous read or write returned mid-flight.

### `file:close()`

Close the file. Idempotent: a second `:close()` does not error. After close, every other op errors with `file is closed`.

### `file:fd()`

Returns the raw u64 handle id. Useful for `vm:ioctl(fd, …)`, `vm:syscall(…)` (where the fd is one of the integer args), or `vm:fd_stream(fd)` to open a streaming subscription against the same file.

After close, returns `0`.

### `file:tail_stream()`

Open a [Tail](/provium/reference/streams.md) stream rooted at the file's current cursor. Streams new bytes appended after the current position. Useful for "give me a stream that starts here" without re-opening the file.

Errors with `file:tail_stream needs the source path; opened via wrap() not wrap_with_path()` if the File was constructed without a recorded path (which never happens through `vm:open_file` — it always records the path).

## Example

```lua
test("file handle round-trip", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local h = vm:open_file("/tmp/data", {write=true, create=true, truncate=true, perm=0x180})  -- 0o600
    local n = h:write("hello world")
    t:assert_eq(n, 11)
    h:close()

    local r = vm:open_file("/tmp/data", {read=true})
    r:seek(6, "set")
    t:assert_eq(r:read(5), "world")
    t:assert_eq(r:tell(), 11)
    r:close()
end)
```

## EOF semantics

Reading past EOF returns the empty string `""`, not nil and not an error. Tests that loop "read until empty" should use:

```lua
while true do
    local chunk = h:read(4096)
    if chunk == "" then break end
    -- process chunk
end
```

## See also

- [VM](/provium/reference/vm.md) — `vm:open_file`, `vm:read_file`, `vm:write_file`, `vm:fd_stream`.
- [Worker](/provium/reference/worker.md) — `worker:open_file` for files allocated under a worker's namespace.
- [Streams](/provium/reference/streams.md) — what `file:tail_stream()` returns.
- [Files and handles](/provium/writing-tests/files-and-handles.md) — patterns for guest-side file I/O.

---

# Process

_Provium / Reference_

> A Process userdata is a handle to an async-spawned guest process. Use it to wait, kill, signal, write stdin, stream stdout/stderr, and query exit status.

A Process is what `vm:run_async(...)` (or `worker:run_async(...)`) returns: a handle to a guest process that the agent has launched but is not waiting on. You control the lifetime — the agent does not auto-kill it.

## Constructing

| Source | Returns |
|---|---|
| `vm:run_async(cmd, opts?)` | New process under the VM's main agent. |
| `worker:run_async(cmd, opts?)` | New process under a worker namespace. |

Opts mirror `vm:run`'s shape (`args`, `env`, `env_clear`, `cwd`), but the `timeout` / `timeout_ms` keys are rejected with a pointer to use `proc:wait(timeout)` instead. `stdin` works (initial bytes go through `RunAsyncArgs`); subsequent input goes through `proc:stdin_write`.

## Methods

### `proc:wait(timeout?)`

Wait for the process to exit. Returns a [RunResult](/provium/reference/vm.md#runresult) userdata.

`timeout` accepts:
- `nil` — wait forever.
- A number — seconds.
- A string — `"5s"` / `"500ms"` / `"5m"` / `"2h"`.

Passing `0` is rejected with a pointer to use `proc:status()` for non-blocking polling — a literal-zero timeout would otherwise SIGKILL the process immediately because the agent's `wait_with_timeout(0)` sees the deadline already past.

After `:wait()` returns, the agent-side slot is gone. The Process is "consumed"; subsequent ops still work, but `:close()` short-circuits without re-killing.

### `proc:kill(sig?)`

Send a signal. `sig` accepts:

- `nil` — defaults to `SIGTERM`.
- An integer — that signal number.
- A string — friendly name. Recognised: `term`/`sigterm`/`15`, `kill`/`sigkill`/`9`, `int`/`sigint`/`2`, `hup`/`sighup`/`1`, `quit`/`sigquit`/`3`, `stop`/`sigstop`, `cont`/`sigcont`, `usr1`/`sigusr1`/`10`, `usr2`/`sigusr2`/`12`, `alrm`/`sigalrm`/`14`, `pipe`/`sigpipe`/`13`, `chld`/`sigchld`/`17`, `winch`/`sigwinch`. Comparison is case-insensitive.

Unknown name → `unknown signal name \`X\``.

### `proc:signal(sig)`

Same as `:kill(sig)`. Read better when the signal is non-fatal (`proc:signal("usr1")`).

### `proc:pid()`

Returns the kernel PID of the process inside the guest. Fetched live via the agent's GetPid op.

### `proc:handle()`

Returns the opaque agent-side handle id (an integer). Distinct from `pid()` — the handle stays stable across forks; the PID can change if the process re-execs.

### `proc:status()`

Non-blocking poll. Returns the agent's view of the process's status without waiting. Useful for "is it still running" without blocking.

### `proc:stdin_write(data)`

Write `data` (Lua string) to the process's stdin. Returns whatever the agent reports (typically the byte count).

### `proc:close_stdin()`

Close the stdin pipe. After this, subsequent `:stdin_write` errors.

### `proc:stdout_stream()` / `proc:stderr_stream()`

Open a [Tail](/provium/reference/streams.md) stream subscribed to captured stdout / stderr. The agent polls the captured-output buffer and emits frames. Useful when the process produces a lot of output you want to consume incrementally.

The returned Tail's `StreamMeta` is pre-filled with `kind="proc_stdout_stream"` (or `"proc_stderr_stream"`) and the process handle id, so snapshot diagnostics show meaningful detail.

### `proc:close()`

Auto-close hook. If `:wait()` already happened, this is a no-op. Otherwise, sends SIGTERM, then waits up to 2 seconds for exit (the agent escalates to SIGKILL on its own timeout).

## Example: tail logs while the process runs

```lua
test("server emits ready then handles request", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local proc = vm:run_async("python3", {args = {"server.py"}})
    local out  = proc:stdout_stream()

    -- Wait for the server to log "ready".
    out:expect("ready", "5s")

    -- Now hit it.
    local r = vm:run("curl -s http://localhost:8080/")
    r:assert_ok()
    t:assert(r.stdout:find("hello"))

    -- Tear down. Inside the test scope this happens automatically;
    -- here we just demonstrate it works explicitly too.
    proc:kill("term")
    local exit = proc:wait("2s")
    t:assert(exit.signal == 15 or exit.exit_code == 0)
end)
```

## See also

- [VM](/provium/reference/vm.md) — `vm:run_async` returns a Process.
- [Worker](/provium/reference/worker.md) — `worker:run_async` returns a Process.
- [Streams](/provium/reference/streams.md) — what `proc:stdout_stream` / `proc:stderr_stream` return.
- [Running commands](/provium/writing-tests/running-commands.md) — patterns for sync vs async, stdin, env.

---

# Worker

_Provium / Reference_

> A Worker userdata gives a test concurrent agent connections to the same guest VM, so you can drive multiple ops in parallel without spinning up another VM.

A Worker is what `vm:spawn_worker()` returns: a sub-agent connection to the same VM. It exposes the same VM-style API for running commands, opening files, and issuing syscalls — handles allocated under it live in the worker's own namespace on the agent side.

Workers are not a hard isolation boundary. They're bookkeeping namespaces — handles are routable from outside the worker, but the agent tracks per-worker membership for cleanup. Enforced per-worker isolation is not currently supported; treat workers as parallelism, not security.

## Constructing

| Source | Returns |
|---|---|
| `vm:spawn_worker()` | New worker on the VM's main agent. |
| `vm:spawn_worker({thread = false})` | Same. (`{thread = true}` is rejected with a future-work pointer.) |

## Methods

The worker mirrors the VM's run / file / syscall surface. Where semantics differ from the VM equivalent, it's noted explicitly.

### `worker:run(cmd, opts?)`

Synchronous exec. Same call shape as [`vm:run`](/provium/reference/vm.md#vmruncmd-or-args-opts). Returns a [RunResult](/provium/reference/vm.md#runresult).

### `worker:run_async(cmd, opts?)`

Async spawn. Returns a [Process](/provium/reference/process.md). Like `vm:run_async`, the `timeout` opt is rejected — pass it to `proc:wait` instead.

The returned Process is auto-registered with the test's resource registry, so the scope walker SIGTERMs and reaps it at scope end. Without this, a worker-spawned child would leak past the test boundary.

### `worker:open_file(path, mode_table)`

Open a guest-side file under the worker's namespace. Returns a [File](/provium/reference/file-handle.md). Same mode-table shape as `vm:open_file`.

The returned File is auto-registered with the test scope so `file:close()` fires automatically at scope end.

### `worker:syscall(nr, …)`

Direct syscall. Same call shape as [`vm:syscall`](/provium/reference/vm.md#vmsyscallnr-) — both the integer-only and table forms work. Returns the same `{ret, result, errno, out_bufs}` shape.

### `worker:kill(sig?)`

Broadcast a signal to every async process spawned under this worker. Argument shape mirrors `proc:kill`: nil → SIGTERM, int → that signal, string → friendly name.

A bad argument type (e.g. a Lua table) errors with the type-name in the message rather than silently defaulting to SIGTERM. This catches test-code typos.

### `worker:join()`

Wait for every in-flight worker child to exit. Returns whatever the agent reports as the joined exit summary.

### `worker:handle()`

Returns the worker's opaque handle id.

### `worker:close()`

Auto-close hook. SIGTERMs every in-flight worker child, then joins. The scope walker fires this at scope end.

## Concurrency pattern

Workers shine for tests that need two threads of control inside the same guest:

```lua
test("two concurrent writers don't tear", function(t)
    local vm = provium:vm("v", "peios"):boot()
    local w1 = vm:spawn_worker()
    local w2 = vm:spawn_worker()

    -- Each worker writes its own block of data, in parallel.
    local p1 = w1:run_async("dd", {args = {"if=/dev/urandom", "of=/tmp/a", "bs=1M", "count=8"}})
    local p2 = w2:run_async("dd", {args = {"if=/dev/urandom", "of=/tmp/b", "bs=1M", "count=8"}})

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

    t:assert_eq(vm:stat("/tmp/a").size, 8 * 1024 * 1024)
    t:assert_eq(vm:stat("/tmp/b").size, 8 * 1024 * 1024)
end)
```

For coordination between workers' guest processes, use a guest-side primitive (file, fifo, etc.). [`lab:barrier(name, count, timeout?)`](/provium/reference/lab.md#labbarriername-count-timeout) is a host-side rendezvous — a worker's guest processes can't call it. See [Labs and scope — Barriers](/provium/writing-tests/labs-and-scope.md#barriers) for what barriers can and can't synchronise today.

## See also

- [VM](/provium/reference/vm.md) — `vm:spawn_worker()` and the parent surface.
- [Process](/provium/reference/process.md), [File](/provium/reference/file-handle.md) — what worker ops return.

---

# Streams

_Provium / Reference_

> The three stream types — Tail, Capture, and ConsoleStream — and the shared surface of next, read_until, expect, drain, close, eof, and creation_site.

Provium has three concrete stream types. They expose a shared surface, so test code can `:next` / `:read_until` / `:expect` / `:drain` / `:close` / `:eof` / `:creation_site` against any of them without caring which kind it is.

| Type | Returned by | Backing |
|---|---|---|
| **Tail** | `vm:tail_file`, `vm:fd_stream`, `file:tail_stream`, `proc:stdout_stream`, `proc:stderr_stream` | Frame-based agent stream over vsock. |
| **Capture** | `bridge:capture`, `nic:capture` | `tcpdump` child stdout — pcap bytes, not framed. |
| **ConsoleStream** | `console:read` (returned by `vm:console():read()`) | Unix socket connected to QEMU's console chardev. |

All three buffer "leftover bytes" past the last `expect`/`read_until` match. A subsequent `:next` returns those leftover bytes as the next frame, so test code never silently loses data.

## Common methods

### `:next(timeout?)`

Returns the next chunk of bytes as a Lua string, or `nil` at EOF / timeout. The exact frame shape:

- **Tail**: one frame per agent-side write. Each frame is whatever bytes the guest had emitted since the previous frame.
- **Capture**: chunks of pcap bytes from tcpdump's pipe (variable size, up to 64 KiB).
- **ConsoleStream**: chunks of console bytes (variable size, up to 64 KiB).

The `timeout` argument accepts seconds (number) or a string with `ms`/`s`/`m`/`h` suffix. Default: 10 seconds.

### `:read_until(pattern, timeout?)`

Pull frames until `pattern` (a Lua string) is found in the accumulated buffer. Returns the prefix up to and including the matched bytes. Errors with a pattern-naming message on timeout (`read_until timed out waiting for \`X\``) or stream EOF (`stream EOF`).

Bytes past the matched suffix are kept as `pending` and replayed on the next call.

Default timeout: 10 seconds.

### `:expect(pattern, timeout?)`

Like `:read_until` but discards the matched prefix. Returns nothing. Useful as an assertion: "the stream produced this; I don't care about the bytes". Same error shape as `read_until`.

Default timeout: 10 seconds.

### `:drain(timeout?)`

Collect every frame currently available and return them as a Lua array. Stops when the next read would block past the timeout, when EOF is reached, or when no more data is available.

Frame-per-element semantics — each iteration of the inner read produces one Lua-string entry. Pending bytes from a prior `expect`/`read_until` come out as the first entry.

Default timeout: 0.5 seconds (much shorter than `next`/`read_until`/`expect` because drain's intent is "what's available right now, then return").

### `:close()`

Close the underlying transport. Subsequent `:next` returns `nil`; subsequent `:read_until`/`:expect` error with `stream closed` / `console closed` / `capture closed`. Idempotent.

Pending bytes are dropped on close so a `:next` after close can't drain stale data.

### `:eof()`

Returns `true` once the stream's transport has reported EOF AND no `pending` bytes remain. Pending bytes mean there's still data to deliver before EOF is honest, so `eof()` returns `false` until they've been consumed.

### `:creation_site()`

Returns `{file=string, line=int}` for the test-author frame that opened the stream, or `nil` if no `*.test.lua`/`*.fixture.lua` frame is on the stack at creation. Useful for snapshot diagnostics — when `vm:snapshot()` refuses because of a live stream, the error names the offending stream's creation site so you can fix it.

## Tail-only behaviour

`vm:tail_file(path, opts?)` accepts a `start` opt:

| Value | Effect |
|---|---|
| `"end"` (default) | Stream only bytes appended after the call. |
| `"beginning"` or `"start"` | Replay the whole file from byte 0, then continue tailing. |
| Non-negative integer | Start streaming from that exact byte offset. |
| Negative integer | Start N bytes before EOF — Provium stats the file and resolves to an absolute offset. If N exceeds the current file size, the stream starts at byte 0. |
| Finite float | Truncated toward zero, then treated as the integer cases above (negative floats count back from EOF). |

A closed Tail returns `nil` from `:next` — the closed stream is semantically EOF. This matches Capture and ConsoleStream, so `while s:next() do … end` loops terminate cleanly.

Tails carry per-frame metadata visible to scope-end diagnostics: `kind` (e.g. `tail_file`, `fd_stream`, `file_tail_stream`, `proc_stdout_stream`), `detail` (the path or fd), `creation_site`, and `test_name`.

## Capture-only notes

Capture spawns `tcpdump -i <iface> -U -w - -s 65535`. The output is pcap-formatted bytes — the standard pcap file format with global header followed by per-packet records. Feed it through `pcap-parser`, `tshark -r -`, or `pyshark` to interpret.

The Capture holds a guard against the bridge's `active_captures` counter so a `vm:snapshot()` while capture is live errors instead of silently producing a half-captured pcap.

Drop on close prevents `:next` from returning stale tcpdump bytes after the child has been killed.

## ConsoleStream-only notes

ConsoleStream connects via `UnixStream` to the VMM's console chardev path (`vm:console_socket_path()`). Reads come back as raw bytes from the chardev — typically the boot console + login prompt + anything the guest has written to `/dev/ttyS0` since the last read.

The connect-time read timeout is 50 ms. The `:next(timeout)` arg overrides it for the duration of the call and the previous timeout is restored afterwards, so a `next("5s")` followed by a bare `next()` doesn't inherit the 5s deadline forever.

Mid-stream `ConnectionReset` and `BrokenPipe` errors are mapped to "console EOF" — they happen when the QEMU chardev closes mid-read on a VM reset or shutdown. Semantically EOF for a console stream, not a real I/O error.

## Example patterns

### Wait for a log line

```lua
local f = vm:open_file("/var/log/messages", {read=true})
local stream = f:tail_stream()
stream:expect("ready", "30s")
```

### Race a process against a timeout

```lua
local proc = vm:run_async("slow-server")
local out  = proc:stdout_stream()
out:expect("listening", "10s")
-- now hit it
```

### Drain a capture and pass through pcap-parser

```lua
local cap = bridge:capture()
vm:run("ping -c 5 10.0.0.2")
local frames = cap:drain("2s")
local pcap = table.concat(frames)
-- write pcap to a file or feed it to a parser
```

## See also

- [VM](/provium/reference/vm.md) — `vm:tail_file`, `vm:fd_stream`.
- [Bridge](/provium/reference/bridge.md) — `bridge:capture`.
- [Nic](/provium/reference/nic.md) — `nic:capture`.
- [Console](/provium/reference/console.md) — `console:read` returns a ConsoleStream.
- [Process](/provium/reference/process.md) — `proc:stdout_stream` / `proc:stderr_stream` return Tails.
- [File](/provium/reference/file-handle.md) — `file:tail_stream` returns a Tail.
- [Streams and tails](/provium/writing-tests/streams-and-tails.md) — patterns and gotchas.

---

# Console

_Provium / Reference_

> The Console userdata — read the boot log, stream the chardev, expect a substring, write input, and how it interacts with snapshots.

A Console wraps the guest's serial console — the host writes to and reads from QEMU's chardev bound to the guest's serial port (`/dev/ttyS0`). Provium appends `console=ttyS0` to the kernel command line if no serial console is present, so kernel output always lands here.

## Constructing

| Source | Returns |
|---|---|
| `vm:console()` | The guest's console. Always succeeds; underlying chardev access is checked at the first `:read` / `:read_log` / `:write` call. |

## Methods

### `console:read()`

Returns a [ConsoleStream](/provium/reference/streams.md) backed by a `UnixStream` to the QEMU chardev socket. Use the stream's `:next` / `:read_until` / `:expect` / `:drain` to consume console output incrementally.

The stream is registered with the VM's resource graph, so an open `console:read()` blocks `vm:snapshot()` from succeeding silently — the snapshot precondition reports "live console stream" with creation site.

Errors with `console:read: VMM does not expose a console socket` if the VMM backend doesn't surface a chardev path (rare; the LocalAgent backend does not).

### `console:read_log()`

Returns the entire captured console log to date as a Lua string. Snapshot-style; not a stream. Useful for one-shot inspections:

```lua
local log = vm:console():read_log()
t:assert(log:find("Welcome to Peios"))
```

### `console:expect(pattern, timeout?)`

Poll the captured log every 50 ms until `pattern` (a Lua string) appears or `timeout` lapses. Default timeout: 30 seconds. Returns the matched substring on hit; raises with the pattern in the message on timeout.

This is the simple form for "wait for the kernel to log X." For long-running streams or when you also want to read past-the-match bytes, use `console:read():expect(...)` instead.

### `console:write(data, opts?)`

Write `data` (Lua string) to the chardev. Opts:

| Key | Type | Effect |
|---|---|---|
| `timeout` | number (seconds) or string `"500ms"` / `"5s"` | Bound the blocking write. `0` means no timeout. |

The legacy `timeout_ms` integer key is **rejected** with a clear error (`opts.timeout_ms is not supported (use timeout = "500ms" or timeout = 0.5)`).

Use this to feed input to interactive console programs, or to pre-populate a getty login.

### `console:close()`

No-op in v1. The chardev itself is owned by the VMM; closing the Console userdata is a Lua-level concept only.

## Example: drive a getty login

```lua
test("login as root via console", function(t)
    provium.timeout = "60s"
    local vm = provium:vm("v", "peios"):boot()
    local console = vm:console()
    local stream  = console:read()
    stream:expect("login:", "30s")
    console:write("root\n")
    stream:expect("Password:", "5s")
    console:write("toor\n")
    stream:expect("# ", "5s")
end)
```

## See also

- [VM](/provium/reference/vm.md) — `vm:console()`.
- [Streams](/provium/reference/streams.md) — what `console:read()` returns.

---

# Clock

_Provium / Reference_

> The Clock userdata — set, advance, sleep, and query the guest's wall clock in seconds or nanoseconds, including moving time backwards.

A Clock wraps the guest's wall clock. Operations dispatch to the agent, which adjusts the kernel's `CLOCK_REALTIME` (and equivalents) under the hood.

The clock is signed — you can move the guest backwards in time. This matters for testing time-dependent code: certificate expiry, timeout handling, leap-second handling, retry backoff.

## Constructing

| Source | Returns |
|---|---|
| `vm:clock()` | The guest's clock. |

## Methods

### `clock:get()`

Returns the current time as seconds-since-epoch (float). For sub-microsecond accuracy use `:get_ns()`.

```lua
local t = vm:clock():get()
-- t ≈ 1700000000.0
```

### `clock:get_ns()`

Returns nanoseconds-since-epoch as a 64-bit signed integer. Lua 5.4 integers are 64-bit so no precision is lost.

### `clock:set(seconds)`

Set the wall clock to `seconds` seconds since epoch. Accepts integers or floats. Negative values are allowed — the guest goes pre-epoch, useful for testing `time_t` boundary handling.

Errors on NaN or infinity (`clock:set: value must be a finite number (got NaN/inf)`). Without this guard, the integer cast would silently saturate or round to zero.

### `clock:set_ns(ns)`

Set the wall clock to `ns` nanoseconds since epoch (i64). Same semantics as `:set` but at full precision.

### `clock:sleep(duration)`

Sleep for `duration` seconds. Accepts:

- Integer / float — seconds.
- String — `"100ms"` / `"5s"` / `"5m"` / `"2h"`.

Errors on NaN, infinity, or negative.

### `clock:advance(duration)`

Advance the wall clock by `duration` seconds (signed). Unlike `:sleep`, this does not block — the kernel's `CLOCK_REALTIME` jumps forward (or backward).

Negative values are explicitly allowed (`clock:advance(-3600)` rolls the guest back an hour). Errors on NaN or infinity but not on negative.

## Example: certificate expiry test

```lua
test("client refuses an expired cert", function(t)
    local vm = provium:vm("v", "peios"):boot()
    -- Set the clock to before the cert was issued.
    vm:clock():set(1500000000)  -- 2017-07-14
    local r = vm:run("openssl s_client -connect server:443 < /dev/null")
    r:assert_ok()  -- baseline: cert is valid

    -- Jump past expiry.
    vm:clock():advance(20 * 365 * 24 * 3600)  -- +20 years
    local r2 = vm:run("openssl s_client -connect server:443 < /dev/null")
    t:assert(not r2:ok())  -- cert is now expired
    t:assert(r2.stderr:find("certificate has expired"))
end)
```

## Notes

- `clock:get()` (float) loses precision below ~250 ns at modern epoch values because of f64 mantissa width. Use `:get_ns()` if you need exact ns roundtrip.
- The clock is not a guest namespace — every process inside the guest sees the same time axis after `:set` / `:advance`.
- Boot-time initial clock is set via `boot_opts.initial_time` (see [VM boot opts](/provium/reference/vm.md#boot-opts)). The Clock methods are for adjustment after boot.

## See also

- [VM](/provium/reference/vm.md) — `vm:clock()` and the `boot_opts.initial_time` boot-time setter.

---

# Snapshot and LabSnapshot

_Provium / Reference_

> Snapshot and LabSnapshot userdata wrap the on-disk artefacts from vm:snapshot() and lab:snapshot() — path, size, and delete.

`vm:snapshot()` returns a **Snapshot** wrapping a single file. `lab:snapshot()` returns a **LabSnapshot** wrapping a directory containing per-VM snapshot files plus a `lab.json` index.

Both are thin handles over filesystem paths. They have no agent-side state — once the snapshot file or directory is on disk, the userdata just gives you ergonomic access to it.

## Snapshot

### Constructing

| Source | Returns |
|---|---|
| `vm:snapshot()` | New snapshot at a fresh tempfile. |
| `vm:snapshot("/explicit/path")` | Snapshot at that exact path. |

A fixture's chunk returns a Snapshot (single-VM fixture); the harness reads the path, compresses + sparsifies the file, and installs it into the cache.

### Methods

#### `snap:path()`

Returns the on-disk path as a Lua string.

#### `snap:size()`

Returns the file size in bytes. Returns `0` rather than ENOENT if the file has been moved or deleted (idempotent with `:delete()`).

#### `snap:delete()`

Best-effort delete. Idempotent — calling on a path that's already gone is fine. Returns nothing.

## LabSnapshot

### Constructing

| Source | Returns |
|---|---|
| `lab:snapshot()` | New lab snapshot in a fresh tempdir. |
| `provium:snapshot()` | Same, on the root lab. |

A lab fixture's chunk returns a LabSnapshot; the harness moves the directory into the cache as `<key>.lab/`.

### Methods

#### `lab_snap:path()`

Returns the on-disk directory path as a Lua string.

#### `lab_snap:size()`

Returns the sum of file sizes directly under the directory (one level deep, doesn't recurse). Useful for picking large fixtures out of the cache.

#### `lab_snap:delete()`

Recursively deletes the directory. Idempotent — calling on a path that's already gone is fine.

## Example: build a fixture

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

```lua
-- tests/fixtures/cluster.fixture.lua
provium:bridge("lan")
local a = provium:vm("a", "peios"):boot()
local b = provium:vm("b", "peios"):boot()
provium.lan:attach({a, b})
a:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
b:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")
return provium:snapshot()
```

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

test("cluster fixture brings two VMs", function(t)
    local cluster = provium:lab_fixture("fixtures/cluster")
    cluster.a:run("ping -c 1 10.0.0.2"):assert_ok()
end)
```

## See also

- [VM](/provium/reference/vm.md) — `vm:snapshot()`, `vm:restore()`.
- [Lab](/provium/reference/lab.md) — `lab:snapshot()`, `lab:restore()`, `lab:vm_fixture()`, `lab:lab_fixture()`.
- [Fixtures and dependencies](/provium/running-tests/fixtures-and-dependencies.md) — how snapshots become cached fixtures.

---

# Test framework

_Provium / Reference_

> test() registers tests, the t context provides assertions and skip / fail / log, todo() skips a whole file, and wait_until() polls a predicate.

Provium's test framework is implemented mostly in Lua and installed onto every test file's state. This page documents every callable: `test`, `todo`, `wait_until`, the `t` context, and the per-test metadata table.

## `test(name, [meta,] fn)`

Register a test in declaration order. Two call shapes:

```lua
test("simple", function(t)
    t:assert(true)
end)

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

| Argument | Required | Type | Description |
|---|---|---|---|
| `name` | yes | string | Test name. Must be unique within the file (duplicate names raise at registration). |
| `meta` | no | table | Per-test metadata. See [meta tags](/provium/reference/meta-tags.md). |
| `fn` | yes | function | The test body, called with `(t)` — the test context. |

Tests run sequentially in declaration order. Each test gets a fresh `t` context. The harness wraps the call in `pcall` semantics — a Lua `error()` becomes a `Failed` outcome; clean return becomes `Passed`; explicit `t:skip()` becomes `Skipped`.

Duplicate names within a file error at registration time:

```
test: duplicate name `foo` in this file
```

## `todo(reason?)`

Declarative file-scope skip. When called at top level (before any `test()` body runs), every registered test is reported `Skipped` with the given `reason`. Test bodies are not executed.

```lua
todo("waiting on the spec")
test("a", function(t) … end)
test("b", function(t) … end)
-- Both report Skipped with reason "waiting on the spec".
```

Without an argument, defaults to `"todo"`.

Skipped tests still emit `TestSkipped` events with the meta intact, so coverage and dashboards see the same shape they would for an inline `t:skip()`.

## `wait_until(predicate, opts?)`

Call `predicate` repeatedly until it returns truthy (and return that value), or until the timeout lapses. Useful for polling guest-side conditions that don't have a stream interface.

```lua
local pid = wait_until(function()
    local r = vm:run("pidof nginx")
    if r:ok() then return r.stdout:match("%d+") end
end, {timeout = "30s", interval = "200ms", desc = "nginx running"})
```

Opts:

| Field | Default | Type | Description |
|---|---|---|---|
| `timeout` | `10` | number (seconds) or string `"30s"` / `"500ms"` / `"5m"` / `"2h"` | Total time to wait before giving up. |
| `interval` | `0.1` | number (seconds) | Time between predicate calls. |
| `desc` | `"condition"` | string | Used in the timeout error message. |

On timeout, errors with `wait_until: <desc> not met within <timeout>s`. The error includes the offending input (e.g. `bad timeout string ...`) when the timeout argument is malformed.

The predicate runs under `pcall` — if it raises, the error propagates immediately (with the raise location) rather than being treated as a "not yet" signal.

`wait_until` uses wall-clock seconds, not CPU time, so the deadline elapses while `_provium_sleep` is pausing.

## The `t` context

A fresh `t` table is built per test. It carries the test's name and metadata, and the runner keeps a sticky per-test failure record so a `pcall(t:assert(false))` is still classified as a failure (the inner pcall swallows the raise, but the failure was recorded before it).

### Fields

- `t.name` — the test's name.
- `t.meta` — the per-test metadata table (or `{}` if none was given).

### Assertions

Every assertion error includes the offending values in the message — assertion failures are self-explanatory without needing to read context.

#### `t:assert(cond, msg?)`

Raise if `cond` is falsy. `msg` is the failure message; defaults to `"assertion failed"`.

#### `t:assert_eq(a, b, msg?)`

Raise if `a ~= b`. The error message includes both values: `<msg>: <a> ~= <b>`.

#### `t:assert_neq(a, b, msg?)`

Raise if `a == b`. Mirror of `assert_eq`.

#### `t:assert_contains(haystack, needle, msg?)`

Raise unless `string.find(haystack, needle, 1, true)` returns non-nil. **Both arguments must be strings**; non-string args raise immediately with a "slice 2 limit" pointer.

#### `t:assert_raises(fn, msg?)`

Run `fn` under `pcall` and assert that it raised. Returns the error value if the raise happened. Errors with `<msg>: expected to raise` if `fn` returned cleanly.

The sticky failure record is saved before invoking `fn` and restored on a successful raise — that way an assertion *inside* `fn` (used to trigger the expected raise) doesn't poison the rest of the test.

### Skip and fail

#### `t:fail(msg?)`

Mark the test failed and raise. `msg` defaults to `"explicit failure (t:fail)"`.

#### `t:skip(reason)`

Mark the test skipped and raise via the internal sentinel. `reason` is recorded in the `TestOutcome` and emitted in the `TestSkipped` event.

```lua
test("only on btrfs", function(t)
    if vm:run("findmnt /"):ok() and not vm:run("findmnt -t btrfs /"):ok() then
        t:skip("not a btrfs root")
    end
    -- … btrfs-specific test body …
end)
```

### Logging

#### `t:log(msg)`

Append `msg` (any value, converted via `tostring`) to the test's log array. The log is included in the `FileOutcome.tests[i].log` and emitted alongside `TestPassed` / `TestFailed` events.

Useful for diagnostic output that should accompany failures. Unlike `print`, `t:log` is structured and tied to the specific test.

## Outcomes

A test ends in one of three states:

| Status | Triggered by |
|---|---|
| **Passed** | Test fn returned without raising AND no assertion failure was recorded. |
| **Failed** | Test fn raised (via `error()`, `t:assert*`, `t:fail`), or a sticky assertion failure was recorded before a pcall caught the raise. A Rust panic during the test also marks Failed and poisons every subsequent test in the file. |
| **Skipped** | `t:skip(reason)` was called, OR `meta.skip = true` / `meta.skip = "reason"`, OR `--tag` / `--no-tag` filtered the test out, OR `--include-slow` was off and `meta.slow = true`, OR file-scope `todo()` was called. |

A skipped test produces no event lifecycle other than `TestSkipped`. A failed test additionally captures the last 4 KiB of every booted VM's console log into the `TestFailed.console_excerpt` field — useful for diagnosing kernel-side regressions without reproducing the run.

## Time and timeouts

Per-test timeouts come from `meta.timeout`. File-default timeouts come from `provium.timeout`. Per-test wins.

The watchdog fires by tearing down the entire file's lab — there is no finer-grained cancellation in v1 because there is no cooperative cancel point in the AgentClient ops. **Practical implication:** for files using `provium.reset_between_tests = true` the lab is restored before the next test anyway, so the lab-wide tear-down is harmless. For files that share live state across tests, a per-test timeout invalidates the rest of the file. Don't put a `meta.timeout = N` on a flaky test in a state-sharing file expecting siblings to be unaffected.

## See also

- [Meta tags](/provium/reference/meta-tags.md) — full reference for `meta.slow`, `meta.tags`, `meta.skip`, `meta.timeout`, `meta.spec`.
- [provium global](/provium/reference/provium-module.md) — `provium.reset_between_tests`, `provium.timeout`.
- [The test function](/provium/writing-tests/the-test-function.md) — patterns for organising a test file.

---

# Meta tags

_Provium / Reference_

> The metadata table on test() — the well-known keys (slow, skip, tags, timeout, spec), arbitrary fields, and how --tag-meta filtering works.

Each `test(name, meta, fn)` call may include a metadata table. Provium's runner inspects a handful of well-known keys, but every key is preserved in the `MetaMap` it ships in `TestStarted` / `TestPassed` / `TestFailed` / `TestSkipped` events. Consumers like `provium-coverage` use this for spec linkage, classification, and filtering.

## Well-known keys

### `meta.slow`

```lua
test("big batch import", {slow = true}, function(t) … end)
```

When `--include-slow` is **not** passed to the CLI, tests with truthy `meta.slow` (`true` or any non-zero integer) are skipped with reason `filtered: slow tests skipped (pass --include-slow)`.

The default behaviour is "skip slow tests" so `provium tests/` is fast by default. CI and explicit slow runs pass `--include-slow`.

### `meta.skip`

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

Declarative skip. Three accepted forms:

| Value | Skip reason |
|---|---|
| `true` (or any non-zero int) | `"skipped (meta.skip)"` |
| String `"<reason>"` | `"skipped: <reason>"` |
| `false` / `nil` / `0` | Not skipped. |

### `meta.tags`

```lua
test("dns lookup", {tags = {"net", "dns"}}, function(t) … end)
test("ipv6 only", {tags = "ipv6"}, function(t) … end)  -- single string also accepted
```

Tag filtering works through the CLI:

- `provium tests/ --tag net` — run only tests tagged `net`. Repeatable: `--tag a --tag b` is "a OR b".
- `provium tests/ --no-tag dns` — run every test EXCEPT those tagged `dns`. Repeatable; OR'd. Wins over `--tag` (intersect).

A single-string `tags = "smoke"` is accepted alongside the canonical array form. Without the single-string convenience, a one-tag test was silently filtered out by `--tag` with the misleading "did not match --tag" message instead of seeing its lone tag.

### `meta.subsystems` and other arbitrary fields

`meta.tags` is the de-facto field for orthogonal cross-cutting flags (`slow`, `flaky`, `perf`, `federation`). For declaring **what a test exercises** — particularly when test files are organised by user-visible scenario rather than by code subsystem — use a separate field with array values:

```lua
test("DNS service survives peinit restart", {
    subsystems = {"peinit", "loregd", "networking"},
    tags = {"dns", "services"},
    slow = true,
}, function(t) … end)
```

Filter via `--tag-meta KEY=VALUE`:

- `provium tests/ --tag-meta subsystems=peinit` — every test that touches peinit, regardless of where it lives in the directory tree.
- `provium tests/ --tag-meta subsystems=peinit --tag-meta subsystems=loregd` — touches peinit OR loregd (OR within key).
- `provium tests/ --tag-meta subsystems=peinit --tag-meta area=boot` — touches peinit AND is in the boot area (AND across keys).
- `provium tests/ --no-tag-meta flaky=true` — exclude anything tagged flaky=true.

`--tag-meta` accepts string-scalar (`{flaky = "true"}`) and array (`{subsystems = {"a", "b"}}`) meta values; nested-map and integer values don't match. The KEY can be any meta field name; the harness doesn't reserve `subsystems` or any other name.

The convention split:
- **`tags`** — orthogonal flags. "How does this test behave?" (slow, flaky, perf).
- **arbitrary keys (`subsystems`, `area`, `feature`)** — "What does this test exercise?" Filter via `--tag-meta`.

Why two mechanisms? `tags` is for things you'd cumulatively-enable ("run me everything tagged slow OR perf"). Arbitrary keys are for orthogonal axes you'd intersect ("run everything that touches peinit AND is in the boot area"). `--tag-meta`'s AND-across-keys is what makes intersection work.

### `meta.timeout`

```lua
test("flaky network probe", {timeout = "30s"}, function(t) … end)
test("quick", {timeout = 0.5}, function(t) … end)
```

Per-test wall-clock timeout. Numbers are seconds. Strings carry a suffix: `"500ms"` / `"5s"` / `"5m"` / `"2h"`. Per-test wins over the file-default `provium.timeout`.

When the timeout fires, the watchdog tears down the entire file's lab. See [test framework / Time and timeouts](/provium/reference/test-framework.md#time-and-timeouts) for the scope-limitation note.

### `meta.spec`

```lua
test("token issue follows §4.2.1", {spec = "PSD-KACS §4.2.1"}, function(t) … end)
```

Provium's runner does not interpret `meta.spec` — it ships the value through to consumers. `provium-coverage` reads it to map tests to spec-document sections for coverage reports.

By convention: a stable identifier that names the spec section a test exercises. Useful for cross-referencing tests against normative documents.

## Arbitrary keys

Any other key in the meta table is passed through to consumers as-is. Provium's `MetaValue` enum covers:

- `null` / `nil`
- bool
- int (i64)
- float (f64)
- string
- bytes (raw `Vec<u8>`)
- array of meta values
- string→meta map

Nested tables come through as either `Array` (when the table has integer keys 1..N) or `Map` (when string keys are present). The decision is made per nesting level; a mixed table is unusual and not specifically handled.

## Examples

### Multi-tag with skip-on-condition

```lua
test("federation handshake (multi-DC)", {
    tags = {"federation", "slow", "multi-dc"},
    timeout = "5m",
    spec = "PSD-FEDERATION §3.1",
}, function(t)
    if not have_two_dcs() then
        t:skip("requires two DCs")
    end
    -- … real test body …
end)
```

### Conditional gate via `wait_until`

```lua
test("sysctl knob takes effect", {
    spec = "PSD-PEINIT §6.5",
    timeout = "10s",
}, function(t)
    vm:run("sysctl -w kernel.shm_rmid_forced=1"):assert_ok()
    wait_until(function()
        return vm:read_file("/proc/sys/kernel/shm_rmid_forced") == "1\n"
    end, {timeout = "5s", desc = "sysctl knob propagated"})
end)
```

## See also

- [Test framework](/provium/reference/test-framework.md) — `test()`, the `t` context.
- [CLI](/provium/reference/cli.md) — `--tag`, `--no-tag`, `--tag-meta`, `--no-tag-meta`, `--include-slow`.
- [Events](/provium/reference/events.md) — how meta is emitted in test-lifecycle events.

---

# CLI

_Provium / Reference_

> Every flag, subcommand, environment variable, and exit code for the `provium` binary.

Provium's CLI surface is the single binary `provium`. By default it discovers `*.test.lua` files under the given paths and runs them; subcommands cover REPL, fixture management, and listing.

## Synopsis

```
provium [PATHS]... [FLAGS]
provium prepare [PROFILE]
provium repl <PROFILE> [--name <NAME>] [--fixture <PATH>]
provium console <PROFILE> [--mem <BYTES>] [--cpus <N>] [--cmdline <TEXT>] [--agent] [--qemu <PATH>] [--print-command] [-- QEMU_ARG...]
provium fixture list
provium fixture build <PATH>
provium fixture rebuild <PATH>
provium fixture clean
provium fixture stale
provium list [--fixtures]
provium lsp-setup [DIR] [--force]
```

If no paths are given, the current directory is scanned recursively.

## Top-level flags

### Discovery and selection

| Flag | Type | Default | Description |
|---|---|---|---|
| `--config <PATH>` | path | `provium.toml` | Config file location. |
| `--filter <STR>` | string | none | Only run files whose test-root-relative path contains this substring. |
| `--include-slow` | flag | off | Include `meta.slow` tests (default skips them). |
| `--tag <TAG>` | string, repeatable | none | Run only tests with one of these tags. OR'd. |
| `--no-tag <TAG>` | string, repeatable | none | Skip tests with any of these tags. Wins over `--tag`. |
| `--tag-meta <KEY=VALUE>` | string, repeatable | none | Run only tests where `meta[KEY]` contains `VALUE`. Multi-flag same KEY = OR within key; different KEYs = AND across keys. Useful for filtering on arbitrary fields like `subsystems`. |
| `--no-tag-meta <KEY=VALUE>` | string, repeatable | none | Skip tests where `meta[KEY]` contains `VALUE`. Same key/value semantics as `--tag-meta`. Wins over `--tag-meta`. |
| `--rerun-failed` | flag | off | Run only files that failed in the last run. Reads from `~/.cache/provium/rerun.json`. |
| `--since <PATH>` | path | none | Run only files whose mtime is newer than this reference file. |
| `--watch` | flag | off | Re-run on file change (poll every 500 ms). |

### VMM and resources

| Flag | Type | Default | Description |
|---|---|---|---|
| `--vmm <CHOICE>` | `qemu` / `local` | `qemu` | VMM backend. `local` is for dev runs without KVM — does not actually boot a kernel. |
| `--mem <BYTES>` | size string `"4G"` | 80 % of host RAM | Pool memory budget. |
| `--cpus <N>` | int | host online CPUs | Pool vCPU budget. |
| `--cpu-overcommit <F>` | float | `1.0` | Multiplier on `--cpus`. Clamped to [0.5, 8.0]. |
| `--no-preflight` | flag | off | Skip the startup `/dev/kvm` / iproute2 / nft / qemu / `CAP_NET_ADMIN` checks. |
| `--no-ksm` | flag | off | Skip Kernel Same-page Merging tuning at startup. |
| `--no-build` | flag | off | Skip every dynamic profile's `build` command for this run. Use when you know the artifacts are already current. Does not affect `provium prepare`, whose whole purpose is to build. |

### Lifecycle

| Flag | Type | Default | Description |
|---|---|---|---|
| `--timeout <DUR>` | seconds-int or duration string | `300` (5 min) | Per-file wall-clock timeout. `0` disables. Accepts `"500ms"`, `"30s"`, `"10m"`, `"2h"`. |
| `--fail-fast` | flag | off | Stop after the first failed file. |

### Output

| Flag | Type | Default | Description |
|---|---|---|---|
| `-v`, `--verbose` | flag | off | Show passing tests too. Mutually exclusive with `--quiet`. |
| `-q`, `--quiet` | flag | off | Show only failures. Mutually exclusive with `--verbose`. |
| `--json` | flag | off | Line-delimited JSON output, one object per file. Mutually exclusive with `--events-stdout`. |

### Observability

| Flag | Type | Description |
|---|---|---|
| `--save-events <PATH>` | path | Persist the event stream to PATH as length-prefixed msgpack frames. Compatible with `provium-coverage --from PATH`. |
| `--events-stdout` | flag | Emit msgpack event frames on stdout; the human/JSON renderer redirects to stderr. Mutually exclusive with `--json`. |
| `--events-socket <PATH>` | path | Multiplex the event stream over a Unix socket. The binary listens, accepts connections, fans out frames. Reused across `--watch` iterations. |
| `--coverage` | flag | Pipe the buffered event stream into `provium-coverage` (must be on `PATH`) after the run. |

## Subcommands

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

### `provium prepare [PROFILE]`

Run dynamic profiles' [`build`](/provium/configuration/provium-toml.md#build) commands without booting anything. With a `PROFILE` argument, builds just that profile (and errors with `provium prepare: profile \`<name>\` not found in provium.toml` if it isn't declared); with no argument, builds every profile that declares a `build` command, in name order, stopping at the first failure.

Use it to pre-warm artifacts — `provium prepare && provium --no-build` builds once, then runs the suite without rebuilding — or to drive an image build from a machine that isn't a test host: `prepare` skips the pre-flight checks (`/dev/kvm`, iproute2, `CAP_NET_ADMIN`), so it runs anywhere `sh` and the builder do.

A profile with no `build` command is a silent no-op. A build that exits non-zero aborts with the profile name and exit status, and Provium never falls through to using whatever stale artifacts are on disk.

### `provium repl <PROFILE> [--name <NAME>] [--fixture <PATH>]`

Boot a VM and drop into an interactive Lua REPL against it.

| Arg / flag | Description |
|---|---|
| `<PROFILE>` | Profile name from `provium.toml`. Optional when `--fixture` is given. |
| `--name <NAME>` | VM name. Defaults to `repl`. |
| `--fixture <PATH>` | Resume from a fixture instead of cold-booting. |

If `--fixture` is set without a profile arg, the first profile in `provium.toml` (sorted by name) is used.

### `provium console <PROFILE> [flags] [-- QEMU_ARG...]`

Boot a profile's VM and attach your terminal to its serial console — no agent, no Lua. `Ctrl-A X` quits QEMU; `Ctrl-A C` toggles the QEMU monitor.

| Flag | Description |
|---|---|
| `--mem <BYTES>` | Override the per-VM memory default. |
| `--cpus <N>` | Override the per-VM vCPU default. |
| `--cmdline <TEXT>` | Override the profile's kernel command line. |
| `--agent` | Inject the agent overlay (off by default for console sessions). |
| `--qemu <PATH>` | Pick the QEMU binary. |
| `--print-command` | Print the assembled QEMU command line and exit. |

Anything after `--` is passed verbatim to QEMU.

### `provium fixture list`

Show every cached entry with size and key.

### `provium fixture build <PATH>`

Force a build for the named fixture (test-root-relative path, no `.fixture.lua` suffix). If the fixture is already cached, prints `already built: <path>` and exits.

### `provium fixture rebuild <PATH>`

Evict the existing cache entry and rebuild. Both single-VM (`.snap`) and lab (`.lab/`) layouts are evicted.

### `provium fixture clean`

Wipe the entire cache directory.

### `provium fixture stale`

List `.fixture.lua` files whose source hash (folded with kernel + initrd identifiers and any [external host-file deps](/provium/running-tests/fixtures-and-dependencies.md#external-host-file-deps) declared via `vm:push_file` / `lab:depends_on_file`) doesn't correspond to any cached entry. Useful for "what would `provium` rebuild on the next run?"

### `provium list [--fixtures]`

List discovered tests (default) or fixtures (`--fixtures`) without running anything. Useful for piping into `xargs` or for CI dry-runs.

### `provium lsp-setup [DIR] [--force]`

Drop Lua Language Server definitions into a test directory so `test`, `provium`, `wait_until`, `json`, and the rest of the harness globals stop showing up as undefined in your editor.

Writes:

- `<DIR>/.provium-meta/types.lua` — `---@meta` stubs covering the test framework, the `TestContext` (`t:assert_eq`, `t:fail`, …), the root `Lab`, and the `json` global. Less-trafficked methods are typed as `any` — the goal is silencing diagnostics, not full IDE intellisense.
- `<DIR>/.luarc.json` — config pointing the LSP at `.provium-meta/`.

`DIR` defaults to the current directory. If `.luarc.json` already exists, the command refuses to overwrite and prints the snippet to merge in by hand; pass `--force` to replace it.

Skips pre-flight checks (`/dev/kvm`, iproute2, nft) so it runs cleanly on machines without test prerequisites — useful from a dev laptop separate from the test host.

After running, reload your editor's Lua server. To extend the types further (e.g. richer completion on a specific userdata you use a lot), edit `.provium-meta/types.lua` directly.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | Every file passed (or skipped). |
| `1` | At least one file did not finish cleanly (failed test, panicked runner, timed-out file). With `--coverage`, also reflects the coverage post-run's exit code if it failed. |
| `2` | Internal error — config load failure, pre-flight failure, a dynamic profile's `build` command failing, or a panic in the dispatcher. |

The exit code is intentionally clamped to `0/1/2` rather than reflecting the number of failed files, so shell scripts don't have to worry about overflow at the 256-file mark or accidentally interpret `failed_count == 2` as the internal-error sentinel.

## Environment variables

| Variable | Effect |
|---|---|
| `PROVIUM_TEST_FILTER` | JSON object set by the binary at startup carrying `--include-slow` / `--tag` / `--no-tag` / `--tag-meta` / `--no-tag-meta` so the runner picks them up. Test code does not set this directly. |
| `PROVIUM_OVERLAY` | Path to the agent-overlay cpio. Overrides the binary-relative path-walk; lower priority than the per-profile `agent_overlay_path` config field. Useful for distribution-installed Provium where the overlay isn't co-located with the binary. |
| `PROVIUM_RERUN_STATE` | Override the path of the rerun-state file (default: `~/.cache/provium/rerun.json`, or `/tmp/provium-rerun.json` if `$HOME` is unset). |
| `PROVIUM_COVERAGE_TMP` | Set by `--coverage` to point at the temp event file the post-run hook will read. Cleared after the hook completes. |
| `PROVIUM_COVERAGE_USER_FILE` | Set when `--coverage` reuses an explicit `--save-events` path so the post-run hook does NOT delete it. |
| `PROVIUM_COVERAGE_MARKER` | Marker file `--coverage` uses to recognise its own temp file (vs an externally-set `PROVIUM_COVERAGE_TMP` pointing at user data). |
| `HOME` | Used to compute the default rerun-state path. |
| `PATH` | Searched for `qemu-system-x86_64`, `ip`, `tc`, `nft`, `tcpdump`, and (on `--coverage`) `provium-coverage`. |

## Output shape

### Plain text (default)

```
PASS tests/smoke.test.lua (3 passed, 0 failed, 0 skipped)
FAIL tests/networking/partition.test.lua (2 passed, 1 failed, 0 skipped)
    FAIL split-brain
        client A could not reach client B after partition
TIME tests/long.test.lua (1 passed, 0 failed, 0 skipped)

3 file(s); 6 passed, 1 failed, 0 skipped; 12.34s
```

`-v` adds `PASS <name>` / `SKIP <name>` lines for each test. `-q` suppresses everything except the file-status line for files with failures and the summary.

### JSON (`--json`)

One object per file, line-delimited. Schema:

```json
{
  "path": "tests/smoke.test.lua",
  "timeout": "in_time",
  "passed": true,
  "chunk_error": null,
  "tests": [
    {"name": "boots", "status": "passed", "message": null, "log": []},
    {"name": "fails", "status": "failed", "message": "1 ~= 2", "log": []}
  ]
}
```

`status` is one of `"passed"`, `"failed"`, `"skipped"`. `timeout` is `"in_time"` or `"timed_out"`.

### Msgpack events (`--events-stdout`)

Length-prefixed msgpack frames, one per `EventFrame` (see [events](/provium/reference/events.md)). When `--events-stdout` is set, the human-readable / JSON renderer redirects to stderr so consumers piping `provium --events-stdout | provium-coverage` don't see human text interleaved into their msgpack parser.

## Filter precedence

Multiple selection flags compose:

1. Discovery: walk `<paths>` for `*.test.lua` files.
2. `--filter` substring match against test-root-relative path.
3. `--rerun-failed` intersect with the prior failed set (zero-result clean exit if no prior state).
4. `--since` mtime newer than the reference path.
5. Per-test (within each file): `meta.skip` → instant skip.
6. Per-test: `meta.slow` and `--include-slow`.
7. Per-test: `--no-tag` (skip if any match).
8. Per-test: `--tag` (run if any match).
9. Per-test: `--no-tag-meta KEY=VALUE` (skip if `meta[KEY]` contains VALUE).
10. Per-test: `--tag-meta KEY=VALUE` (run if all KEYs have at least one matching VALUE).

`--watch` mode rediscovers from scratch every iteration; `--rerun-failed` is automatically dropped under `--watch` so file-edit detection works as expected.

## See also

- [provium.toml reference](/provium/configuration/provium-toml.md) — config-file fields.
- [Events](/provium/reference/events.md) — wire format of the msgpack event stream.
- [Running tests](/provium/running-tests/the-cli.md) — patterns for the CLI.

---

# Events

_Provium / Reference_

> Every event variant and field in Provium's msgpack event stream — file lifecycle, test outcomes, VM spawns, pool state, claims, and fixture builds.

Provium's host-side scheduler and runners emit observability events that drive the human-readable summary, the `--json` output, `provium-coverage`, and any other consumer that wants to follow what the harness is doing.

The transport is **length-prefixed msgpack frames** (the same framing as the wire protocol). Get them via `--save-events <path>`, `--events-stdout`, or `--events-socket <path>`.

## Frame shape

Each frame is an `EventFrame`:

```
{
  "ts":      <i64>,    // ns since Unix epoch, host clock at emission
  "kind":    "<name>", // discriminator (snake_case)
  "payload": { … }     // variant-specific
}
```

The `Event` enum is flattened into the envelope so `kind` and `payload` appear at the top level of each frame.

`PROTOCOL_VERSION` (currently `1`) is bumped on any wire-shape change. Adding a new variant or field is a strict version bump; removing or renaming one is breaking. See [protocol version](/provium/reference/protocol-version.md) for the pinning policy.

## File lifecycle

### `file_discovered`

Emitted once per `*.test.lua` file before any dispatching begins.

| Field | Type | Description |
|---|---|---|
| `path` | string | Test-root-relative path. |
| `fixture_refs` | array of strings | Fixtures referenced by `vm_fixture(...)` / `lab_fixture(...)` (path with `.fixture.lua` stripped). |
| `declared_claim` | optional `ResourceAmount` | `provium:claim(...)` declared at file scope, if any. |

### `file_dispatched`

A test file was picked up by a runner thread.

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `reservation` | `ResourceAmount` | Resources reserved at dispatch (sum of runner overhead + claim). |

### `file_blocked`

A test file is waiting on the resource pool or PSI pressure.

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `waiting_for` | `ResourceAmount` | What the file is waiting on. |
| `reason` | string | `pool_full`, `psi_pressure`, etc. |

### `file_completed`

A test file finished — successfully, with failures, or terminated by timeout / panic.

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `status` | enum | `passed`, `failed`, `timed_out`, `crashed`. |
| `duration_ns` | u64 | Wall-clock duration. |

`crashed` means the runner panicked or hit a test-infrastructure failure; remaining tests in the file are marked `failed-due-to-poisoned-state`.

## Test lifecycle

### `test_started`

A `test()` block within a file started.

| Field | Type | Description |
|---|---|---|
| `path` | string | Containing file's path. |
| `name` | string | `test()` block name. |
| `meta` | `MetaMap` | Per-test metadata. Always present; empty if none. |

### `test_skipped`

The test was filtered or marked Skipped (declarative `meta.skip`, inline `t:skip()`, file-scope `todo()`, or tag/slow filter).

| Field | Type | Description |
|---|---|---|
| `path` | string | Containing file's path. |
| `name` | string | Test name. |
| `reason` | string | Filter expression, `t:skip()` argument, or `todo()` argument. |
| `meta` | `MetaMap` | Per-test metadata. |

### `test_passed`

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `name` | string | Test name. |
| `duration_ns` | u64 | Test wall-clock duration. |
| `meta` | `MetaMap` | Per-test metadata. |

### `test_failed`

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `name` | string | Test name. |
| `duration_ns` | u64 | Wall-clock up to failure. |
| `reason` | string | Assertion message, exception text, or panic payload. |
| `console_excerpt` | string | Last 4 KiB from each booted VM's console log. May be empty. |
| `meta` | `MetaMap` | Per-test metadata. |

## VM lifecycle

### `vm_spawned`

| Field | Type | Description |
|---|---|---|
| `file` | string | File that owns this VM. |
| `vm_name` | string | Name as given to `lab:vm`. |
| `profile` | string | Profile used to boot. |
| `memory_bytes` | u64 | Memory cap. |
| `cid` | u32 | Assigned vsock CID. |

### `vm_shutdown`

| Field | Type | Description |
|---|---|---|
| `file` | string | File that owned this VM. |
| `vm_name` | string | VM name. |
| `duration_ns` | u64 | VM uptime. |

## Pool and claims

### `pool_state`

Periodic snapshot of resource-pool usage. Default cadence: 1 Hz.

| Field | Type | Description |
|---|---|---|
| `used` | `ResourceAmount` | Currently in use. |
| `available` | `ResourceAmount` | Currently free. |

### `claim_acquired`

A file successfully claimed resources via `provium:claim(...)`.

| Field | Type | Description |
|---|---|---|
| `path` | string | File path the claim belongs to. |
| `amount` | `ResourceAmount` | Amount claimed. |

### `claim_released`

A file released its claim. Pairs with `claim_acquired` on the same `path`.

| Field | Type | Description |
|---|---|---|
| `path` | string | File path. |
| `amount` | `ResourceAmount` | Amount released. |

## Fixtures

### `fixture_build_started`

| Field | Type | Description |
|---|---|---|
| `path` | string | Fixture path (test-root-relative, no `.fixture.lua`). |

### `fixture_build_done`

| Field | Type | Description |
|---|---|---|
| `path` | string | Fixture path. |
| `duration_ns` | u64 | Build duration. |
| `snapshot_bytes` | u64 | Cached snapshot size, post-compression. |

### `fixture_build_waiting`

A second file is waiting on the build lock another file holds.

| Field | Type | Description |
|---|---|---|
| `path` | string | Fixture path being waited on. |
| `held_by_file` | string | File currently holding the build lock. |

### `fixture_cache_hit`

A fixture was resumed from its cached snapshot — no rebuild required.

| Field | Type | Description |
|---|---|---|
| `path` | string | Fixture path. |

## Shared types

### `ResourceAmount`

```
{
  "memory_bytes": <u64>,
  "cpus":         <u32>
}
```

Used in pool / claim / reservation events.

### `MetaMap` and `MetaValue`

`MetaMap` is `BTreeMap<String, MetaValue>`. `MetaValue` is a tagged-by-type union over native msgpack types:

| Variant | msgpack mapping |
|---|---|
| `Null` | unit / nil |
| `Bool` | bool |
| `Int` | i64 |
| `Float` | f64 |
| `Str` | str |
| `Bytes` | bin (distinct from str) |
| `Array` | array of MetaValue |
| `Map` | map of String → MetaValue |

The deserialiser dispatches on the input type rather than relying on declaration-order fallthrough, so valid-UTF-8 byte sequences are not mis-classified as strings.

## Event guarantees

- `file_discovered` for every discovered file, before any dispatching.
- For each file: at most one `file_dispatched`, optional `file_blocked`(s) before it, exactly one `file_completed` after.
- For each test: exactly one `test_started`, then exactly one of `test_passed` / `test_failed` / `test_skipped`.
- `claim_acquired` and `claim_released` are paired per file.
- `fixture_build_started` and `fixture_build_done` are paired per build. A `fixture_build_waiting` may precede the `_done` if the file queued behind a peer.
- `fixture_cache_hit` fires AFTER successful restore — never on a corrupt entry that turns into a rebuild.

## Consuming the stream

The three output channels — `--save-events <path>` (file), `--events-stdout` (pipe), and `--events-socket <path>` (multi-consumer Unix socket) — and the patterns for choosing between them are covered in [events and coverage](/provium/running-tests/events-and-coverage.md#output-channels).

## See also

- [CLI](/provium/reference/cli.md) — flags that produce / consume events.
- [Protocol version](/provium/reference/protocol-version.md) — wire-shape pinning policy.
- [Events and coverage](/provium/running-tests/events-and-coverage.md) — patterns for using the event stream.

---

# Protocol version

_Provium / Reference_

> PROTOCOL_VERSION pins the host-agent wire shape and the event stream — what it pins, when it bumps, and how the pin tests catch silent breakage.

Provium has one wire-version constant: `PROTOCOL_VERSION`, currently `1`.

It pins:

- The host ↔ agent protocol — every op, every result variant, every error shape.
- The observability event stream — every `Event` variant and every payload field.
- The Hello / HelloOk / HelloErr handshake and the `OpenMode` field set.

## When it bumps

`PROTOCOL_VERSION` increments any time:

- A new wire variant is added (op or event).
- A field is added to an existing payload struct.
- A new `OpenMode` flag, `ExecResult` variant, or `OpResult` shape lands.
- The `EventFrame` envelope changes.

It is **not bumped** for changes that don't reach the wire — internal struct renames, host-side refactors, agent-side optimisation, additional Lua bindings that map onto existing wire ops.

## What it does NOT pin

- The Lua API surface (the userdata methods `vm`, `bridge`, etc.). That surface evolves freely; tests that use it are rebuilt against the same Provium version that runs them.
- Internal types like `Vm`, `Lab`, `Bridge`. Those are language-level objects, not wire-level contracts.
- The CLI flag set. New flags can be added without bumping the protocol; removing or renaming a flag is a separate compatibility concern documented in [the CLI reference](/provium/reference/cli.md).

## How the pin is enforced

The conformance suite locks every wire-facing struct against an explicit byte expectation. Examples of what it locks:

- `Hello.protocol_version` is a u32 currently encoded as `1`.
- `HelloOk` field tags (`agent_version`, `guest_os`, `agent_features`, …).
- `OpResult.outcome` is the `"ok"` / `"err"` discriminator key, with `value` for the payload.
- `SyscallResult` field set: `ret`, `errno`, optional `out_bufs` (omitted when empty).
- `OpenMode`'s six flags — `read`, `write`, `create`, `truncate`, `append`, `exclusive` — each serialise as their own named field (it is a struct of booleans, not a packed bitfield).

Any refactor that changes a tag name, reorders an enum, or shifts a bit fails the pin test. The fix is to bump `PROTOCOL_VERSION` and update the pin to match the new shape — explicit, not silent.

## How to add a new op or event

1. Add the new variant / payload type to the protocol crate.
2. Add a serialisation round-trip test alongside it.
3. Bump `PROTOCOL_VERSION`.
4. Update the conformance pin to lock the new shape.
5. (Op only) Wire the agent-side handler.
6. (Op only) Wire the host-side dispatcher and a Lua binding.

Skipping step 4 produces a protocol change without explicit pinning — exactly the regression the pin tests are designed to catch.

## Backwards compatibility

Provium does not commit to backwards compatibility on the host-agent wire. The host and agent are versioned together — they ship in lock-step out of the same workspace. The Hello handshake exchanges versions and `HelloErr`s the connection if they don't match, so a stale agent against a fresh host fails fast at connection time, not silently mid-op.

For the event stream, consumers (`provium-coverage`, dashboards) are versioned against `PROTOCOL_VERSION`. A consumer built against version N reading a stream from version N+1 will fail to deserialise the new fields; the typical recovery is to rebuild the consumer.

## See also

- [Events](/provium/reference/events.md) — what the event stream carries.
- [CLI](/provium/reference/cli.md) — flags that produce events.
