Skip to content

feat(microvm): host-written per-session pidfiles with PID-reuse defense (RIG-2498) - #974

Merged
trunk-io[bot] merged 3 commits into
compass-runner/rig-2497-microvm-v6-isolation-quotafrom
compass-runner/2498-w1-pidfiles
Sep 8, 2026
Merged

feat(microvm): host-written per-session pidfiles with PID-reuse defense (RIG-2498)#974
trunk-io[bot] merged 3 commits into
compass-runner/rig-2497-microvm-v6-isolation-quotafrom
compass-runner/2498-w1-pidfiles

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 2 PRs:

  1. main
  2. feat(microvm): virtio-fs mount-ns isolation + volume-quota verification (RIG-2497) #912
  3. "feat(microvm): host-written per-session pidfiles with PID-reuse defense (RIG-2498)" (this PR)

V7 W1 per the frozen record §(a)/§W1: the host writes a pidfile for each of
the three children, carrying enough identity that a later reaper can tell a
live child from a recycled PID.

Each record is written in two steps. An intent <bootid> line goes down
before the spawn, so a crash between fork and the settled write still leaves
a removable marker; the settled <pid> <starttime> <bootid> line replaces it
once the child is up. Both go through one atomic temp-file-plus-rename helper
that chmods 0600 explicitly, because CreateTemp's mode is umask-masked and the
record's permissions should not be an environment dependency.

alive() checks the boot id first, so a record from a previous boot reads as
dead rather than reaching the PID comparison at all. Within the same boot an
intent record returns errPidUnknown — the spawn outcome is genuinely unknown,
which is a third verdict and not a synonym for dead. A settled record compares
/proc/<pid> field 22 against the recorded starttime, so a recycled PID does
not read as the original child.

Field 22 is parsed from the last ) in the stat line: a process can put
parentheses in its own comm, and splitting the whole line on whitespace shifts
every later field. A round-trip through live /proc cannot catch that, since
the write and the read share the parse and misparse identically, so
parseProcStartTime is tested against synthetic lines whose field 22 is a known
sentinel with decoys in the other numeric fields.

passt's --pid argument is retired: the host now owns all three records, so
there is one writer and one format rather than a daemon-specific special case.
VM.pidfiles is registered before the intent write, not after the settled one,
so teardown removes a record left behind by a failed spawn.

Refs: RIG-2498

@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

RIG-2498

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-runner-2498-w1-pidfi.compass-eng-docs.pages.dev

Deployed from compass-runner/2498-w1-pidfiles at f399d70.

TestLaunchFailClosedTeardown narrows PATH to its own fake bin so that
cloud-hypervisor is absent, but both fakes ended in a bare `sleep 30`. There
is no `sleep` in that dir and /bin/sh has no `sleep` builtin, so each fake
died on `sleep: command not found` milliseconds after recording its pid. The
daemons were not orphans being reaped; they exited on their own, and the
no-orphan assertion held trivially.

Once waitForSockets became liveness-aware, that latent fixture defect turned
into a merge-queue failure: launch now fails at the socket wait with
"virtiofsd exited before its socket was serving" instead of reaching the
cloud-hypervisor lookup, the second fake never records a pid, and readPidFile
could only ever exhaust its 2s budget. Every observed failure landed at
~2.02s, the deadline itself. It reproduced only in the merge result, never on
either head alone.

Resolve `sleep` on the real PATH before narrowing it and burn the absolute
path into the stubs, so the fakes outlive launch as the test always claimed.

Add the control that makes this class of vacuity self-reporting: assert the
error names cloud-hypervisor, the only absence this test arranges. Anything
earlier means the daemons never came up. On the unfixed merge result that
control fires on 39/40 runs while the assertion it guards stays green.

readPidFile then needs no poll at all: each fake records its pid before it
touches its socket, and the caller has confirmed launch got past the socket
wait, which orders the write before the read. A missing pidfile is now a real
ordering defect that fails immediately rather than a wall-clock budget that
can only expire.

Measured on the merge result (git merge-tree of main and the V6 head), 40
runs 5-way concurrent: 4/40 then 3/40 failures before, 0/40 after. Mutating
Shutdown to skip reaping passt fails 12/12 on the real invariant in ~0.03s,
so the assertion is load-bearing rather than merely green.
…se (RIG-2498)

V7 W1 per the frozen record §(a)/§W1: the host writes a pidfile for each of
the three children, carrying enough identity that a later reaper can tell a
live child from a recycled PID.

Each record is written in two steps. An `intent <bootid>` line goes down
before the spawn, so a crash between fork and the settled write still leaves
a removable marker; the settled `<pid> <starttime> <bootid>` line replaces it
once the child is up. Both go through one atomic temp-file-plus-rename helper
that chmods 0600 explicitly, because CreateTemp's mode is umask-masked and the
record's permissions should not be an environment dependency.

`alive()` checks the boot id first, so a record from a previous boot reads as
dead rather than reaching the PID comparison at all. Within the same boot an
intent record returns `errPidUnknown` — the spawn outcome is genuinely unknown,
which is a third verdict and not a synonym for dead. A settled record compares
`/proc/<pid>` field 22 against the recorded starttime, so a recycled PID does
not read as the original child.

Field 22 is parsed from the last `)` in the stat line: a process can put
parentheses in its own comm, and splitting the whole line on whitespace shifts
every later field. A round-trip through live `/proc` cannot catch that, since
the write and the read share the parse and misparse identically, so
`parseProcStartTime` is tested against synthetic lines whose field 22 is a known
sentinel with decoys in the other numeric fields.

passt's `--pid` argument is retired: the host now owns all three records, so
there is one writer and one format rather than a daemon-specific special case.
`VM.pidfiles` is registered before the intent write, not after the settled one,
so teardown removes a record left behind by a failed spawn.

Refs: RIG-2498
…idfile errors (RIG-2498)

Two HIGH findings, both in the new launch wiring rather than the pidfile
primitives.

A settled-write failure hung Shutdown on a nil channel. vm.vmmExited is
hoisted onto the VM only after startRecordedChild returns, but
startRecordedChild can fail after startChild has already spawned the VMM —
leaving a live process handle beside a nil channel, which is exactly the state
launch deferred Shutdown runs in. The bare receive there blocked forever,
replacing the launch error it was cleaning up after with a hang carrying no
stack. Guard the RECEIVE alone, not the arm: such a child is started, live and
unrecorded, so skipping its Kill would orphan precisely what the deferred
Shutdown exists to reap. Guarding the whole arm was measured to leave the VMM
alive past Shutdown.

A fast-exiting child failed the boot with a /proc path instead of its own
diagnostic. writePidfile reads /proc/<pid>/stat after startChild has installed
the reaper, so the read can lose to cmd.Wait reaping the pid. Reshape that one
case — and only when the child has provably exited — into the shape launch
already uses for a dead child: name the daemon, wrap waitResult, carry the log
tail. Every other write failure stays fatal, and the on-disk record is left
alone so a child that may have become live is never under-named.

That failure has TWO kinds, roughly half each: ENOENT when /proc/<pid> is
already gone at open time, and ESRCH when the open succeeds but the task is
reaped before the read. ESRCH does not satisfy errors.Is(err, os.ErrNotExist),
so the obvious predicate silently misses about half the occurrences. Add
procReadMeansGone covering both and use it at the alive() site too, where the
same predicate decided whether a dead process routes to reapable or surfaces
as a fault.

Also: scope writePidRecordLine durability doc to a process crash (host-crash
durability is deliberately not bought, since a changed boot id short-circuits
every surviving record dead); document alive() two-kinds-of-error contract so
the natural-but-wrong W2 call is harder to write; bound readPidfile read at
256 bytes; wrap the bare error returns with the path; move the three-name
wiring assertion into the hermetic tier so it no longer needs KVM; dot-prefix
the temp pidfile pattern.
@rigel-mintaka
rigel-mintaka force-pushed the compass-runner/2498-w1-pidfiles branch from 45874a6 to f399d70 Compare September 7, 2026 17:34
@trunk-io

trunk-io Bot commented Sep 8, 2026

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@trunk-io
trunk-io Bot merged commit 19c3c95 into main Sep 8, 2026
15 checks passed
@trunk-io
trunk-io Bot deleted the compass-runner/2498-w1-pidfiles branch September 8, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants