Labs and scope
A Lab is the unit of resource ownership in Provium. The provium global is the root Lab; sub-labs let you carve out scoped subsets. This page covers the patterns for using labs effectively.
The exhaustive method reference is on Lab.
Per-test scope #
Each test() body runs in its own ephemeral sub-Lab. Resources you create inside a test are local to that test; resources declared at file scope are visible (via lookup fallthrough) and persist across tests.
-- File scope: persists across all tests
local shared = provium::
test
-- `local_vm` is shutdown silently here
test
The rules:
| Operation | What happens in a test() body |
|---|---|
provium:vm(name, profile) | Creates in the test scope. Auto-shutdown at test end. |
provium:vm(name) | Looks up name: test scope first, then file root. Errors if not found. |
provium.foo (dot access) | Same lookup as above; returns nil on miss. |
provium:bridge(name, opts?) | Same as VMs: create local, lookup walks up. |
provium:lab(name) | Creates a sub-lab in the test scope (auto-cleaned). |
provium:vm_fixture(name) / provium:lab_fixture(name) | Always materialises at file root, regardless of where called. The fixture cache stays warm across tests. |
Shadow detection. Declaring a name at test scope when it already exists at file scope is an error, not a silent shadow:
local shared = provium::
test
The intent: if you wanted the file-scope VM, use the lookup form (provium:vm("shared")). If you wanted a fresh independent VM, pick a different name.
Federation sub-labs are isolated. A sub-lab created via provium:lab("dc1") has no parent chain — dc1.web doesn't fall through to find a sibling DC's "web". This is intentional; federation models distinct sites.
Things that don't walk parents (each by design):
vm_names/bridge_names/members— return only the local scope's contents.boot/shutdown/pause/resume— batch ops on the local scope's VMs.snapshot/restore— operate on the local scope plus its sub-labs (downward, not upward).claim,barrier— file-scope coordination primitives; test-scope claims/barriers are isolated.
Per-test scope vs reset_between_tests #
These solve different problems and compose well together — they're not alternatives.
| Per-test scope (default, automatic) | reset_between_tests = true (opt-in) | |
|---|---|---|
| What it isolates | New declarations made inside a test() body. | Mutable state of file-scope resources. |
| Mechanism | Test-scope sub-Lab; auto-shutdown at test end. | Snapshot after file setup; restore between tests. |
| Per-test cost | Booting the test-scope VMs you declared. | Snapshot restore (cheaper than full boot, slower than nothing). |
| What it doesn't help with | File-scope VMs accumulating cruft across tests. | Name collisions across tests for ad-hoc VMs. |
The typical heavy test file uses both:
provium. = true
-- File-scope setup: expensive cluster build, snapshot baseline.
local lan = provium:
local web = provium::
local db = provium::
lan:
web:
db:
db: -- baseline schema
test
test
Without per-test scope, the second test couldn't introduce probe without colliding (or having to pick a unique name per test). Without reset_between_tests, the row the first test inserted would still be in the database for every later test.
When to use which:
- Per-test scope alone is enough when each test is self-contained: declare what you need, do work, done. The smoke-test shape —
local vm = provium:vm("v", "peios"):boot(); vm:run(...)repeated per test. - Add
reset_between_testswhen you have a non-trivial setup at file scope (configured cluster, populated DB, attached topology) and tests mutate it. Pays for itself once a per-test setup-cost crosses the snapshot/restore time. - Skip both by declaring
reset_between_tests = falseAND being careful with naming when you actually want state to accumulate across tests (e.g. progressive integration scenarios).
Why sub-labs #
Most simple tests use only the root lab — provium:vm("a", "peios"), provium:bridge("lan"). Sub-labs are useful when:
- You're modelling multi-DC topologies (
provium:lab("dc1"),provium:lab("dc2")). - You want to snapshot or restore a coherent subset of resources.
- You want to use
lab_fixtureto cache an entire topology.
local dc1 = provium:
dc1:
dc1:
dc1::
local dc2 = provium:
dc2:
dc2::
dc1.a is shorthand for dc1:vm("a"). The dot lookup tries vm → bridge → sub-lab in order; missing names return nil (no error).
Anonymous sub-labs #
local sub = provium: -- name auto-generated: __lab_0, __lab_1, ...
Useful for one-shot subsets that don't need a stable name.
Membership: include and remove #
You can move resources between labs:
local v = provium:
local sub = provium:
sub: -- v is now in sub
provium: -- and gone from root
lab:include accepts a single VM, Bridge, or sub-Lab userdata, or an array of those. It errors on duplicate names, reserved names, and shadow conflicts — the exact error cases are in the Lab reference.
lab:remove is graph-state only — the underlying VM, bridge, or sub-lab is NOT shut down. It's removed from the lab's child list. Useful when you want to take ownership of a resource somewhere else.
Listing members #
provium: -- {"v", "v2"}
provium: -- {"lan"}
provium: -- {"dc1", "dc2"}
provium: -- [{kind="vm", name="v"}, {kind="bridge", name="lan"}, …]
members() is the unified accessor; the others are sliced by kind.
Batch lifecycle #
The lifecycle methods on a lab apply to every direct VM child (not recursive):
provium: -- boot every VM in the root lab
provium: -- shutdown every VM
provium:
provium:
Useful when a test sets up the topology declaratively and wants to bring it up atomically:
local lan = provium:
local a = provium:
local b = provium:
lan:
provium: -- boots a and b together
For per-sub-lab control:
local dc1 = provium:
dc1::
dc1::
-- dc1 boots independently of the root lab
Resource claims #
Each test file may make at most one claim against the dispatcher's resource pool:
provium:
test
test
The claim sits across the file's lifetime; it's released at file end. A second :claim errors — lab claim already held; one-shot per lab. The accepted field shapes and the no-pool behaviour are in the Lab reference.
Why claim? The dispatcher won't oversubscribe — it tracks total RAM and CPU budget across files and only schedules a file when its claim plus the per-file overhead fits. A file that needs 4 VMs at 2 GiB each should claim ~10 GiB so it doesn't get scheduled alongside other heavy files and OOM the host.
-- For a 3-VM, 2-CPU-each, 1-GiB-each test:
provium: -- 3 GiB + per-file overhead, 6 + 1 vCPU
Barriers #
lab:barrier(name, count, timeout?) is an N-arrival rendezvous: it blocks the caller until count callers have hit the same name-keyed barrier, then returns true for all of them. On timeout it returns false — it does not raise. The default timeout, count lock-in, and round-reuse semantics are in the Lab reference.
-- count = 1 is satisfied immediately: a labelled checkpoint.
provium:
-- An unmet count times out and returns false rather than raising.
local ok = provium:
if not ok
A count above 1 needs a second concurrent host-side caller. Test files execute on a single thread, and workers run guest processes (they can't call back into the test's Lua), so today a count > 1 barrier in an ordinary test file simply times out. The multi-caller form is aimed at thread-mode workers (vm:spawn_worker({thread = true})), which aren't supported yet.
For inter-VM coordination (e.g. between two guests), use a guest-side primitive (file in a shared mount, fifo, network message). Barriers are host-side only.
Snapshot and restore #
local snap = provium: -- to a tempdir, returns LabSnapshot
local snap = provium: -- to that path, returns the path string
The LabSnapshot userdata's accessors are in the Snapshot reference. Restore with:
provium: -- from LabSnapshot userdata
provium: -- from path
The same precondition that blocks vm:snapshot() blocks lab:snapshot() — open streams cause the snapshot to refuse with the offending stream's creation site.
Lab fixtures #
A lab fixture builds a multi-VM topology once, caches the snapshot, and restores it per test:
-- tests/fixtures/cluster.fixture.lua
local lan = provium:
local a = provium::
local b = provium::
lan:
a:
b:
return provium:
-- tests/uses-cluster.test.lua
test
provium:lab_fixture(path) returns a Lab userdata wrapping a fresh sub-lab with the fixture restored. For single-VM fixtures, use provium:vm_fixture(path) — same cache mechanism, returns a VM directly. What goes into the cache key (and therefore what triggers a rebuild) is covered in fixtures and dependencies.
Common patterns #
Multi-DC topology #
local
local dc1 = dc
local dc2 = dc
provium: -- boot every VM
File-level resource claim #
provium:
test
Cached cluster fixture #
-- fixture builder
return provium: -- after building the topology
-- test usage
local cluster = provium:
local a = cluster. -- look up by name from the restored sub-lab
Reset-between-tests with a sub-lab snapshot #
provium.reset_between_tests = true snapshots the root lab. To reset only a subset:
local sub = provium:
local v = sub::
v:
local snap = sub:
test
test
This pattern is more verbose than provium.reset_between_tests = true but works when only part of the lab needs resetting.
Auto-close ordering #
When a test scope ends (per-test or per-file), the harness's resource graph walker closes resources in reverse-dependency order:
- Streams (Tails, Captures, ConsoleStreams).
- Processes (
vm:run_async,worker:run_async). - Files (
vm:open_file,worker:open_file). - Workers.
- Bridges.
- VMs.
This is implemented as a Lua-side registry that every register_resource call appends to. Resources are closed in priority order, then within a priority by registration order.
You don't usually need to call :close yourself — the walker fires it automatically. Calling explicitly is fine (the methods are idempotent) and useful when the resource's lifetime is bounded by a clear point in the test.
See also #
- Lab reference — every method.
- provium global — file-scope configuration globals.
- VM reference, Bridge reference — what lives in a lab.
- Pool and parallelism — how the dispatcher uses claims.