Worker
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.
A worker is a real, separate guest process: a re-exec'd copy of the agent that the main agent relays ops to over a socket pair. Its syscalls run with its own credentials — its own kernel token, privileges and process security block — so a test can stand up two distinct security principals in one VM: a caller and the target of a process check, an unprivileged caller against a privileged operation, two peers on a socket. Handles a worker allocates live in its own table; the main agent routes them, so from the test's side they look like any other handle.
Constructing #
Workers also run as separate host processes when using the local-agent backend. The provium executable handles the worker protocol itself, so this backend does not need a separate installed guest-agent binary. Rust applications embedding LocalAgentVmm must select a worker-capable executable with LocalAgentVmm::new().with_worker_executable(path) if their own executable does not implement that protocol. The path may name provium or provium-agent.
| 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. Returns a RunResult.
worker:run_async(cmd, opts?) #
Async spawn. Returns a Process. Like vm:run_async, the timeout opt is rejected — pass it to proc:wait instead.
The child is forked and exec'd by the worker, so it starts life with the worker's credentials: whatever token the worker installed, whatever privileges it adjusted, is what the new program runs under. That is what makes exec-time behaviour testable from a principal the test minted — a token surviving or being relabelled at exec, a descriptor without FD_CLOEXEC crossing into the new image, a child's identity after its parent impersonated. proc:pid() gives the child's pid for the main agent to inspect from outside.
proc:wait, proc:kill, proc:pid, proc:status and the stdin methods all work; the main agent relays them to the worker, and the worker performs them — so they run with the worker's credentials at that moment. A worker that is impersonating another user, or has installed a lowered token, may find proc:kill on its own child refused with EPERM; revert or restore before reaping. proc:stdout_stream() and proc:stderr_stream() do not work — a stream owns its connection for its lifetime and the worker channel is a request/reply pipe — so read a worker-spawned child's output from the RunResult that proc:wait returns.
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. Joining the worker first orphans any child still running: it is reparented to PID 1 and the Process handle no longer reaches it.
worker:open_file(path, mode_table) — rejected #
There is no worker-scoped File. The call errors with worker_open_file: not supported for a process-isolated worker, and the reason is structural: a worker is a separate guest process with its own file table, so a descriptor opened there would be out of reach of file:read, file:write and file:close, which act through the parent agent.
Open files from inside the worker with worker:syscall instead, so the descriptor is created, used and closed in the worker's own process under the worker's credentials:
-- x86-64 syscall numbers: openat = 257, write = 1, close = 3.
local AT_FDCWD, O_WRONLY_CREAT_TRUNC = -100, 0x241
local r = w:
local fd = r.
w:
w:
If the file only needs to be read or written by the test, and the worker's identity does not matter, vm:open_file on the VM is the simpler call.
worker:syscall(nr, …) #
Direct syscall. Same call shape as vm:syscall — 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:
test
For coordination between workers' guest processes, use a guest-side primitive (file, fifo, etc.). lab:barrier(name, count, timeout?) is a host-side rendezvous — a worker's guest processes can't call it. See Labs and scope — Barriers for what barriers can and can't synchronise today.