microVM Runner V7 — teardown, crash recovery, observability
Status: PROPOSED — details the V7 milestone under the frozen parent microvm-runner.md (its Plan § V7, microvm-runner.md:583-598; Approach (f) “Teardown and mid-session death”, microvm-runner.md:238-261; Approach (g) “Observability + kill switch”, microvm-runner.md:263-277). Authoritative issue scope: RIG-2498. V7 is designed against V6’s supervision rework (PR #912, in review): the one-reaper- per-child core V7’s death detection rides is #912’s shape, cited below from its workspace; V7 must land after V6 merges.
Ledger impact: none. V7 details failure handling and metrics the parent’s
§(f)/(g) already froze — “the backend supervises its per-session process set
(VMM, virtiofsd, net backend, guest …)” (microvm-runner.md:240-241), “Healthy
VMs found at restart are killed and rebooted on next request, not adopted”
(microvm-runner.md:258-259) — plus a metric-naming translation (§(d)) that is
an implementation-convention fact, not a new cross-cutting decision. There is
no DECISIONS.md under docs/designs/infra/ (V5’s precedent), and
docs/designs/DECISIONS.md is untouched. Two rulings DO land on that frozen
supervised-set sentence rather than merely detailing it — the guest (OQ-10)
and the net backend (OQ-6) both leave V7’s session-fatal handling — and both
are graded load-bearing and carried to the human for an explicit ruling
rather than absorbed here.
Problem / Intent
Section titled “Problem / Intent”The microVM backend can boot, exec, and tear down a session it owns — but only along paths where the Runner stays alive and the VM’s processes die when told to. Three gaps remain, and they are the last V-milestone before the V8 acceptance suite:
-
Nothing survives a Runner crash. Each session’s VMM/virtiofsd/passt are direct children with a best-effort
PR_SET_PDEATHSIGguard — “The real teardown guarantee is Shutdown, not Pdeathsig” (go/internal/runtime/microvm/launch.go:284-289) — and the per-session runtime dir records only passt’s pid:Launchpasses"--pid", vm.pidfilefor passt alone (launch.go:178-196), while the VMM and virtiofsd leave no on-disk identity. The parent requires startup orphan-reaping “by their per-session runtime dir (pidfiles + process- liveness check)” (microvm-runner.md:254-256); with no VMM/virtiofsd pidfiles there is nothing to reap them by. §(a) adds the pidfiles (with a pre-spawn intent record, so a crash mid-spawn cannot leave a live orphan unrecorded), §(b) the reaper — behind a RunRoot lock, because a reaper that cannot tell a live Runner’s sessions from a dead one’s orphans kills the wrong VMs with full confidence. -
Mid-session death is undetected. V6’s supervision core observes every child’s exit through its sole reaper (
c.exitedclosed after the singlec.cmd.Wait(), PR #912launch.go:486-495), but nothing acts on a death after boot: a VMM that dies mid-session leaves in-flightExecs failing with an undifferentiated transport error, live peer daemons, and a session entry that looks startable. The parent’s matrix (microvm-runner.md:244-253) demands a distinguishable error, peer teardown, and an idempotentRemove; virtiofsd death is “fatal to the session (no remount-and-hope)”. A session can have two VM lives (Stopkeeps the entry), so detection is stamped per VM life rather than per session. §(c). -
The backend is invisible. The whole tree carries exactly one OTel metric instrument (
compass.delivery.dispatched,go/internal/delivery/consumer.go:269-272, verified by a tree-wide grep for instrument constructors this session); the Runner and both backends emit none. The parent’s §(g) metric set — boot latency, per-VM RSS, vsock RPC latency, virtiofsd restarts, quota utilization, canary result, plus abackendlabel on session metrics — does not exist. Itscompass_microvm_*names are illustrative Prometheus-style; the codebase convention is dotted OTel names via the global meter. §(d) translates and specifies them concretely.
Non-goals. The transitional kill switch already exists and is not
redesigned: SelectBackend defaults to podman — “an unset backend never
silently switches an operator onto the unfinished path”
(go/internal/runtime/microvm.go:110-116) — which IS the parent’s
runtime.backend: podman safety valve (microvm-runner.md:269-276). The
acceptance suite and the boot/RSS benchmark are V8
(microvm-runner.md:600-621). The Q-budget threshold VALUES stay deferred to
V8 measurement — “Set from V2a/V8 measurements on real hardware, not invented
here. The mechanism (canary + …)” (microvm-runner.md:839-841); V7 ships the
mechanism that produces the numbers and invents no threshold.
Approach
Section titled “Approach”Each subsection resolves one concern the parent’s V7 plan leaves to detailing.
Every fork is also listed in ## Open Questions for the pre-freeze batch, and
the body designs against the recommended option. Line numbers into the V6
supervision core cite PR #912’s branch (the shape V7 lands on); everything
else cites main.
(a) Per-session pidfiles for all three children, with PID-reuse defense
Section titled “(a) Per-session pidfiles for all three children, with PID-reuse defense”Today only passt records a pid on disk, and it records it itself: Launch
passes "--pid", vm.pidfile with vm.pidfile = filepath.Join(dir, "passt.pid")
(go/internal/runtime/microvm/launch.go:178-196), and Shutdown removes it
(launch.go:385-389). The VMM and virtiofsd leave no on-disk identity, so the
parent’s “reaps orphaned VMM/virtiofsd/net-backend processes by their
per-session runtime dir (pidfiles + process-liveness check)”
(microvm-runner.md:254-256) has nothing to work with. §(a) makes the runtime
dir the durable record of the session’s process set.
Layout. Three host-written pidfiles in the existing per-session runtime
dir (<RunRoot>/microvm/<id>/, created 0700 by Create,
go/internal/runtime/microvm_lifecycle.go:225-228), plus one Runner-scoped
lockfile a level up (§(b) step 0):
| File | Process | Writer |
|---|---|---|
<id>/vmm.pid |
cloud-hypervisor | host (launch) |
<id>/virtiofsd.pid |
virtiofsd | host (launch) |
<id>/passt.pid |
passt | host (launch) |
.runner.lock |
(none — the RunRoot owner’s flock) |
host (lockRunRoot, called by run()); created once, NEVER unlinked (§(b) step 0) |
passt’s --pid flag is retired: with -f in the argv “this *exec.Cmd IS the
passt process” (launch.go:182-191), so the host knows passt’s pid exactly as
it knows the other two (cmd.Process.Pid after startChild), and a
host-written file can carry the reuse defense below where passt’s own
bare-pid file cannot. One writer, one format, three files. This diverges from
the parent’s Interfaces sketch
({vmm.pid,virtiofsd.pid,netbackend.pid,vsock.port},
microvm-runner.md:589-590) in two flagged ways (OQ-1): the file keeps the
process’s real name (passt.pid, matching the existing on-disk name) rather
than the role name netbackend.pid, and there is no vsock.port file — the
control-plane port is a fixed per-host constant (“Per-session uniqueness is
carried entirely by the AF_UNIX socket paths under the session runtime dir,
never the port”, microvm_lifecycle.go:48-52), so a per-session port record
would be a copy of a constant.
Format: pid + start time + boot id, because PID reuse is real — across
reboots too. A pidfile holds one line in one of two forms — the SETTLED
record <pid> <starttime> <bootid>, or the pre-spawn INTENT record
intent <bootid> the write lifecycle below explains:
<starttime> is field 22 of /proc/<pid>/stat — the kernel’s process start
time in clock ticks since boot, immutable for the process’s life, so
(pid, starttime) is unique across reuse within a boot for any pid-wrap
slower than one clock tick (USER_HZ, conventionally 100 Hz ⇒ 10 ms): the
same identity pair systemd and every pidfd-less supervisor relies on, a
bound rather than a kernel guarantee — and <bootid> is
the host’s boot UUID, read once per Runner process from
/proc/sys/kernel/random/boot_id. A liveness check compares the boot id
first: a mismatch means the file predates a reboot and nothing it names can
still exist — verdict “gone” before any proc read or signal. Within the same
boot it re-reads /proc/<pid>/stat and compares starttime: mismatch or
ENOENT ⇒ the recorded process is gone and the pid, if live, belongs to an
unrelated process that must NOT be killed. A bare-pid file can make neither
distinction, which is exactly the kill-an-innocent hazard §(b)’s reaper has
to defend against. The boot id is load-bearing, not belt-and-braces:
starttime is ticks SINCE BOOT, so after a reboot a long-uptime host
re-issues low pids and a stale pidfile’s (pid, starttime) can legitimately
MATCH an unrelated process on the new boot — and a §(b) match kills. One
extra token per line buys a structural short-circuit where the alternative
is a plausible wrong kill.
Write lifecycle, and the spawn→write window. launch records each child
in TWO steps, because the pid does not exist until the spawn returns and a
crash can land between the two:
- Before
startChild, write an INTENT record — the single lineintent <bootid>— to that child’s pidfile path. - After
startChildsucceeds, replace it with<pid> <starttime> <bootid>.
Both writes are atomic: the line goes to a temp file in the same runtime dir,
then os.Rename into place — a same-directory rename is atomic, so a reader
sees the complete record or no file, never a torn prefix, and step 2’s rename
supersedes step 1’s record in one operation. A plain os.WriteFile is not
atomic, and the torn-write window is EXACTLY the Runner-crash window
ReapOrphans exists for: a half-written pidfile would demote a recorded live
child to §(b)’s no-pidfile arm — never killed, its dir age-gate-removed, the
orphan leaked with its only record destroyed.
The intent record exists because atomicity alone does NOT close the
spawn→write window. Spawn order is virtiofsd, passt, then the VMM LAST
(startChild(vm.virtiofsd) at launch.go:168, startChild(vm.passt) at
launch.go:197, startChild(vm.vmm) at launch.go:222), so a Runner dying
between startChild(vm.vmm) returning and vmm.pid being renamed into place
would leave a dir holding two perfectly parseable pidfiles naming two
now-dead helpers and NO record at all of a live orphan VMM — whereupon §(b)
step 3 reads “everything recorded is dead”, os.RemoveAlls the dir, and
erases the only evidence of the leak. That is not a torn write and no
atomicity buys it back; it is an ORGANIC under-recording path, and it is the
exact outcome OQ-7 calls a correctness question rather than a latency knob.
PR_SET_PDEATHSIG does not rescue it either — it is explicitly best-effort,
“The real teardown guarantee is Shutdown, not Pdeathsig”
(launch.go:284-289). Step 1 closes the window instead of reasoning around
it: the dir names every child that may be live BEFORE it can be live, so
§(b) treats a leftover same-boot intent record as a possibly-live but
unidentifiable orphan (WARN, counted, dir KEPT — never a kill, since there
is no pid to verify), and a leftover intent record from a PREVIOUS boot as
dead by the same boot-id short-circuit a pidfile gets. At most one intent
record can dangle per dir, because the writes are sequential, and it names
WHICH child — so the WARN is actionable rather than a shrug.
A pidfile write failure (either step) fails the boot — the existing error
path, launch’s deferred vm.Shutdown tearing down what started
(launch.go:146-152): orphan reapability is load-bearing, so a session that
cannot be recorded must not run. Shutdown removes all three alongside the
sockets (extending the existing pidfile removal, launch.go:379-389), and
Remove’s os.RemoveAll(session.runtimeDir)
(microvm_lifecycle.go:674-676) is the backstop — a pidfile structurally
cannot outlive its runtime dir.
Invariant preservation. The two-step write makes the on-disk record CONSERVATIVE by construction: at every instant the set of live children is a subset of {children named by a complete pidfile} ∪ {the child named by a dangling intent record} — and BOTH terms now live on disk, so a crash at any instant leaves a dir that names every process it may have started. (With a write-after-spawn-only scheme the second term existed solely in the crashed process’s memory, which is the defect above.) The record may OVER-name — a child that never spawned, or one that already died — and §(b) is built to tolerate exactly that (starttime mismatch ⇒ no kill; stale boot id ⇒ no kill); it can never UNDER-name a live child, which is the failure §(b) cannot recover from.
The writes themselves are plain file I/O (temp-write-then-rename) around
startChild; they do not touch the child’s reaper, exited channel, or
Wait discipline (one reaper per child installed by startChild, “exactly
one goroutine per child owning exactly one cmd.Wait”, PR #912
launch.go:460-465; the single c.cmd.Wait() at PR #912 launch.go:493
stays the package’s only Wait, verified by grep this session).
(b) ReapOrphans(ctx, held) error: kill-and-remove at startup, never adopt
Section titled “(b) ReapOrphans(ctx, held) error: kill-and-remove at startup, never adopt”The parent is explicit: “on startup the backend reaps orphaned VMM/virtiofsd/net-backend processes by their per-session runtime dir … Healthy VMs found at restart are killed and rebooted on next request, not adopted: the supervisor handshake state is in-process and not reconstructable across a Runner restart” (microvm-runner.md:254-261). V7 honors that verbatim — the reaper has no adoption path, no health probe, no attempt to re-dial a found VM’s vsock socket.
// ReapOrphans REQUIRES the exclusive RunRoot lock to be held already: the// token comes from lockRunRoot, taken once by run()/startup (step 0). A// zero token is an error with NO scan and NO signal — the reaper never// acquires the lock itself, so it structurally cannot scan unlocked and// stays safely re-invocable within one process. Past that it scans// <RunRoot>/microvm/*/ for runtime dirs left by a// previous Runner process, kills every recorded process that is verifiably// the one the pidfile named (boot id + pid + starttime match), and removes// the dir. Healthy VMs are killed, never adopted (microvm-runner.md:258-261).// Idempotent and safe to re-run: a partial reap leaves the dir for the next// attempt.func (m *MicroVMRuntime) ReapOrphans(ctx context.Context, held RunRootLock) errorStep 0 — the RunRoot lock, a DELIVERED mechanism, not an assumption,
owned by the STARTUP unit. An exclusive non-blocking flock on a
lockfile at the RunRoot root is taken ONCE by run()
(go/cmd/compass-runner/main.go:43) through W5’s startup unit, ahead of
the reap hook, and released by a defer that runs at process shutdown —
so it is genuinely held for the Runner’s life. ReapOrphans does NOT
acquire it; it REQUIRES the held-lock token as a parameter and refuses
without one. This is a W2 deliverable in its own right, not a property
inherited from OQ-8’s
recommendation: the reaper’s whole defense against killing a second live
Runner’s sessions is this lock, and a safety property that exists only as
an Open Question’s preference is not implemented. OQ-8 still rules on WHICH
mechanism; the body ships one so that W1-W5 implemented verbatim cannot
produce a reaper with no cross-process exclusion at all.
Why the startup unit and not ReapOrphans. Two reasons, both
structural rather than stylistic. First, a lock the reaper acquires cannot
be held for the Runner’s life: its release func would have to outlive the
call that returned it, and ReapOrphans returning while still holding a
lock it hands to a caller is exactly the shape that makes “who releases
this” unanswerable. Second, flock conflicts across distinct open file
descriptions — including two opens of the same file within ONE process —
so a second ReapOrphans call in one process would refuse ITSELF with
EWOULDBLOCK. Step 1 below designs explicitly for repeat invocation, so
reaper-owned acquisition would freeze a reaper that is designed to be
re-invocable behind a lock guaranteeing it cannot be. Taking the token as
a parameter gives both properties at once: the reaper structurally cannot
scan unlocked, AND it is callable as many times as the process likes.
// lockRunRoot takes LOCK_EX|LOCK_NB on <RunRoot>/microvm/.runner.lock and// returns the held-lock token plus the release func. Refuse-not-wait:// EWOULDBLOCK means another Runner owns this RunRoot; startup then WARNs// and SKIPS the reap hook, so not a single dir is scanned — a scan there// would kill live sessions whose §(a) identity check matches perfectly.// ITS SOLE CALLER IS run()/startup, which acquires once and defers the// release, so the lock is held for the Runner's life and the kernel drops// it on ANY holder death, SIGKILL included: a crashed Runner never wedges// its successor. That release-on-death property is why OQ-8 recommends// flock over a recorded (pid, starttime, bootid) owner file: the lock IS// liveness, with no stale record to reason about.//// The release CLOSES the fd and NEVER unlinks the file. The lockfile is// created once and lives forever, because two Runners flocking two// DIFFERENT inodes of the same path is indistinguishable from no lock at// all — no reap arm, cleanup path, or preflight probe may remove it.func (m *MicroVMRuntime) lockRunRoot() (held RunRootLock, release func(), err error)What a refused lock does to startup is a sub-fork, and it is OQ-5’s, not
OQ-8’s. OQ-8’s option (i) is worded “a second Runner fails the lock and
refuses startup”, while OQ-5 rules that a reap failure is a WARN and never
an abort. With the lock owned by startup, a refused lock surfaces one
step earlier than a ReapOrphans error — at the lockRunRoot hook — so
the arm is stated there: WARN, SKIP the reap hook entirely, continue
serving, having scanned NOTHING (the reaper cannot run without the token).
The reasoning is that the lock’s job is preventing the
wrong reap, which a refused lock has already accomplished; the second
Runner’s own sessions are freshly-minted ids in fresh dirs
(mintSessionID, microvm_lifecycle.go:332-341) and cannot collide with
the predecessor’s, so refusing startup over a still-draining predecessor
would convert a handled race into the box outage OQ-5 rejects. Ruling OQ-8
(i) with refuse-startup semantics instead is coherent — it enforces
one-Runner-per-box outright rather than only protecting the reap — but it
is a STRICTER posture than OQ-5 grants, so it needs saying explicitly
rather than inheriting. Both OQs should be ruled in one pass.
Per-directory procedure, for each <RunRoot>/microvm/<id>/:
-
Skip live sessions. Under
m.mu, a dir whose leaf matches a key inm.sessionsbelongs to a session this process owns and is skipped. At the startup call site the table is empty (NewMicroVMRuntimebuilds it empty,go/internal/runtime/microvm.go:96-103), so this guard is inert there — it exists so a later or concurrent invocation can never race a Runner that is simultaneously creating sessions.Createinserts the session into the table under the samem.muit checks duplicates under (microvm_lifecycle.go:258-274), but it creates the runtime dir BEFORE taking the lock (microvm_lifecycle.go:225-228), so a concurrent scan could see a fresh dir with no table entry and no pidfiles. The no-pidfile arm below therefore does NOT remove such a dir (see 3). This guard is INTRA-process only: it cannot see a SECOND live Runner’s sessions over the same RunRoot, whose processes would pass the §(a) identity check and be killed with full confidence. Step 0’s RunRoot lock — held bystartup, its token required here — is what makes that unreachable, and because the reaper takes the token rather than acquiring it, this repeat-invocation path is genuinely open (a second in-processflockwould refuse itself). OQ-8 (load-bearing) rules on the mechanism, and the body shipslockRunRootas the one it designs against. If the human rules OQ-8 another way, THIS clause changes with it — the reaper never scans without some cross-process exclusion in hand. -
Kill recorded processes, VMM first. For each pidfile present: parse the line. An INTENT record (
intent <bootid>, §(a)) names a child whose spawn may or may not have happened and carries no pid — from the current boot it is handled by step 3’s possibly-live arm (WARN, dir kept, no kill, because there is nothing to verify or signal); from a previous boot it short-circuits dead like any stale record. A settled record parses as<pid> <starttime> <bootid>: a boot id differing from the current boot’s short-circuits the entry as dead — nothing from a previous boot survives a reboot — with no proc read and no signal (the §(a) cross-reboot defense). Same boot: read/proc/<pid>/stat; on starttime match, SIGTERM, poll for disappearance (starttime re-check) up toorphanReapGrace = 5 * time.Second, a reaper-local constant in packageruntimemirroring packagemicrovm’s unexportedreapGrace(“how long Shutdown waits for a SIGTERM’d auxiliary daemon (virtiofsd/passt) to exit before escalating to SIGKILL” /const reapGrace = 5 * time.Second,launch.go:45-47). The microvm constant is NOT referenceable from here —launch.go:3ispackage microvmwhilemicrovm_lifecycle.go:3ispackage runtime— so the reaper names its own, deliberately equal in value and declared besideorphanDirGrace(see 3) so one place answers the package question for both graces. Then SIGKILL and poll again;ctxbounds the whole SIGTERM → grace → SIGKILL escalation (checked between polls), and a cancellation leaves the dir in place behind a named per-dir error. Signal errors are differentiated, not lumped: ESRCH means the process died between the probe and the signal — benign, counted as confirmed-gone; EPERM means the pid now belongs to a process this Runner may not signal, which is necessarily NOT the recorded child (the rootless Runner’s children run as its own uid and are always signalable) — a named per-dir error, the dir kept, and NEVER a blind SIGKILL retry. The reaper is NOT the parent of these processes — a restarted Runner cannotWaitthem — so “gone” is observed by the proc probe, and the one-reaper/single-Waitinvariant is untouched (these are notchild-managed processes; no*exec.Cmdexists for them). A starttime mismatch or ENOENT means the process already died (or the pid was reused): no kill, the stale file is simply part of the dir removal. VMM before daemons mirrorsShutdown’s order (“The VMM is killed first (a VM gets no graceful drain)”, PR #912launch.go:557-569). -
Remove the dir only when everything recorded is dead, and NOT when anything recorded is merely unidentifiable. All recorded processes confirmed gone AND no same-boot intent record present ⇒
os.RemoveAllthe dir (sockets, logs, pidfiles — everything; the session volume is untouched, it lives outside RunRoot on the durable volume, microvm-runner.md:251-253). A process that would not die within the bounded escalation leaves the dir in place and contributes a named error to the joined return — the next startup retries; a partial reap is re-runnable by construction because every step is idempotent.Three arms keep a dir that step 3 must NOT remove:
- A same-boot intent record (§(a)) means
launchcrashed between writing the intent and completing the spawn-and-settle: the named child may be running right now with no pid on disk. The dir is KEPT with a WARN naming which child, and theorphans.reapedsibling counter gets apossibly_liveseries so the rot is visible rather than silent. Removal here is the one outcome §(a)’s two-step write exists to prevent, so no age gate promotes it to removable — an operator (or a reboot, which retires it via the boot id) resolves it. - Unparseable pidfile content — treated as no-pidfile for that entry and logged at WARN with the content: nothing identifiable to kill. §(a)’s atomic temp-write-then-rename means a torn write cannot produce this state, so it is reachable only through external tampering. That atomicity is what makes REMOVING such a dir defensible; it is NOT what makes the intent arm above unnecessary, because the spawn→write window is an organic under-recording path atomicity cannot touch (§(a)) — the two are different failures and OQ-7 rules on them separately.
- A dir with NO pidfiles at all, younger than a small grace
(mtime ≤
orphanDirGrace = time.Minute), which may be a concurrentCreate’s pre-insert window (see 1). Older than the grace it is removed, per OQ-7’s recommendation. LikeorphanReapGrace(see 2) this is a reaper-local constant in packageruntime, declared beside it.
- A same-boot intent record (§(a)) means
Call site. run() (go/cmd/compass-runner/main.go:43), through W5’s
startup unit, takes the RunRoot lock (step 0) and then runs the reap
once, after the backend-gated preflight, via two unexported single-method
probe interfaces in
package main — the V3/V4-ratified discipline the existing three probes
follow (microVMPreflighter/podmanPreflighter/canaryBooter,
go/cmd/compass-runner/main.go:185-203):
type runRootLocker interface { lockRunRoot() (runtime.RunRootLock, func(), error)}type orphanReaper interface { ReapOrphans(ctx context.Context, held runtime.RunRootLock) error}Two probes rather than one widened interface, per that same discipline,
and in that order: the lock is acquired first and its release goes into
the shutdown closure run() defers, so it outlives the reap and covers
the process’s life. An engine implementing neither (podman) skips both.
A refused lock is a startup WARNING that SKIPS the reap entirely (OQ-5,
OQ-8’s sub-fork): nothing is scanned, which is the outcome the lock exists
to produce. A reap failure past the lock is likewise a WARNING, not an
abort (OQ-5): the un-reaped
processes hold stale resources but cannot corrupt new sessions — every new
session gets a fresh random id and dir (mintSessionID,
microvm_lifecycle.go:332-341), so nothing collides; refusing startup would
turn one wedged orphan into a fleet outage. The error is logged with the
per-dir detail and the reap re-runs at next startup.
(c) Mid-session death: detect via the reaper channels, fail distinguishably, tear down peers
Section titled “(c) Mid-session death: detect via the reaper channels, fail distinguishably, tear down peers”The parent’s matrix (microvm-runner.md:244-253): VMM death ⇒ mark the handle
dead, fail in-flight Execs with a distinguishable error, tear down the peer
daemons, release vsock/mount state, keep Remove idempotent; virtiofsd death
⇒ “treated as fatal to the session (no remount-and-hope): kill the VMM, same
teardown path”.
Detection: a per-session monitor over the existing reaper channels. V6’s
supervision core already publishes every child’s exit: “exited is closed by
this child’s SOLE reaper … Running and PSS all observe the exit THROUGH this
channel instead of calling Wait themselves” (PR #912 launch.go:83-94).
Nothing new needs to probe processes; the microvm package grows one method:
// DeathCause names which child died, for the session-level monitor.type DeathCause string
const ( DeathVMM DeathCause = "vmm" DeathVirtiofsd DeathCause = "virtiofsd" // DeathPasst is REPORTED, never fatal (§(c)): the monitor logs and // counts it and keeps watching. It exists so the net backend does not // leave the parent's supervised set unobserved (microvm-runner.md:240-241). DeathPasst DeathCause = "passt")
// DeathWatch returns a channel that receives one DeathCause per child death// among the VMM, virtiofsd, and passt, and closes once every watched child// that can still die has died — and that CLOSE is the consumer's// termination signal: the monitor ranges over this channel, so it exits// when the channel does (see the monitor below). SINGLE-CONSUMER: each// send is drained by exactly one receiver, the session monitor below; a// second receiver would steal events and then observe only the close,// reading the zero value DeathCause(""). At most three sends occur, so// the channel is buffered to three and the internal goroutine never// blocks on a monitor that has stopped receiving after a fatal cause.// The method cannot be unexported instead — the monitor lives in package// runtime and reaches it through the guestVM seam// (microvm_lifecycle.go:99-113) — so the contract is this// comment plus the hermetic test, not visibility. It observes the children// through their sole reapers' exited channels — it never calls Wait — so// the one-reaper-per-child invariant holds.func (vm *VM) DeathWatch() <-chan DeathCauseInternally one goroutine per VM selects over vm.vmm.exited,
vm.virtiofsd.exited, and vm.passt.exited (nil-guarded like every other
consumer of those channels), sending each exit as it is observed and
returning when all non-nil children have been reported. It always
terminates: any teardown path kills the VMM and reaps the daemons, closing
all three channels.
The session monitor, stamped with its VM’s epoch. Start, in the same
locked critical section that transfers ownership to the session table
(session.vm = vm … booted = false, microvm_lifecycle.go:401-415),
increments the session’s epoch, captures that value, and spawns one
monitor goroutine carrying it. The monitor RANGES over the channel — for cause := range vm.DeathWatch() — so the channel’s CLOSE is its
termination, on every path: every watched child is dead, so there is
nothing left to observe. That is the only exit on the non-fatal passt
path, and a for { cause := <-ch } loop would instead spin forever on
the zero value DeathCause("") a closed channel yields, one goroutine
per session that ever saw a passt death.
For each received cause, under m.mu, two gates apply before acting: the
death must belong to the CURRENT VM life (session.epoch == myEpoch) and
must not be a deliberate teardown (!session.tearingDown). A stale-epoch
event is discarded and the monitor returns; a deliberate teardown’s exit
is discarded silently and the loop continues to the close.
Past those gates the cause decides:
DeathPasst— log INFO, countpeer.deaths{process=passt}, and CONTINUE receiving. Not fatal, but never unobserved (§(c) deviation note below).DeathVMM/DeathVirtiofsd— recorddeadCauseANDdeadEpoch = myEpochon the session and run the one teardown path that already exists,vm.Shutdown(“the VMM is killed first … then virtiofsd and passt are reaped … and finally the AF_UNIX sockets and passt’s pidfile are removed”,launch.go:352-357) — which is exactly “kill the VMM, same teardown path” for the virtiofsd arm, kills the peers for the VMM arm, and removes the socket/pidfile state. Then the monitor returns: the teardown’s own cascade of child exits is not a second death to report.
The vsock “port” needs no separate release: the port is a fixed constant
and per-session identity rides the AF_UNIX socket paths Shutdown removes
(microvm_lifecycle.go:48-52).
State on microvmSession (all under m.mu, the existing discipline —
“All fields are read/written under MicroVMRuntime.mu”,
microvm_lifecycle.go:141-144):
// deadCause names which child's mid-session death tore the session down —// non-empty only alongside deadEpoch. THE REFUSE CONDITION, stated once// and referenced everywhere else in this record://// session.deadCause != "" && session.deadEpoch == session.epoch//// Exec and ExecStreaming (through startedExec) are the only readers, and// they refuse with a *SessionDeadError. Stop, Remove and Start never// refuse: teardown must always be accepted, and Start's whole job is to// begin the NEXT life.deadCause microvm.DeathCause// deadEpoch is the epoch deadCause belongs to. It is what scopes the READ// side per VM life, exactly as epoch already scopes the monitor's write// side: without it a death in life #1 would refuse execs forever in life// #2, since Stop is a no-op success that leaves deadCause set and Start// only bumps the epoch.deadEpoch uint64// epoch identifies the session's CURRENT VM life. Start increments it in the// same locked critical section that stores the new VM// (microvm_lifecycle.go:401-415) and hands the new value to that VM's// monitor; a monitor whose captured epoch no longer equals this field is// watching a VM that has already been replaced, and DISCARDS its death// event. Monotonic, never reset, and only ever read/written under mu.epoch uint64// tearingDown is set (under mu) by Stop and Remove before they call// vm.Shutdown, so the CURRENT epoch's monitor can tell a deliberate// teardown's exit from a crash. Start clears it alongside the epoch bump.tearingDown boolWhy the read side needs its own stamp, and why not “Start clears
deadCause”. Clearing on Start would work only if every path into a
second life went through it with the session lock held and nothing else
could observe the field in between — but the record’s own teardown shape
makes clearing the strictly weaker choice: Stop on a dead session is a
no-op success that returns BEFORE any Start, and Remove is
specified to OBSERVE and report deadCause after the fact. A clear-on-
Start design therefore has to promise both “the cause survives for
Remove” and “the cause is gone by the next Exec”, which are the same
field in two states. The deadEpoch stamp needs no such promise: the
cause stays readable for Remove forever, and the comparison against
the CURRENT epoch is what makes it inert for life #2. It also matches
the write side one-for-one — the same uint64 under the same lock,
gating reads exactly as epoch gates writes — so there is one
generation concept in this design, not two mechanisms with a seam
between them.
Why an epoch and not the boolean alone. A boolean describes the SESSION
while a death describes a specific VM, and one session can have two VM lives
— Stop keeps the session entry (microvm_lifecycle.go:607-634), so
start-after-stop is a lifecycle the podman parity allows. That opens a race
the flag cannot close. Stop sets tearingDown, then vm.Shutdown kills
VMM#1 and waits on its exit itself (“VMM first: kill outright, then let the
sole reaper’s single Wait complete via vmmExited” — Process.Kill() then
<-vm.vmmExited, launch.go:361-369; the VMM’s exited is therefore
ALREADY closed when Stop returns), so Stop can return before VM#1’s
DeathWatch goroutine is ever scheduled. If Start then runs — clearing
the flag and storing VM#2 — monitor#1 finally wakes, reads
tearingDown == false, concludes “crash”, and marks a healthy just-booted
session dead; every subsequent Exec refuses with *SessionDeadError for a
VM that is fine. Nothing schedules DeathWatch’s selector before Stop’s
caller resumes, so this is ordinary Go scheduling, not an exotic
interleaving.
The epoch makes the stale monitor STRUCTURALLY inert rather than
timing-dependent: monitor#1 holds epoch 1, the session now reads epoch 2,
the event is discarded whatever tearingDown says. tearingDown keeps a
narrower job — suppressing the CURRENT epoch’s expected exit during a
deliberate Stop/Remove — where a boolean is exactly right, because
within one epoch there is only one VM to describe.
Both fields stay on the session rather than on per-VM state: every
microvmSession field already lives under m.mu (the discipline quoted
above, microvm_lifecycle.go:141-144), so the epoch bump and the flag reset
are two lines inside a critical section the ownership transfer already
takes. Putting the generation on the VM instead would open a second
synchronization domain behind the guestVM seam AND would not help: the
monitor must compare its stamp against the SESSION’s current value to learn
that it has been superseded, which is a session-level fact by definition.
The distinguishable error, mirroring the CommandError/TimeoutError
shape (exported error structs with contextual fields in package runtime,
go/internal/runtime/podman.go:318-341; Exec already maps the microvm
package’s timeout onto *runtime.TimeoutError at this exact seam,
microvm_lifecycle.go:486-494):
// SessionDeadError is an operation refused (or an in-flight exec failed)// because the session's VM died mid-session — the VMM exited or virtiofsd// died (fatal, no remount-and-hope; microvm-runner.md:249-253).type SessionDeadError struct { ID ContainerID Cause string // "vmm" | "virtiofsd"}
func (e *SessionDeadError) Error() stringTwo paths produce it: (i) Exec/ExecStreaming entered after the refuse
condition (above) tests TRUE under the lock (extending startedExec,
microvm_lifecycle.go:742-756) and refuse immediately; (ii) an exec already
in flight when the VM dies fails with a transport error from the broken
vsock stream — Exec’s error arm (the non-timeout branch,
microvm_lifecycle.go:486-495) re-tests the same condition under the lock
and, when the monitor has not yet taken it (the death may be microseconds
old), probes the VMM directly via the seam’s VMMExited() — a nil-safe
export of the VMM child’s hasExited(), a non-blocking read of the sole
reaper’s channel, “NOT a signal-0 probe” (PR #912 launch.go:96-107) —
and wraps the transport error in *SessionDeadError on either signal, so
the caller sees the cause, not connection noise (deadCause when
recorded; "vmm" from the direct probe, attributable because the VMM
serves the vsock transport). The residual window — virtiofsd freshly
dead, the VMM not yet killed by the monitor, the in-flight exec failing
anyway — stays best-effort by design:
that one caller sees the raw transport error, and every subsequent call
refuses with the fully-attributed error via (i). errors.As reaches
*SessionDeadError through the wrap chain either way.
Stop and Remove are NOT in the refuse list — a dead session must
still be tearable-down. Only the exec verbs refuse. The Runner’s sole
teardown path is Stop-then-Remove with an early return on Stop’s error:
func (r *AgentRuntime) Teardown(ctx context.Context, handle *AgentHandle) error { if err := r.runtime.Stop(ctx, handle.id, stopTimeout); err != nil { return atStage("stop", err) } if err := r.runtime.Remove(ctx, handle.id); err != nil { return atStage("remove", err) } // …}(go/internal/runtime/agent.go:216-222, and agentHost.Remove tears a
session down “through the AgentRuntime (stop + remove + deregister)”,
host.go:537-546.) A Stop that refused a dead session would return at
stage “stop”, Remove would never be reached, and the session-table entry
and its runtime dir would leak PERMANENTLY — the one state the whole
reap-and-teardown design exists to prevent, reachable through the ordinary
API. It would also contradict three things at once: the frozen parent’s
“Remove is idempotent on an already-dead VM” (microvm-runner.md:248), the
“Idempotent Remove, unchanged” paragraph above, whose premise is that a dead
session removes cleanly, and today’s deliberately tolerant Stop, which
already answers success for a session with no VM to stop (“A session that
never started (no VM handle) is a no-op success” / if vm == nil { return nil }, microvm_lifecycle.go:600-617).
So Stop on a dead session is a NO-OP SUCCESS, the same arm as
never-started: the monitor has already run vm.Shutdown, the VMM is gone,
the peers are reaped and the sockets removed, so there is nothing left for
Stop to do and nothing to report as a failure. deadCause/deadEpoch
stay set for Remove to observe and report, exactly as before — and a
later Start bumps epoch past deadEpoch, which is what makes the
stale cause inert for the second life without ever clearing it (the
refuse condition, above). Teardown is the one operation a dead session
must never refuse.
Idempotent Remove, unchanged. Remove already tolerates a dead VM:
vm.Shutdown is sync.Once-guarded (“safe to call twice”,
microvm_lifecycle.go:652-656), the monitor’s teardown ran it first, and
Remove’s own call returns the recorded shutdownErr and proceeds to
os.RemoveAll + table delete. A dead session’s entry stays in the table
(with deadCause set) until Remove — deliberately, so the Runner’s Remove
path and Exists behave identically to a live session’s, and the death is
reportable rather than silently vanished.
Deviation surfaced: the net backend leaves V7’s supervised set for FATALITY, not for observation. The parent’s §(f) preamble supervises the process set including the “net backend” and “the guest via the supervisor channel’s liveness” (microvm-runner.md:240-241). V7 diverges on BOTH members of that one sentence, and both are surfaced:
- passt (net backend). A passt death is not session-fatal — the vsock
control plane is served by the VMM and stays up, so
Stop/Removekeep working while the guest loses egress. But non-fatal must not mean UNOBSERVED: with no host-side detection an operator gets no signal at all that a session lost egress, which would remove passt from the supervised set outright rather than re-scoping its failure handling. SoDeathWatchgains aDeathPasstcause that is REPORTED and NEVER fatal: the monitor records an INFOmicrovm session peer diedline and acompass.microvm.peer.deaths{process=passt}counter, then keeps watching — it does not setdeadCause, does not callvm.Shutdown, and does not make the session refuse anything.DeathWatchtherefore no longer closes after one send; see its contract above. The fatality ruling is OQ-6 (load-bearing: it rules on frozen text), and the body designs against its recommendation, matrix-literal non-fatality WITH observation. - The guest.
DeathWatchobserves host children only, so a guest that kernel-panics and hangs under a live VMM is a zombie session nothing detects except per-exec timeouts on sessions actively in use. That is a deliberate V7 re-scope carried as OQ-10 (load-bearing), not a silent drop; the body assumes its recommendation — per-exec timeout coverage suffices for V7, active guest health-probing lands with V8’s acceptance evidence.
(d) Observability: concrete dotted OTel metrics, warn-and-disable, no per-session labels
Section titled “(d) Observability: concrete dotted OTel metrics, warn-and-disable, no per-session labels”The convention is OTel, not Prometheus. The parent’s
compass_microvm_boot_seconds-style names (microvm-runner.md:592-594) are
illustrative. The tree’s one existing instrument fixes the real pattern
(go/internal/delivery/consumer.go): a package-level scope const
(“const instrumentationScope = "github.com/RigelBuild/compass/go/internal/delivery"”, consumer.go:165),
creation once at construction from the global meter
(otel.Meter(instrumentationScope).Int64Counter("compass.delivery.dispatched", metric.WithDescription(…)), consumer.go:269-272), and the failure posture:
“On error, leave it nil and log — a metric miss must never fail consumer
construction or block a delivery” (consumer.go:266-276). The cardinality
rule is equally explicit: “NEVER labelled per-session/channel/message — that
is a cardinality hazard” (consumer.go:249-254; enforced by test,
trace_test.go:204-208). V7 adopts all three verbatim: a microvmMetrics
struct built in NewMicroVMRuntime from the global meter under
const microvmInstrumentationScope = "github.com/RigelBuild/compass/go/internal/runtime", nil instruments on
creation error (warn + disable, construction never fails), every record
nil-guarded and off the hot path’s critical section.
The metric set (translating parent §(g), microvm-runner.md:265-268):
| OTel name | Instrument | Unit | Attributes | Meaning |
|---|---|---|---|---|
compass.microvm.boot.duration |
Float64Histogram | s |
outcome = ok|error |
Wall time of Start (Launch → Health-OK → nonce → Provision), the “VMM start → supervisor handshake” latency; same basis as CanaryReport.BootLatency (microvm_preflight.go:351-355) |
compass.microvm.teardowns |
Int64Counter | {teardown} |
cause = remove|stop|vmm_death|virtiofsd_death |
Session teardowns by cause; the virtiofsd_death series IS the parent’s “virtiofsd restarts” number (OQ-4: under §(f)’s fatal-no-restart posture a virtiofsd death is a session teardown, never a restart). A reaped orphan is NOT a cause here — it is a previous Runner process’s leftover, not a session this process ever had, and compass.microvm.orphans.reaped already carries that number with richer attributes; conflating them would corrupt the one series an operator uses to count session deaths |
compass.microvm.vsock.rpc.duration |
Float64Histogram | s |
rpc = exec|health|provision|signal, outcome = ok|error |
Host-side wall time of guest control-plane RPCs, recorded in Exec/awaitHealthy/Start’s Provision/stopGuest (microvm_lifecycle.go:476-502,426-458,393-399,641-650) |
compass.microvm.guest.memory.pss |
Int64ObservableGauge | By |
process = vmm|virtiofsd|passt |
Sum over live sessions of per-process PSS via the existing VM.PSS() (“PSS, NOT summed VmHWM … PSS divides shared pages among their mappers”, PR #912 launch.go:663-668), summed per process kind so no per-session label exists; kB→bytes at record |
compass.microvm.quota.used.ratio |
Float64ObservableGauge | 1 |
none | Observed byte utilization of the session-volume quota, read from m.lastQuota (the preflight-time snapshot; see below) and recorded ONLY when that reading’s LimitBytes > 0 — the condition under which UsedRatio() is meaningful, which is NOT Active() (see below); V6’s single-meaning discipline (“the caller must gate on Active() … V7 inherits a single-meaning number”, PR #912 microvm_quota.go:138-145) applied to the number actually emitted; silent (no point) for a zero-value snapshot and for an inode-only active quota |
compass.microvm.canary.runs |
Int64Counter | {run} |
outcome = ok|error |
Boot-canary executions by outcome — the parent’s compass_microvm_canary_ok as a countable series |
compass.microvm.orphans.reaped |
Int64Counter | {process} |
process = vmm|virtiofsd|passt, outcome = killed|possibly_live |
Orphan processes killed by ReapOrphans; the possibly_live series counts §(b) step 3’s dangling-intent arm, where a child may be running with no pid on disk |
compass.microvm.peer.deaths |
Int64Counter | {death} |
process = passt |
Non-fatal peer-daemon deaths observed by the session monitor (§(c)): the net backend’s death is reported, never a teardown |
The PSS gauge is fleet-summed per process kind while the parent’s frozen
line reads “per-VM RSS” (microvm-runner.md:266) — whether that sentence
means the aggregate or a per-VM series is OQ-9 (load-bearing, a ruling on
frozen text). The body assumes OQ-9’s recommendation: the aggregate gauge
stays, per-session PSS rides a low-frequency INFO log line (logs are exempt
from the cardinality rule, below), and V8’s benchmark sources per-VM
numbers from the harness calling PSS() directly, never from a metric.
That log line is a NAMED W4 deliverable, not an assumption — the
microvm session memory sampler below — because it is the ONLY
runaway-VM identification signal V7 ships: the aggregate gauge cannot
name a session and V8’s benchmark path is explicitly no-metric. Under an
OQ-9 (i)-only ruling the sampler is dropped and V7 then ships NO
runaway-identification signal at all; that is the visible consequence of
that ruling, not a silent gap.
Every attribute value is drawn from a closed enumeration named in this table;
no session id, container name, or path ever becomes a label. The observable
gauges register one callback at construction that snapshots the live
session/VM list under m.mu, RELEASES the lock, and only then calls
PSS() on each handle: PSS reads /proc/<pid>/smaps_rollup for up to
three processes per session (PR #912 launch.go:669-676), a kernel-side
VMA walk costing milliseconds on a large-memory process, and holding m.mu
across N×3 of those walks would block Create/Start/Exec on every
collection — the lock is held for a slice copy only, so collection stays
off the session hot path in fact, not just in intent. PSS is already
best-effort (a failed read is “expected and leave[s] no entry rather than
failing”, PR #912 launch.go:678-683), so a session torn down between
snapshot and read contributes nothing and harms nothing.
The quota gauge reads a NAMED snapshot, not a live syscall. V6 leaves no
quota state behind to read: the preflight binds the reading as a local
consumed only by its own slog.Info and drops it when verifyQuota returns
(reading, err := verifyVolumeQuota(...) at PR #912
microvm_preflight.go:174, logged at PR #912
microvm_preflight.go:189-194), and the runtime struct carries no quota
field at all (config, mu, sessions, launchFunc, newGuestClient —
PR #912 microvm.go:96-111). An observable gauge fires on every collection
interval, so leaving the source unnamed would force the implementer to
invent one. V7 names it:
- The field.
lastQuota QuotaReadingonMicroVMRuntime, written underm.muby the preflight’sverifyQuotaat the point it already has the reading in hand, read underm.muby the gauge callback. - The staleness posture: preflight-time snapshot, NEVER refreshed. The
callback does no
statfs— so it has no syscall cost, no error posture, and noActive()-transition semantics to specify, because the reading cannot transition. The number therefore answers “what was the tenant’s utilization when this Runner started”, which is what a V7 mechanism slice can honestly deliver; a live-tracking gauge needs a refresh policy and a staleness bound that V8’s measurement work is the place to set. - A meaningless RATIO ⇒ no point emitted, and the gate is
LimitBytes > 0— NOTActive().UsedRatioshort-circuits to a hard0whenLimitBytes <= 0(“Zero when no limit was observed” /if r.LimitBytes <= 0 { return 0 }, PR #912microvm_quota.go:134-153), soLimitBytes > 0is exactly the condition under which the number means anything.Active()is a DIFFERENT question — is there a bound at all — and answers it over TWO INDEPENDENT arms: a byte arm requiringLimitBytes > 0 && FilesystemBytes > 0 && LimitBytes < FilesystemBytes, and, reached when that arm is false, an inode arm returningLimitInodes < FilesystemInodes - FilesystemInodes/inodeMarginDivisor(PR #912microvm_quota.go:117-125; V6 keeps the inode arm deliberately independent — “an inode-only project quota … is a real bound, and gating would read it as absent”, PR #912microvm_quota.go:110-112). So an inode-only projected quota is ACTIVE withLimitBytes == 0, and anActive()-gated gauge would emitquota.used.ratio = 0there — indistinguishable from an empty volume, and precisely the silently-wrong denominator V6’s DUAL MEANING comment exists to prevent. Under theLimitBytes > 0gate an inode-only active quota emits NO byte-ratio point; an inode-utilization series, if ever wanted, is its own row with its own denominator, not this one. A zero-valuelastQuota(the preflight skipped, or no volume root configured) fails this gate too, so the same condition still covers both cases with no extra flag.
A monotonic gauge is a real limitation, stated rather than hidden: if V8 wants live utilization it adds the refresh, and this row’s meaning changes with an explicit ruling instead of an implementer’s guess.
The backend label on “every existing session metric” is vacuous today —
flagged, not silently satisfied (OQ-2). A tree-wide search for OTel
instrument constructors this session found exactly one,
compass.delivery.dispatched in the server-side delivery consumer
(consumer.go:269-272) — a message-fan-out metric, not a session metric; the
Runner and both backends define none (the only other counters are two
server-side expvar webhook-drop counts, go/internal/ingest/ board_webhook.go:33, notify_webhook.go:26). There is no existing metric to
retrofit. V7 therefore delivers the obligation’s intent by minting the first
session-lifecycle metric WITH the label from birth, in the backend-agnostic
Runner host (agentHost.Start, go/internal/runner/host.go:306):
| OTel name | Instrument | Unit | Attributes | Meaning |
|---|---|---|---|---|
compass.runner.session.starts |
Int64Counter | {session} |
backend = podman|microvm, outcome = ok|error |
Session starts through the Runner host, by backend |
The label is threaded without cardinality risk because it has exactly two
values, resolved once: the host asks its engine which backend it is via an
unexported single-method probe (interface{ BackendName() string }, the same
discipline as main.go’s probes) at construction, never per-call.
INFO transition logs, plus the one PERIODIC line. Boot and teardown
transitions log at INFO with session id and timings (parent,
microvm-runner.md:268): Start logs
microvm session booted (session_id, boot_duration); the monitor logs
microvm session died (session_id, cause, uptime) on a fatal cause
and microvm session peer died (session_id, process, uptime) on the
non-fatal passt arm (§(c)); Stop/Remove log microvm session stopped/removed (session_id, teardown_duration); ReapOrphans logs
one line per reaped dir (session_id, reaped process list) plus a WARN
per dangling-intent dir naming the child (§(b) step 3). Session id in LOGS
is fine — the cardinality rule constrains metric labels, not log fields
(the delivery consumer meters without ids, dispatch.go:379-383, while
carrying them in the adjacent WARN, dispatch.go:392-393).
One line is NOT a transition, and it is the OQ-9(ii) deliverable: a
periodic microvm session memory (session_id, vmm_pss_kb,
virtiofsd_pss_kb, passt_pss_kb) emitted per live session on a
pssSampleInterval = 60 * time.Second tick from one goroutine started in
NewMicroVMRuntime. It is the per-session counterpart of the aggregate
PSS gauge, and it is what makes a runaway VM identifiable at all
(§(d)’s gauge is fleet-summed by construction). It reads the same
VM.PSS() — a map[string]int64 keyed by child name, in kB (PR #912
launch.go:663-669) — under the same discipline as the gauge callback:
snapshot the session/VM pairs under m.mu, RELEASE the lock, then walk
smaps_rollup outside it, so the sampler adds no m.mu hold beyond a
slice copy per minute. Best-effort like the gauge: a missing key is a
dropped field, never an error, and a session torn down between snapshot
and read contributes nothing. The interval is a named constant rather
than a knob — V7 ships the mechanism, and V8’s measurement work is where
a sampling rate would earn tuning.
Meter-setup ordering (OQ-3). main.go today installs the meter provider
AFTER the whole engine-and-preflight block: engine, err := backends.selectEngine() is at main.go:106, verifyBackendPreflight
(which runs BootCanary) at main.go:110-112, and setupOtel only at
main.go:167-172 — so a canary metric recorded at preflight time would hit
the default no-op global and vanish.
One posture: setupOtel moves ahead of selectEngine(), and instrument
creation stays at construction time. The target is selectEngine, not
merely the preflight, because that is where the instruments are born:
selectEngine returns runtime.SelectBackend(...) (main.go:332-345)
whose "microvm" case returns NewMicroVMRuntime(cfg.MicroVM)
(microvm.go:117-122), and W4 builds microvmMetrics inside
NewMicroVMRuntime. Moving setupOtel only ahead of
verifyBackendPreflight would leave it BELOW main.go:106 — the preflight
dispatches on the engine, so the engine must already exist — and instrument
construction would still run against the default global. Ahead of
selectEngine there is no such conflict: setupOtel is env-only, displaces
no operator-input check, and its disabled path installs nothing and costs
nothing (go/internal/otel/provider.go noop path).
There is no lazy-on-first-record path. An earlier draft of this section prescribed one and then also claimed construction-time creation; only the latter survives, and it is what W4 implements.
Worth recording so the reorder is not over-sold: instrument CREATION would
survive a late provider install anyway. The global meter’s setDelegate
re-delegates both instruments and registered callbacks when a real provider
arrives (for _, inst := range m.instruments { inst.setDelegate(meter) }
and the same loop over m.registry,
go.opentelemetry.io/otel@v1.46.0/internal/global/meter.go:126-147; v1.46.0
is the pinned version, go/go.mod:35). What is genuinely lost is
measurements RECORDED before the install — the canary’s own points, which
is exactly the case OQ-3 exists for. The reorder is about records, not
about instrument validity.
Alternatives considered
Section titled “Alternatives considered”- Adopt healthy orphan VMs at restart ((b)). Foreclosed by the parent:
“the supervisor handshake state is in-process and not reconstructable
across a Runner restart” (microvm-runner.md:258-261) — the exec-gate nonce
binding, the
guestVMseam handle, and the reaper channels all live in the dead process. Rejected without an OQ; the parent froze it. - Keep passt’s
--pidself-written pidfile and host-write only the other two ((a)). Two formats in one dir, and passt’s bare-pid file cannot carry the starttime the reuse defense needs — the reaper would have a kill-an-innocent hole for exactly one of three processes. Rejected. pidfd_openinstead of pid+starttime ((b)). A pidfd pins identity only for the holding process’s life; a restarted Runner cannot mint a pidfd that refers to the process as recorded before the crash. The (pid, starttime) pair is the durable equivalent of a pidfd across process death. Rejected.- A per-session cgroup or process group as the identity token ((a)/(b)).
Strictly stronger where available:
cgroup.procsenumerates CURRENT members exactly, immune to pid reuse entirely, and would sweep grandchildren the three-pidfile model cannot see (a process a daemon forked); asetpgidgroup similarly enableskill(-pgid). Rejected on the dependency and the need: a rootless Runner owns no writable cgroupfs subtree without systemd delegation (systemd-run --user --scopeor aDelegate=unit), coupling session lifecycle to a host service manager the design otherwise never touches; a bare process group carries the same pid-reuse hazard as a pid (a pgid IS a pid) with no durable identity; and the processes to reap ARE the three directly-spawned leaves — none forks further, so grandchild coverage buys nothing today. Revisit if a supervised child ever gains children of its own. - Write-after-spawn only, relying on the atomic rename ((a)). The
original shape: one write per child, after
startChildreturns. The rename makes each write torn-proof but leaves the spawn→write window open — a crash there leaves a live VMM with NO on-disk record, and §(b) then removes the dir as fully-dead (§(a)). The pre-spawn intent record costs one extra rename per child on the boot path and converts an under-recording failure into an over-recording one, which §(b) already tolerates. Rejected. - A single boolean
tearingDownas the whole death-disambiguation ((c)). Cannot distinguish two VM lives of one session, so a stale monitor’s death event can mark a healthy just-booted VM dead (§(c), “Why an epoch”). The epoch stamp makes the stale monitor structurally inert for oneuint64under the lock the transfer already holds. Rejected. Stoprefusing a dead session with*SessionDeadError((c)). It reads as consistent with the exec verbs, butTeardownearly-returns on Stop’s error (agent.go:216-222), so a dead session could never be torn down and its table entry and runtime dir would leak permanently. Rejected — teardown is the one path a dead session must always accept.- Deployment discipline (D8) as the RunRoot single-owner guarantee ((b), OQ-8). One Runner per box is an intention, not an enforced invariant, and the failure mode it leaves open is the reaper killing a live predecessor’s sessions with the §(a) identity check CONFIRMING each kill. A lockfile is one file and one syscall. Rejected as the primary defense.
- A live
statfsinside the quota gauge callback ((d)). Puts a syscall on every collection interval and needs an error posture andActive()-transition semantics that no V7 slice produces; the preflight-time snapshot delivers a single-meaning number with none of that (§(d)). Rejected for V7, revisit with V8’s measurement work. - A
main-level ordering test over the existing fake-engine seam ((d), W5). The existing harness callsverifyBackendPreflightdirectly and never invokesrun(), so it cannot observesetupOtel’s position — a test written against it passes either way (W5’s test cycle). Rejected in favor of an extractedstartupunit with injected hooks. - A
vsock.portfile per the parent’s sketch ((a), OQ-1). The port is a per-host constant (guestVsockPort,microvm_lifecycle.go:48-52); a per-session copy of a constant is drift surface with no reader. Rejected. - Restart a dead virtiofsd under a live VM ((c)). “no remount-and-hope” (microvm-runner.md:249-251): the guest’s mount is stale and the handshake state unprovable; the parent froze fatal-to-the-session. Rejected.
- Prometheus-style underscore metric names ((d)). The parent’s names are
illustrative; the codebase convention is dotted OTel through the global
meter (
consumer.go:269-272). An executor following the parent literally would mint a second convention. Rejected. - Per-session metric attributes ((d)). Session ids are unbounded;
the delivery consumer’s hard rule (“NEVER labelled per-session/channel/
message”,
consumer.go:252-253) applies unchanged. Session identity goes to INFO logs, aggregates to metrics. Rejected. - Abort startup on a
ReapOrphansfailure ((b), OQ-5). Fresh random session ids mean orphans cannot collide with new sessions (mintSessionID,microvm_lifecycle.go:332-341); an abort converts one wedged process into a box-wide refusal with no operator gain over a loud WARN + next-startup retry. Rejected. ReapOrphansacquiring the RunRoot lock itself ((b), OQ-8). The shape an earlier draft specified in four places at once. It cannot hold the lock for the Runner’s life (the release func outlives the call that returned it, with no defined owner), and — decisively — a SECOND in-processReapOrphansrefuses itself with EWOULDBLOCK, becauseflockconflicts across distinct open file descriptions including two opens of one file within one process. §(b) step 1 designs explicitly for repeat invocation, so this shape freezes a reaper that is designed to be re-invocable behind a lock guaranteeing it cannot be. Rejected in favor ofrun()/startupacquiring once and passing the token.StartCLEARINGdeadCauseinstead of adeadEpochstamp ((c)). Simpler-looking, and it matches howtearingDownis handled, but it puts one field in two contradictory duties:Removeis specified to OBSERVE and reportdeadCauseafter a death, while a clear-on-Startdesign needs it gone before the nextExec— andStop-on-dead is a no-op success that returns before anyStartruns, so there is no single moment where both promises hold. ThedeadEpochstamp needs neither promise: the cause stays readable forever and the comparison against the CURRENT epoch makes it inert for the next life. It also keeps ONE generation concept in the design, gating reads exactly asepochgates writes. Rejected.- A bare
for { cause := <-vm.DeathWatch() }monitor loop ((c)). Adequate whileDeathWatchclosed after one fatal send, but theDeathPasstarm (OQ-6) made the channel multi-send and its consumer long-lived, and a receive on a closed channel yields the zero valueDeathCause("")forever — a full-CPU spin, one goroutine per session that ever saw a passt death.for cause := range …makes the close the termination on every path. Rejected.
Global Constraints
Section titled “Global Constraints”Every task below inherits these.
- Lands after V6. The supervision core V7 rides (per-child
exitedreapers,hasExited, the singlecmd.Wait) is PR #912’s shape; W-slices rebase onto merged main, never onto the review branch. - Metrics are OTel, never Prometheus-style. Dotted names under the
package instrumentation scope via the global meter; creation failure logs a
warning and disables the instrument (nil + skip), never fails construction
or blocks the hot path (
consumer.go:266-276). No per-session/name/path attribute anywhere; every attribute value comes from a closed enum. - One reaper per child, one
cmd.Waitper package. Every new death/exit observation goes throughc.exited/hasExited(PR #912launch.go:83-105,460-465); no V7 code callsWait, andReapOrphansoperates on non-child processes via proc-probe + signal only. - The reaper NEVER scans without the RunRoot lock in hand, and never
takes it itself.
run()(go/cmd/compass-runner/main.go:43), through W5’sstartupunit, acquireslockRunRootONCE before the reap hook anddefers the release for the process’s life;ReapOrphansaccepts the held-lock token and refuses — no scan, no signal — without it. That is the ONE owner, named identically at §(b) step 0, theReapOrphansdoc comment, W2’s Interfaces, W5’s Interfaces, the## Tasksbullets and OQ-8. No code path, test seam, or fake may bypass it, and the reaper may not acquire it even as a fallback: a secondflockfrom the same process refuses itself, which would break the re-invocability §(b) step 1 designs for. This is the one invariant standing between the §(a) identity check and a confident kill of a live Runner’s sessions. - The lockfile is created once and NEVER unlinked. Release closes the
fd only; no reap arm, cleanup path, or preflight probe may remove
<RunRoot>/microvm/.runner.lock. Two Runners holding flocks on two different inodes is indistinguishable from no lock at all, and the run root is not inviolate in this tree — the preflight already creates andos.RemoveAlls<RunRoot>/microvm/.preflightat every startup (microvm_preflight.go:177-183) — so the prohibition has to be stated rather than assumed. - The on-disk record never UNDER-names a live child.
launchwrites each child’s intent record before its spawn and settles it after, so a crash at any instant leaves a dir naming every process it may have started (§(a)). Over-naming is fine —ReapOrphanstolerates it by construction; under-naming is the failure mode nothing downstream can recover from. - A named test must be able to FAIL on the bug it names. Every case in the W-slices asserts against a seam that can actually observe the property: no ordering claim over a seam that cannot see the ordering (W5), and no lock-release claim whose fixture can return before reaching the blocking call (W4). Where no such seam exists, the slice extracts one or states plainly that the property is review-guarded.
- The podman path is byte-identical. No podman argv, check, metric, or
message changes; the one shared touchpoint (the
backend-labelled session counter in the Runner host) reads the backend name through an unexported probe and changes no podman behavior. - Frozen
ContainerRuntimeinterface untouched.ReapOrphansand the backend-name probe are*MicroVMRuntimemethods / unexported interface assertions — the V3/V4-ratified single-method-probe discipline (main.go:185-203). - Two-tier test split. Everything booting or killing a real VM carries
//go:build microvm && unix, callsmicrovmtest.Require(t)first, and lives in*_microvm_test.go; pidfile parsing/liveness verdicts, reaper directory logic (fake proc probes), monitor sequencing (seam-faked VM), error mapping, and metric emission (sdkmetric.NewManualReaderinstalled as the global before construction,trace_test.go:209-220) are hermetic. - Lint gates. golangci-lint 2.13.2 clean under BOTH tag sets (untagged
and
--build-tags microvm); nilaway underGOTOOLCHAIN=go1.27.1;funlen≤ 120 per function. - No panics in library code. Every failure is an error value; the new
error type follows the
CommandErrorshape (podman.go:318-341). - V8 owns proof and thresholds. The acceptance suite, the benchmark, and the Q-budget threshold VALUES are V8 (microvm-runner.md:600-621,839-841); V7’s tests prove mechanism, not budget.
Dependency order: W1 (pidfiles) precedes W2 (the reaper reads them, intent
records included). W3’s hermetic tier is independent of W1/W2 and may run in
parallel; its KVM tier depends on W1 — both rewrite Shutdown’s cleanup
surface (VM.pidfile becomes W1’s three-element set) and W3’s KVM test
asserts the three-pidfile removal after a death teardown, so the assertion
differs by whether W1 landed. W4 (backend metrics + logs) consumes W3’s death
causes and W2’s reap outcomes; W5 (startup wiring + Runner-host counter)
consumes W2 and W4. W2 carries TWO deliverables that must land together —
lockRunRoot and ReapOrphans — because a reaper without the lock is the
live-session-killing scan OQ-8 exists to prevent; do not split them across
PRs. The lock’s ACQUISITION is W5’s (run()/startup owns it, §(b) step
0), so W2 must land before or with W5: a ReapOrphans requiring a token
nobody yet mints is not callable, which is the intended direction of that
dependency.
W1 — host-written pidfiles with boot-id + starttime identity (hermetic + KVM)
Section titled “W1 — host-written pidfiles with boot-id + starttime identity (hermetic + KVM)”The §(a) layout: three pidfiles, one writer, atomic two-step writes (intent before the spawn, settled record after), PID-reuse defense within and across boots.
- Interfaces: produces, in
go/internal/runtime/microvm(//go:build unixfiles):func writePidIntent(path string) error— writesintent <bootid>\n(0600) via temp-file-then-os.Renamein the same dir, called BEFORE the child’sstartChildso the dir names every child that may become live (§(a));func writePidfile(path string, pid int) error— reads/proc/<pid>/statfield 22 and the boot id (from/proc/sys/kernel/random/boot_id, read once per process and cached), writes<pid> <starttime> <bootid>\n(0600) by the same temp-file-then-os.Rename, superseding the intent record in one atomic operation — so a reader sees the intent record or the settled record, never a torn prefix and never nothing (§(a));type pidRecord struct { Intent bool; PID int; StartTime uint64; BootID string },func readPidfile(path string) (pidRecord, error)— parses either form,Intenttrue for the pre-spawn line,func (r pidRecord) alive() (bool, error)— boot-id short-circuit, then starttime-compared liveness (stale boot id ⇒ false, nil; ENOENT ⇒ false, nil). A SAME-BOOT intent record is neither alive nor dead: it returns a distincterrPidUnknownso §(b) routes it to the possibly-live arm rather than to a kill or a removal;launchextended: before eachstartChild,writePidIntent; after it succeeds,writePidfile— both into the runtime dir (vmm.pid/virtiofsd.pid/passt.pid); a failure of either returns an error (the existing deferredvm.Shutdowncleans up,launch.go:146-152);- passt argv loses
--pid(launch.go:187-196);VM.pidfilebecomes the three-element cleanup set removed byShutdownbeside the sockets (launch.go:379-389).
- Test cycle:
- Hermetic: pidfile round-trip (write → read → fields match proc and
the live boot id); intent round-trip (
writePidIntent→readPidfilereportsIntent, andalive()returnserrPidUnknownfor the current boot but false-nil for a perturbed boot id);writePidfileover an existing intent record leaves ONLY the settled record;alive()false on ENOENT, on a starttime mismatch (fake a record with a perturbed starttime for the test’s own pid), and on a perturbed boot id; neither write leaves a temp file behind; unparseable content errors; a spawn-failure boot leaves the intent record and no settled record — the state §(b)’s possibly-live arm is built for. These read/proc/self/statand the boot id, so they are Linux-hermetic under the package’s//go:build unixtag — the precedent isreadPSS, which reads/proc/<pid>/smaps_rollupfrom the same unix-taggedlaunch.go(PR #912launch.go:1,695-697). - KVM (
launch_teardownextension,//go:build microvm && unix): afterLaunch, all three pidfiles hold settled records (no intent lines left) and each names a live process whose starttime matches; afterShutdown, all three are gone.
- Hermetic: pidfile round-trip (write → read → fields match proc and
the live boot id); intent round-trip (
W2 — ReapOrphans + startup wiring (hermetic + KVM)
Section titled “W2 — ReapOrphans + startup wiring (hermetic + KVM)”The §(b) reaper. Depends on W1’s file format.
- Interfaces: produces
func (m *MicroVMRuntime) lockRunRoot() (held RunRootLock, release func(), err error)ingo/internal/runtime(//go:build unix) —syscall.FlockwithLOCK_EX|LOCK_NBon<RunRoot>/microvm/.runner.lock(created 0600, the dir created if absent), returning the held-lock token plus the fd-closing release. OnEWOULDBLOCKit returns a named “another Runner owns this RunRoot” error. Its sole caller isrun()/startup(W5,go/cmd/compass-runner/main.go:43), which acquires it ONCE ahead of the reap hook anddefers the release for the process’s life, so the kernel drops it on any holder death including SIGKILL (§(b) step 0, OQ-8).ReapOrphansnever calls it — it takes the token. The release CLOSES the fd and never unlinks the file: the lockfile is created once and lives forever, because two Runners flocking two different inodes is indistinguishable from no lock at all (§(b) step 0). This is a NAMED W2 deliverable, not an OQ-8 assumption —ReapOrphansmust be unable to scan without it;type RunRootLock— an opaque non-nil token the lock hands out andReapOrphansrequires, so “the lock is held” is a value an executor cannot forget to check rather than a comment;func (m *MicroVMRuntime) ReapOrphans(ctx context.Context, held RunRootLock) erroringo/internal/runtime(//go:build unix): a ZEROheldtoken is a named error with NO scan and NO signal — the reaper never acquires the lock itself, and structurally cannot scan without it; then scan<RunRoot>/microvm/*/; skip table-live dirs (underm.mu); per §(b) kill VMM-first via SIGTERM →orphanReapGracepoll → SIGKILL with boot-id short-circuit and starttime re-verification before every signal;ctxbounds the whole escalation loop (checked between polls; cancellation ⇒ named per-dir error, dir kept); signal errors differentiated — ESRCH benign (already gone, counts as confirmed-dead), EPERM a named per-dir error with no blind retry and the dir kept;os.RemoveAllfully-dead dirs; KEEP dirs holding a same-boot intent record (WARN +orphans.reaped{outcome=possibly_live}, no kill, no removal, no age gate); age-gate (orphanDirGrace = time.Minute) dirs with no readable pidfiles; join per-dir errors;- two reaper-local constants in package
runtime, beside each other so the package question is answered once for both:const orphanReapGrace = 5 * time.Second— mirroring packagemicrovm’s unexportedreapGrace(launch.go:45-47, “how long Shutdown waits for a SIGTERM’d auxiliary daemon … before escalating to SIGKILL”), whichReapOrphanscannot reference:launch.go:3ispackage microvmwhilemicrovm_lifecycle.go:3ispackage runtime— andconst orphanDirGrace = time.Minute(§(b) step 3, OQ-7). The reaper is not the parent of these processes, so it polls rather thanWaits; the value is deliberately kept equal to the parent-side grace and neither constant is exported to the other package; - an injected probe seam
type reapProbes struct { readStat func(pid int) (uint64, error); signal func(pid int, sig syscall.Signal) error }defaulted to the real proc/kill, overridden in hermetic tests. The lock is deliberately NOT seamed — a fake would defeat the one property the hermetic contention case must prove; main.go:type orphanReaper interface{ ReapOrphans(ctx context.Context, held runtime.RunRootLock) error }andtype runRootLocker interface{ lockRunRoot() (runtime.RunRootLock, func(), error) }, both probed afterverifyBackendPreflightpasses and wired as W5’slockRunRootandreaphooks; a non-nil reap error is aslog.Warnwith the joined detail, never an abort (OQ-5), and a refused lock is aslog.Warnthat SKIPS the reap hook entirely;- consumes W1’s
readPidfile/pidRecord.alive.
- Test cycle:
- Hermetic (fake probes, temp RunRoot): a SECOND
lockRunRootover the same RunRoot is REFUSED while the first release is outstanding (flockconflicts across distinct open file descriptions, including two opens of the same file within one process, so one test process can exercise the contention path directly); after the first release runs, a fresh acquisition SUCCEEDS — the re-acquirability the startup-owned lifetime needs. Separately,ReapOrphanscalled with a ZERO lock token performs no scan and issues no signal — asserted by planting a live-match pidfile that must survive untouched and by the fake signal probe recording zero calls. Then the reap behaviors, all with the lock held: planted live-match pidfiles ⇒ signalled in VMM-first order and dir removed; starttime-mismatch pidfile ⇒ NO signal issued, dir removed; stale-boot-id pidfile ⇒ no proc read, no signal, dir removed; same-boot intent record ⇒ no signal, dir KEPT,possibly_livecounted, WARN naming the child; previous-boot intent record ⇒ no signal, dir removed; fake signal returns ESRCH ⇒ treated as confirmed-gone, dir removed; fake signal returns EPERM ⇒ named error, dir kept, no SIGKILL follow-up; cancelled ctx mid-escalation ⇒ named error, dir kept; a process that never dies ⇒ dir kept, named error returned, second run retries; young no-pidfile dir kept, aged one removed; table-live dir untouched; concurrentCreateduring a scan never loses its dir. - KVM — a REAL crash, not a dropped pointer: spawn the first Runner as
a CHILD PROCESS, let it boot a real VM and hold the RunRoot lock, then
SIGKILLthe child so the kernel drops its flock, and only then take the lock and runReapOrphansfrom the surviving test process ⇒ the lock is acquired, all three processes dead (proc-verified), dir gone. Constructing a fresh in-process runtime over the same RunRoot would NOT be a simulated crash — the first runtime still holds the lock, so the acquisition would be correctly refused and prove nothing about the reap. This case exercises the release-on-death property OQ-8(i) rests on and the reap together, which is why it replaces the drop-the-pointer form.
- Hermetic (fake probes, temp RunRoot): a SECOND
W3 — DeathWatch + session monitor + SessionDeadError (hermetic + KVM)
Section titled “W3 — DeathWatch + session monitor + SessionDeadError (hermetic + KVM)”The §(c) mid-session death path. Hermetic tier independent of W1/W2; KVM
tier depends on W1 (Shutdown’s cleanup set and the three-pidfile
assertion; see the Plan preamble).
- Interfaces: produces
type DeathCause string(DeathVMM,DeathVirtiofsd,DeathPasst) andfunc (vm *VM) DeathWatch() <-chan DeathCausein the microvm package — select over all three children’sexitedchannels, one send per observed exit on a buffered (cap 3) channel, closed when every non-nil child has been reported, nil-device tolerant, noWaitcalls, single-consumer by documented contract (§(c): the session monitor is the one receiver);func (vm *VM) VMMExited() bool— nil-safe delegation to the VMM child’shasExited()(PR #912launch.go:96-107; a channel read, not a zombie-blind signal-0 probe), forExec’s in-flight wrap (§(c));type SessionDeadError struct { ID ContainerID; Cause string }withError()ingo/internal/runtime(untagged file, besideCommandError);microvmSession.deadCause+microvmSession.deadEpoch+microvmSession.epoch+microvmSession.tearingDown(all underm.mu);Stop/RemovesettearingDownbefore callingvm.Shutdown;StartINCREMENTSepochand clearstearingDownin the same locked critical section that stores the new VM (microvm_lifecycle.go:401-415), and hands the new epoch to that VM’s monitor (§(c));- the one refuse condition,
session.deadCause != "" && session.deadEpoch == session.epoch, read underm.mubystartedExecand byExec’s error arm and NOWHERE restated in a second form (§(c)):deadEpochscopes the READ side per VM life exactly asepochalready scopes the monitor’s write side, so a death recorded in life #1 refuses nothing in life #2; Startspawns the monitor goroutine after ownership transfer (microvm_lifecycle.go:401-415) carrying its epoch, and it RANGES overDeathWatch()—for cause := range vm.DeathWatch()— so the channel’s close is its termination on every path, including the non-fatal passt path that has no other exit. It DISCARDS any death whosesession.epoch != myEpoch(a superseded VM life, then returns) and any death arriving whiletearingDown; on a fatal cause it recordsdeadCauseANDdeadEpoch = myEpoch, runsvm.Shutdown(context.WithoutCancel(…)), logs + counts (W4 hooks) and returns; onDeathPasstit logs + countspeer.deaths{process=passt}and KEEPS RECEIVING, never settingdeadCause(§(c));startedExec/Exec/ExecStreamingrefuse a dead session with*SessionDeadErrorwhen the refuse condition above holds.Stop,RemoveandStartdo NOT —Stopon a dead session is a no-op success (the same arm as never-started,microvm_lifecycle.go:615-617) soAgentRuntime.Teardown’s Stop-then-Remove reachesRemoveinstead of early-returning at stage “stop” and leaking the entry and dir forever (agent.go:216-222; §(c)), andStartre-boots rather than refusing — bumpingepochpastdeadEpochis exactly what makes the previous life’s cause inert;Exec’s error arm re-tests the same condition and, when it is false, probesVMMExited()before wrapping in-flight transport failures (microvm_lifecycle.go:486-495; §(c));- the
guestVMseam gainsDeathWatch() <-chan microvm.DeathCauseandVMMExited() bool(microvm_lifecycle.go:99-113lists the seam’s method-set rule).
- Test cycle:
- Hermetic (seam-faked VM): fake fires a VMM death ⇒ monitor records
cause, Shutdown called once, next Exec refuses with
errors.As*SessionDeadError{Cause:"vmm"}; virtiofsd death ⇒ same with"virtiofsd"; passt death ⇒ NOdeadCause, NO Shutdown, the next Exec still succeeds, and the peer-death counter and INFO line are both emitted (the non-fatal-but-observed arm, §(c)); deliberate Stop/Remove ⇒ monitor exits silently, no death recorded; Stop → Start → fake death ⇒ the second life’s death IS recorded (the per-epoch reset proven); STALE-MONITOR CASE: hold monitor#1’s death event undelivered across aStopand aStartthat stores VM#2, then deliver it ⇒ the session is NOT marked dead,deadCausestays empty, the next Exec succeeds, and monitor#2 still detects VM#2’s own death (the epoch stamp’s whole purpose — this is the case a booleantearingDownfails, §(c)); STOP-AFTER-DEATH CASE: fake fires a VMM death, thenStop⇒ nil, thenRemove⇒ nil, and the session-table entry is gone (a dead session must remain tearable-down, §(c)); in-flight exec failing whiledeadCauseis still unset but the fake’sVMMExited()reports true ⇒ wrapped*SessionDeadError{Cause:"vmm"}; a second receive on a drainedDeathWatchchannel observes only the close (the single-consumer contract’s failure mode pinned, never relied on); Remove after death ⇒ nil (idempotent), table entry gone; SECOND-LIFE CASE: fake fires a VMM death in life #1, thenStop⇒ nil, thenStartboots VM#2, thenExecSUCCEEDS —deadEpochis 1 whileepochis 2, so the stale cause refuses nothing, and monitor#2 still records VM#2’s own death (the per-life scoping of the READ side, §(c)); MONITOR-TERMINATION CASE: fake fires a passt death (non-fatal, the monitor keeps receiving), then CLOSES theDeathWatchchannel ⇒ the monitor goroutine EXITS — asserted on a done-channel the monitor closes on return, so the case fails on a spinningfor { <-ch }loop rather than merely being slow — with nodeadCauseset and noteardownspoint (§(c)). - KVM: boot a session,
SIGKILLthe VMM pid mid-exec ⇒ the in-flightExecfails with*SessionDeadError, peers reaped (proc-verified), sockets/pidfiles removed,Stopreturns nil andRemovereturns nil; kill virtiofsd ⇒ session torn down, the workspace share content intact on the host — the parent’s V7 test-cycle rows verbatim (microvm-runner.md:595-598);SIGKILLpasst ⇒ the session stays alive and a subsequentExecstill succeeds over vsock (egress lost, control plane intact), with the peer-death counter incremented.
- Hermetic (seam-faked VM): fake fires a VMM death ⇒ monitor records
cause, Shutdown called once, next Exec refuses with
W4 — backend metrics + INFO transition logs (hermetic)
Section titled “W4 — backend metrics + INFO transition logs (hermetic)”The §(d) instrument set inside the microVM backend.
-
Interfaces: produces
const microvmInstrumentationScope = "github.com/RigelBuild/compass/go/internal/runtime";type microvmMetrics struct { bootDuration metric.Float64Histogram; teardowns metric.Int64Counter; vsockRPCDuration metric.Float64Histogram; canaryRuns metric.Int64Counter; orphansReaped metric.Int64Counter; peerDeaths metric.Int64Counter; guestPSS metric.Int64ObservableGauge; quotaUsedRatio metric.Float64ObservableGauge }built inNewMicroVMRuntimefrom the global meter, each instrument nil plus oneslog.Warnon creation error, every record nil-guarded;lastQuota QuotaReadingonMicroVMRuntime— the quota gauge’s ONLY source, written underm.muby the preflight’sverifyQuotawhere it already holds the reading (PR #912microvm_preflight.go:174,189-194is where the reading is bound and logged today, and dropped on return; the struct carries no quota field at all, PR #912microvm.go:96-111). Preflight-time snapshot, never refreshed; the callback does NOstatfsand therefore needs no error posture (§(d));- record points:
Start(boot duration + INFOmicrovm session booted), the W3 monitor (teardowns{cause}+ INFOmicrovm session diedon a fatal cause;peer.deaths{process=passt}+ INFOmicrovm session peer diedon the non-fatal arm),Stop/Remove(teardowns+ INFO),Exec/awaitHealthy/Provision/stopGuest(vsock.rpc.duration{rpc,outcome}),BootCanary(canary.runs{outcome}),ReapOrphans(orphans.reaped{process,outcome}, including thepossibly_liveseries for §(b)’s dangling-intent arm — and NOTteardowns: a reaped orphan dir is a previous process’s leftover, never a session this Runner tore down, so it must not land in the series carryingvmm_death, §(d)), and the per-session PSS sampler below; - observable callbacks registered at construction:
guestPSSsnapshots the live VM handles underm.mu, RELEASES the lock, then sumsvm.PSS()per process kind outside it — never holdingm.muacross asmaps_rollupread (§(d); best-effort, errors dropped asPSSitself does, PR #912launch.go:678-683);quotaUsedRatioreadslastQuotaunderm.muand emits a point ONLY whenlastQuota.LimitBytes > 0— the gate is the RATIO’s own validity, notActive(), becauseUsedRatioshort-circuits to a hard0whenLimitBytes <= 0(PR #912microvm_quota.go:149-153) andActive()’s independent inode arm is true withLimitBytes == 0(PR #912microvm_quota.go:117-125). The zero-value snapshot is still silent under this gate, with no extra flag (§(d)); pssSampleInterval = 60 * time.Secondand the per-session PSS sampler OQ-9(ii)’s recommendation assumes: one goroutine started inNewMicroVMRuntime, stopped by the runtime’s shutdown, that on each tick snapshots the live session/VM pairs underm.mu, RELEASES the lock, and then emits one INFOmicrovm session memoryline per session —session_idplusvmm_pss_kb/virtiofsd_pss_kb/passt_pss_kbfromvm.PSS(), which returnsmap[string]int64keyed by child name in kB (PR #912launch.go:663-669). Same snapshot-then-read-outside-the-lock discipline as theguestPSScallback, so it costs no additionalm.muhold, and same best-effort posture (a missing key is a dropped field, never an error). This is the ONLY runaway-identification signal V7 ships (§(d), OQ-9); it is dropped only if the human rules OQ-9 (i)-only;- consumes W2/W3 hooks; no new public API.
-
Test cycle (hermetic):
sdkmetric.NewManualReader+NewMeterProviderinstalled as the global BEFORENewMicroVMRuntime(thetrace_test.go:209-220harness); seam-faked boot ⇒compass.microvm.boot.durationhas one point withoutcome=okand no other attribute; faked death ⇒teardowns{cause=vmm_death}= 1; faked passt death ⇒peer.deaths{process=passt}= 1 andteardownsunchanged; every data point asserted to carry ONLY enum attributes (thedispatchedCounts-style attribute audit,trace_test.go:253-256); a no-op global meter ⇒ construction succeeds, records are skipped, nothing panics; quota gauge with an unsetlastQuota⇒ NO point collected; with a reading planted whoseLimitBytes > 0⇒ exactly one point carrying itsUsedRatio(); with an INODE-ONLY reading planted (LimitBytes == 0,LimitInodes/FilesystemInodesset soActive()is TRUE) ⇒ NO point collected — the case anActive()gate would have emitted a hard0for (§(d)); the PSS sampler with two live seam-faked sessions ⇒ onemicrovm session memoryINFO line per session per tick, each carryingsession_idand the per-process-kind values, driven by an injected tick rather than by waiting outpssSampleInterval, and the sampler goroutine EXITS when the runtime’s stop channel closes.The PSS-callback lock test is written SELF-VERIFYING, because the naive form passes vacuously: with no session in
m.sessionsor a nilguestPSS, the callback returns before it ever reachesPSS()and any concurrentm.muacquisition proceeds trivially. So the fixture registers a session carrying a seam-faked VM whosePSS()signals an entry channel and then blocks, and the test asserts BOTH that the entry signal was observed (proving the callback actually reachedPSS) and that a concurrentm.muacquisition completes while it is still blocked. ACollecton the ManualReader drives the callback.
W5 — startup wiring: setupOtel reorder + Runner-host session counter (hermetic)
Section titled “W5 — startup wiring: setupOtel reorder + Runner-host session counter (hermetic)”The §(d) main.go ordering fix and the backend-labelled session metric.
-
Interfaces: produces
-
main.go: the startup sequence extracted into a testable unittype startupHooks struct {setupOtel func(ctx context.Context) (func(), error)selectEngine func() (runtime.ContainerRuntime, error)preflight func(ctx context.Context, engine runtime.ContainerRuntime) errorlockRunRoot func(engine runtime.ContainerRuntime) (runtime.RunRootLock, func(), error)reap func(ctx context.Context, engine runtime.ContainerRuntime, held runtime.RunRootLock) error}func startup(ctx context.Context, h startupHooks) (runtime.ContainerRuntime, func(), error)which calls them in the order
setupOtel→selectEngine→preflight→lockRunRoot→reapand returns the engine plus a combined shutdown closure (the OTel flush AND the lock release).run()(go/cmd/compass-runner/main.go:43) builds the real hooks and calls it, replacing the inline sequence atmain.go:106-112and thesetupOtelcall atmain.go:167-172.setupOtelmust precedeselectEngine, not merely the preflight, because instrument construction happens insideNewMicroVMRuntimedownselectEngine’s call path (main.go:332-345→microvm.go:117-122); §(d) has the argument.startupis the RunRoot lock’s sole OWNER (OQ-8, §(b) step 0). ThelockRunRoothook runs ONCE, before the reap hook, through a fifth unexported single-method probe (type runRootLocker interface { lockRunRoot() (RunRootLock, func(), error) }, the same discipline as the existing three atmain.go:185-203); its release goes into the returned shutdown closure thatrun()defers, so the lock is held for the process’s life and the kernel drops it on any holder death. The lock token is then PASSED to thereaphook —ReapOrphansnever acquires it, which is what makes the reaper structurally unable to scan unlocked AND safely re-invocable within one process (a secondflockfrom the same process would refuse itself; §(b) step 0). AlockRunRooterror is aslog.Warnand the reap hook is SKIPPED — nothing is scanned, startup continues (OQ-5). An engine that does not implement the probe (podman) skips both hooks, unchanged. TheorphanReaperprobe (W2) is thereaphook; -
go/internal/runtime:func (m *MicroVMRuntime) BackendName() string { return "microvm" }andfunc (p *PodmanCLI) BackendName() string { return "podman" }— NOT on the frozen interface; -
go/internal/runner/host.go:agentHostresolves the backend name once at construction viainterface{ BackendName() string }(unknown ⇒"unknown", never a per-call probe) and mintscompass.runner.session.starts(Int64Counter,backend+outcomeattributes) recorded inStart(host.go:306-499; the two record points are the error return athost.go:441-444and the success path fromhost.go:446to thereturn sessionID, nilathost.go:498), nil-on-error posture as everywhere; -
consumes W4’s conventions; no proto or interface change.
-
-
Test cycle (hermetic): ManualReader over a fake-engine
agentHostStart⇒ onesession.startspoint withbackendset from the probe and no other attribute beyond the enum pair; an engine without the probe ⇒backend="unknown", no error;startupcalled with hooks that append their own name to a shared slice ⇒ the slice is exactly["setupOtel", "selectEngine", "preflight", "lockRunRoot", "reap"], and a hook returning an error short-circuits the rest; alockRunRoothook returning an error ⇒ thereaphook is NEVER called andstartupstill returns the engine with no error (the OQ-5 warn-and-continue arm, asserted on the hook-call slice rather than on a log line); the returned shutdown closure invokes the lock release exactly once.This replaces an earlier claim that the ordering could be pinned “via the existing startup-dispatch test seam,
main.gofake engines”. It cannot: that harness callsverifyBackendPreflightdirectly and never invokesrun()(main_test.go:167,178,193,205,214,227,238,258; norun(call anywhere in the file), so it can observe nothing aboutsetupOtel’s position and a test written against it would pass whether the reorder happened or not. The extractedstartupunit exists precisely so the ordering has a test that can FAIL on the bug — the reorder is OQ-3’s sole delivery mechanism, so review-only would leave it unguarded.
- W1 — host-written
vmm.pid/virtiofsd.pid/passt.pidwith<pid> <starttime> <bootid>identity and atomic temp-then-rename writes; retire passt--pid; remove-on-Shutdown, boot fails on write failure - W1 — the two-step write that closes the spawn→write window:
writePidIntent(intent <bootid>) BEFORE eachstartChild, superseded by the settled record after it, andreadPidfilereporting the intent form as neither-alive-nor-dead (errPidUnknown) - W2 —
(*MicroVMRuntime) lockRunRoot():LOCK_EX|LOCK_NBflock on<RunRoot>/microvm/.runner.lock, refuse-not-wait on contention; a second acquisition over the same RunRoot is refused. Acquired ONCE byrun()/startup(W5) and held for the Runner’s life, NEVER byReapOrphans; the lockfile is created once and never unlinked - W2 —
(*MicroVMRuntime) ReapOrphans(ctx, held runRootLock) error: REQUIRES the startup-held lock token (a zero token ⇒ named error, no scan, no signal), then scan runtime dirs, boot-id short-circuit + starttime-verified SIGTERM→orphanReapGrace→SIGKILL bounded by ctx, ESRCH/EPERM differentiated, VMM first, remove fully-dead dirs, KEEP same-boot-intent dirs (WARN +possibly_livecount), age-gate empty dirs, skip table-live sessions;main.goprobe wiring, warn-never-abort - W3 —
VM.DeathWatch()(single-consumer, all three children, one send per exit) +VM.VMMExited()over the reaper channels; per-session monitor stamped with the session’sepoch— a superseded VM life’s death event is DISCARDED — plustearingDownfor the current epoch’s deliberate teardown; the monitor RANGES over the channel and returns when it closes (no other termination exists on the non-fatal passt path);*runtime.SessionDeadErroron refused and in-flight execs - W3 — death is scoped to ONE VM life: the monitor records
deadEpochbesidedeadCause, and readers refuse only whendeadCause != "" && deadEpoch == epoch, so a death in life #1 cannot refuse an exec in life #2 after aStop/Start - W3 — teardown never refuses:
Stopon a dead session is a no-op success (onlyExec/ExecStreamingrefuse), soAgentRuntime.Teardown’s Stop-then-Remove reachesRemove; idempotent Remove proven on a dead VM and the table entry gone - W3 — passt death observed but never fatal:
DeathPasstarm loggingmicrovm session peer diedand countingpeer.deaths{process=passt}with nodeadCauseand no teardown - W4 —
microvmMetrics(boot/teardown/vsock-RPC/canary/orphans/ peer-deaths/PSS/quota instruments, warn-and-disable), INFO transition logs with session id + timings - W4 —
lastQuota QuotaReadingonMicroVMRuntime, written underm.muby the preflight’sverifyQuotaand read by thequota.used.ratiocallback: preflight-time snapshot, never refreshed, and a point emitted ONLY whenlastQuota.LimitBytes > 0— the condition under whichUsedRatio()is meaningful, NOTActive()(an inode-only active quota emits no byte-ratio point, §(d)) - W4 — the per-session PSS sampler OQ-9(ii) assumes: a
pssSampleInterval = 60 * time.Secondticker emitting one INFOmicrovm session memoryline per live session (session_idplus the per-process-kind kB values fromVM.PSS()), reusing the gauge callback’s snapshot-m.mu-then-read-outside-the-lock discipline, stopped on runtime shutdown (§(d)) - W5 —
startup(ctx, hooks)extracted fromrun()so the ordersetupOtel→selectEngine→preflight→lockRunRoot→reapis pinned by a call-order test;setupOtelmust precedeselectEngine(not merely the preflight), because instruments are built insideNewMicroVMRuntime;run()acquires the RunRoot lock ONCE via therunRootLockerprobe anddefers the release for the process’s life, and a refused lock WARNs and skips the reap hook (OQ-8/OQ-5) - W5 —
BackendName()probes;compass.runner.session.starts{backend,outcome}in the Runner host
Open Questions
Section titled “Open Questions”Batched for the pre-freeze ruling; the body designs against each recommendation.
-
OQ-1 (non-load-bearing) — the runtime-dir file set diverges from the parent’s Interfaces sketch. The parent sketches
{vmm.pid,virtiofsd.pid,netbackend.pid,vsock.port}(microvm-runner.md:589-590). V7 keepspasst.pid(the name already on disk,launch.go:178) overnetbackend.pid, and ships NOvsock.portfile: the control-plane port is a fixed per-host constant with per-session identity on the socket paths (microvm_lifecycle.go:48-52), so the file would be a reader-less copy of a constant. Two additions the sketch does not name: each pidfile can transiently hold §(a)’s pre-spawnintent <bootid>line instead of a pid (the same three paths, one extra record form — not a fourth file), and<RunRoot>/microvm/.runner.locksits beside the per-session dirs as the Runner-scopedflocktarget (§(b) step 0). Recommendation: ratify all of it as (benign, naming/dead-file/mechanism) deviations from a sketch, not from a frozen behavior. -
OQ-2 (load-bearing) — “every existing session metric gains a
backendlabel” is vacuous today; V7 mints the first such metric instead. A tree-wide instrument-constructor search this session found exactly one OTel metric,compass.delivery.dispatched(server-side fan-out,consumer.go:269-272) — not a session metric — and two serverexpvarwebhook counters; the Runner emits no metrics at all. Options: (i) mintcompass.runner.session.starts{backend,outcome}in the backend-agnostic host as the first labelled session metric (delivers the intent; the label exists from birth for every future session metric to copy); (ii) declare the obligation satisfied-by-vacuity and ship nothing (honest to the letter, but V8’s benchmark then has no per-backend series to compare); (iii) retrofitcompass.delivery.dispatched(wrong process — the server doesn’t know the backend). Recommendation: (i). If the parent meant a wider session-metric set, that set does not exist to label, and inventing it is not V7 scope. -
OQ-3 (non-load-bearing) —
setupOtelmoves ahead ofselectEngine, not merely ahead of the backend preflight. TodayselectEngineis atmain.go:106, the preflight (and its canary) atmain.go:110-112, and the meter provider installs only atmain.go:167-172, so preflight-time metrics would hit the no-op global and vanish.selectEngineis the right target because instrument construction happens on its call path (main.go:332-345→microvm.go:117-122→NewMicroVMRuntime), and the preflight dispatches on the engine so nothing can sit between them. The reorder is safe:setupOtelis env-only and its disabled path is a no-op (provider.go). Worth noting what is NOT at risk: the global meter’ssetDelegatere-creates and re-delegates both instruments and registered callbacks when a real provider installs (go.opentelemetry.io/otel@v1.46.0/internal/global/meter.go:126-147, the pinned version pergo/go.mod:35), so instruments built pre-install keep working — only measurements RECORDED before the install are lost, which is exactly the canary’s points. Recommendation: reorder ahead ofselectEngine; keep instrument creation at construction time, with no lazy-on-first-record path (§(d) states the single posture). -
OQ-4 (non-load-bearing) — the parent’s “virtiofsd restarts” metric is translated to a teardown-cause series. Under §(f)’s frozen fatal-no-restart posture (microvm-runner.md:249-251) a virtiofsd death is a session teardown, never a restart, so a restart counter would be constant zero by design.
compass.microvm.teardowns{cause=virtiofsd_death}carries the actionable number (how often virtiofsd kills sessions). Recommendation: ratify the translation; if a restart path ever lands, it mints its own counter. -
OQ-5 (load-bearing) — a
ReapOrphansfailure is a startup WARN, not an abort. Un-reaped orphans hold stale resources but cannot collide with new sessions (fresh random ids,microvm_lifecycle.go:332-341); aborting turns one wedged process into a box outage. The D3 hard-fail posture governs capability preflights (can this host run VMs), not cleanup. Options: (i) warn + retry next startup; (ii) abort startup (symmetric with D3 but over-broad); (iii) warn but refuse only microVM-backend session creation until a clean reap (complexity without a demonstrated need). Recommendation: (i), with the per-dir error detail in the WARN and theorphans.reapedcounter making silent rot visible. A REFUSED RunRoot lock (§(b) step 0) takes the same arm one step earlier: the lock is acquired bystartup(OQ-8), so a Runner that cannot acquire it warns, SKIPS the reap hook, and starts — nothing is scanned, becauseReapOrphansmay not run without the lock held. The wrong reap is what the lock prevents, and refusing startup over a still-draining predecessor would be the box outage this option rejects. -
OQ-6 (LOAD-BEARING) — the net backend leaves the parent’s supervised set; this rules on frozen text. State the divergence plainly, because it is larger than a fatality question: the parent’s §(f) preamble supervises the per-session process set including the “net backend” (microvm-runner.md:240-241), and V7 removes passt from session-FATAL handling entirely. That is the same class of change as OQ-10’s dropping the guest — the other member of that one frozen sentence — which this record grades load-bearing and sends to the human; grading passt lower was an inconsistency, and it is corrected here.
On the merits, non-fatality is right: the parent’s failure matrix names VMM death and virtiofsd death only (microvm-runner.md:244-253), and passt dying leaves the vsock control plane (served by the VMM over AF_UNIX) intact, so Stop/Remove still work while the guest loses egress. But “non-fatal” must not silently become “unobserved”: with no host-side detection there is no death signal, no metric series, and no log line, so an operator gets nothing when a session loses egress and OQ-6’s own closing rationale (“the session lifecycle handles it”) would rest on no mechanism at all. So the body ships observation WITHOUT fatality regardless of the ruling — a
DeathPasstarm onDeathWatchthat logsmicrovm session peer diedand countscompass.microvm.peer.deaths{process=passt}and does NOT tear the session down (§(c), §(d)).Options: (i) matrix-literal non-fatal, WITH the observation arm (what the body designs against); (ii) extend the frozen matrix and treat passt as fatal too (symmetric with virtiofsd, but widens frozen text and converts a degraded session into a killed one); (iii) non-fatal and unobserved (what an earlier draft implied — rejected here as removing passt from the supervised set outright). Recommendation: (i); revisit fatality with V8 evidence if egress-less zombie sessions show up in practice.
-
OQ-7 (load-bearing) — what the reaper does with an unidentifiable dir, ruled together with §(a)’s write lifecycle. Two distinct states get confused here, and the ruling needs them separated:
- A dir with no readable pidfiles — a crashed pre-spawn
Create(safe to remove), a concurrentCreate’s pre-insert window (must not remove, §(b) step 1), or content destroyed by external tampering. - A dir holding a same-boot INTENT record (§(a)) —
launchcrashed between writing the intent and settling the pid, so a child may be running RIGHT NOW with no pid on disk.
The correction to an earlier draft of this question: the atomic temp-write-then-rename closes the TORN-WRITE path ONLY. It does not close the spawn→write window, and that window is an ORGANIC under-recording path — spawn order puts the VMM last (
launch.go:222), so a crash between its spawn and its record leaves two parseable pidfiles naming two dead helpers and no record of a live orphan VMM, whereupon a remove-if-all-recorded-dead reaper erases the only evidence of the leak. Claiming atomicity made that state “reachable only via external tampering” conflated the two paths and made removal look safer than it was.So the body CLOSES the window rather than reasoning around it (§(a)’s pre-spawn intent record), which makes state 2 both detectable and attributable to a named child — and state 2 is never removed: WARN, count
orphans.reaped{outcome=possibly_live}, keep the dir, no age gate. With the window closed, state 1 is genuinely only pre-spawn-Create, theCreatepre-insert window, or tampering, and removal after the grace is defensible again for the reason the earlier draft gave — the reasoning just had to be earned rather than assumed.The mtime age gate (
orphanDirGrace = time.Minute, a reaper-local constant in packageruntimebesideorphanReapGrace, §(b) step 2) stays for state 1 regardless: it separates theCreatepre-insert window (microseconds of lock work) from everything else by orders of magnitude and bounds cleanup latency. Options for state 1: (i) remove after the grace; (ii) quarantine-and-WARN (leave or rename aside, log loudly, never auto-remove). Options for state 2: (a) keep-and-WARN forever (what the body ships); (b) age-gate it into removal like state 1 — rejected, since it re-opens exactly the leak the intent record exists to prevent. Recommendation: (i) + (a), and ratify 1m as the named constant. Rule the write lifecycle (intent record included) and the removal arms together. - A dir with no readable pidfiles — a crashed pre-spawn
-
OQ-8 (load-bearing) — RunRoot single-owner exclusion: nothing prevents two Runners over one RunRoot, and the reaper would then kill LIVE sessions. The most serious pre-freeze finding. §(b)’s table-skip guard is intra-process; the realistic breach is not operator error but a restart racing a still-draining predecessor: the new Runner’s
ReapOrphanssees the old Runner’s live sessions as orphan dirs, and the §(a) identity check — pid, starttime, boot id all genuinely matching — CONFIRMS the kill rather than preventing it. The defense built to stop kill-an-innocent makes this wrong kill MORE certain, one layer up. D8’s one-Runner-per-box is a deployment intention, not an enforced invariant. Options: (i) anflocked lockfile at the RunRoot root, taken exclusively at startup before any reap and held for the Runner’s life — a second Runner fails the lock and refuses startup; the kernel drops the lock on ANY holder death, SIGKILL included, so a crashed Runner never wedges its successor; (ii) declare D8 plus deployment tooling the guarantee and documentReapOrphansas unsafe under concurrent Runners; (iii) record the owning Runner’s own (pid, starttime, bootid) in RunRoot and haveReapOrphansskip dirs while that recorded owner is alive. Recommendation: (i), refuse-not-wait: the standard single-owner mechanism, one file and one syscall, turning the race into a loud startup failure that a supervisor’s restart backoff resolves naturally once the drain finishes — and, unlike (iii), it has no stale-record arm to reason about (the lock IS liveness). (ii) leaves the kill reachable; (iii) rebuilds half offlockby hand.This question rules on WHICH mechanism; it does not supply the mechanism. The body SHIPS (i) as a named W2 deliverable —
lockRunRootin W2’s Interfaces, its own## Tasksbullet, and a hermetic case asserting a second acquisition over the same RunRoot is refused and thatReapOrphanscalled without the lock held scans nothing (§(b) step 0). An earlier draft left the defense as this question’s recommendation only, which meant an executor implementing W1-W5 verbatim would have shippedReapOrphanswith zero cross-process exclusion — precisely the live-session-killing reaper this question exists to prevent. A safety property that lives only in an Open Question is not a delivered defense. If the human rules (ii) or (iii), W2’s lock deliverable and §(b) step 1’s clause change with the ruling.The lock’s OWNER is the startup unit, not the reaper, and that follows from (i)’s own “held for the Runner’s life” wording rather than being a second choice layered on it.
run()(go/cmd/compass-runner/main.go:43) acquires it once through W5’sstartupunit, ahead of the reap hook, anddefers the release for the process’s life;ReapOrphansREQUIRES the lock already held and never acquires it. A reaper-owned lock cannot satisfy (i) at all: the release func would have to outlive the call that returned it, and — the sharp edge — a SECONDReapOrphansin one process would refuse ITSELF with EWOULDBLOCK, becauseflockconflicts across distinct open file descriptions including two opens of one file within one process (this record’s own premise in W2’s hermetic case). §(b) step 1 designs explicitly for that repeat invocation, so reaper-owned ownership would freeze a reaper that is designed to be re-invocable behind a lock guaranteeing it cannot be. §(b) step 0, theReapOrphansdoc comment, the Global Constraint, W2’s Interfaces and the## Tasksbullets all name this one owner.One sub-fork rides along: option (i)’s wording says a second Runner “refuses startup”, while OQ-5 rules a reap failure a WARN and never an abort. With the lock owned by
startup, a refused lock surfaces BEFORE the reap hook rather than as aReapOrphanserror, so the arm has to be stated at that call site:startupWARNs, SKIPS the reap hook entirely (nothing is scanned, becauseReapOrphansmay not run without the lock), and continues serving. The reasoning is at §(b) step 0. Ruling (i) with refuse-startup semantics is coherent but strictly stronger, so rule OQ-5 and OQ-8 in one pass. -
OQ-9 (load-bearing) — “per-VM RSS” vs the fleet-summed PSS gauge: a ruling on a frozen sentence. The parent freezes “per-VM RSS” (microvm-runner.md:266); §(d) delivers
compass.microvm.guest.memory.psssummed per process kind with no per-session label (the cardinality rule,consumer.go:249-254). The aggregate cannot identify a runaway VM and cannot feed V8’s per-VM RSS benchmark comparison — whether the frozen line means fleet-sum or per-VM is not the designer’s call. Options: (i) accept the aggregate gauge and source V8’s per-VM numbers from the benchmark harness callingPSS()directly (no metric); (ii) periodic INFO log lines carrying per-session PSS — logs are exempt from the cardinality rule, the record’s own argument (§(d); the delivery consumer meters without ids,dispatch.go:379-383, while carrying them in the adjacent WARN,dispatch.go:392-393); (iii) an exemplar/high-watermark scheme (e.g. a max-per-session gauge). Recommendation: (i) + (ii) combined: keep the aggregate gauge as the fleet trend, add per-session PSS to a low-frequency INFO line for runaway identification, and let V8’s benchmark readPSS()directly — no per-session metric label is ever minted and every consumer of the frozen sentence gets a number. The body designs against this assumption (§(d)), and (ii)’s emitter is a NAMED W4 deliverable — themicrovm session memorysampler and itspssSampleIntervalconstant, in W4’s Interfaces and its own## Tasksbullet — not a property left to this recommendation. The same rule OQ-8 states applies here: a signal that lives only in an Open Question is not a delivered signal, and an executor implementing W1-W5 verbatim must not end up with the aggregate gauge and nothing that can identify a runaway VM. If the human rules (i)-only, W4’s sampler deliverable and its Task bullet are dropped with the ruling, and V7 then ships NO runaway-identification signal at all — §(d) says so explicitly so the consequence of that ruling is visible rather than silently unimplemented. -
OQ-10 (load-bearing) — the guest is dropped from V7’s supervised set; the parent’s sentence says it is in it. Parent §(f): the backend supervises the per-session process set including “the guest via the supervisor channel’s liveness” (microvm-runner.md:240-241). V7’s
DeathWatchcovers host children only; a guest that kernel-panics and hangs under a live VMM is a zombie session detected by nothing except per-exec timeouts (execDefaultTimeout,microvm_lifecycle.go:95-97) on sessions actively being used. Options: (i) declare per-exec timeout coverage sufficient for V7, explicitly re-scope the parent’s sentence, and defer active guest health-probing to V8 alongside the acceptance suite; (ii) add a low-frequencyHealth-poll arm to the session monitor now (the RPC exists, PR #912launch.go:548-555). Recommendation: (i): an idle-guest zombie holds quota but corrupts nothing, a poll arm mints a new false-positive teardown class (a loaded guest missing a poll deadline) with no V8 evidence to tune against, and V8’s acceptance suite is where liveness probing earns its thresholds. The deviation is surfaced in §(c), not silent; either option needs the human’s explicit ruling on the parent’s sentence.