Skip to content

ci(fixture-vm): make the nightly actually run — lock, qcow2 attribute, git-dep hash (#362, #365) - #364

Open
avrabe wants to merge 18 commits into
mainfrom
fix/fixture-vm-flake-lock
Open

ci(fixture-vm): make the nightly actually run — lock, qcow2 attribute, git-dep hash (#362, #365)#364
avrabe wants to merge 18 commits into
mainfrom
fix/fixture-vm-flake-lock

Conversation

@avrabe

@avrabe avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Addresses #362 and #365. Neither is auto-closed: a green dispatch is the gate
for flipping those to Closes, and that has not happened yet.

The scope grew after the first push, deliberately. This opened as "commit
the flake.lock", and everything below this box is the original text, left
intact because it was written before I knew the result. It predicted that the
lock alone would not make the nightly green and that a second failure might be
hiding behind the first. That was right — twice. Rather than land a lock that
unblocks nothing and leave the workflow exactly as dead as it was, this PR now
carries every fix needed for the nightly to actually run.

What

Adds tools/fixture-vm/flake.lock. One new file, 27 lines. Nothing else.

Why the nightly has never passed

Trace-Topology Fixture Generation has concluded failure on 60 of 60 runs,
2026-06-012026-07-30. There is no passing run in the window.

It is not a defect — it is trace-fixtures.yml:58 doing precisely its job
(run 30516478347):

##[error]tools/fixture-vm/flake.lock is missing.
##[error]The nightly build runs only against a committed,
##[error]reviewed lock. Dispatch the 'fixture-vm flake.lock'
##[error]workflow, download its artifact, and commit the
##[error]resulting flake.lock in a PR.

The guard is fail-closed by design: the nightly builds only against a committed
lock and never re-resolves inputs itself, so that lock is the determinism pin for
the pinned nixpkgs, the guest kernel, and the whole closure. It has simply never
been satisfied — tools/fixture-vm/ has contained only flake.nix since it
landed. The workflow told us the exact remedy in every one of those 60 runs.

This follows that remedy literally: dispatch
fixture-vm flake.lock
(concluded success), download its artifact, commit it in a reviewed PR.

What is being pinned, and why this commit chooses nothing

flake.nix:70 declares github:NixOS/nixpkgs/nixos-24.05. The lock resolves that
to b134951a4c9f3c995fd7be05f3243f8ecd65d798.

I verified that independently of the container that produced it — verifying a
CI artifact with the CI that made it proves nothing. Against the GitHub API
directly:

check result
rev exists in NixOS/nixpkgs b134951a…, 2024-12-30T13:00:28Z
rev is the current tip of nixos-24.05 ✅ identical sha

So this commit is the deterministic resolution of what the repo already declares,
not a value I selected.

⚠️ For human judgement — deliberately not acted on

That branch tip is dated 2024-12-30. nixos-24.05 has not moved in 19 months
and is EOL.

nix flake update faithfully resolved the newest commit on the declared branch;
that branch just stopped receiving them. Bumping to a supported channel is a real
decision and is not taken here, for one concrete reason: it would change the
guest kernel, and the generated trace fixtures are a function of the guest
kernel.
Folding a fixture-changing input bump into a red-to-green CI repair would
hide it.

Two separable questions, and I'd rather they be reviewed as such:

  1. Should the nightly be able to run at all? — that is this PR.
  2. Should it run on an EOL nixpkgs? — that is a follow-up, and it needs someone
    who can weigh fixture stability against an unpatched-CVE build environment.

If the answer to (2) is "bump it," the right shape is: change flake.nix's ref,
re-dispatch, and land the fixture diff visibly in its own PR.

Verification — and its honest limit

  • Dispatch run 30560238740success.
  • Pinned rev independently confirmed present and branch-tip (table above).
  • Lock is well-formed JSON, version: 7, single nixpkgs input matching
    flake.nix.

What I have not verified: that the nightly now goes green. This unblocks the
guard at line 58; whether the subsequent steps (guest build, /dev/kvm boot,
fixture generation) succeed has been untestable for as long as the guard has been
failing — no run has ever reached them. It is entirely possible this reveals a
second failure behind the first. That is progress either way: the next failure
will be a real one rather than a missing file, and it will be visible.

I'll dispatch trace-fixtures on this branch and report what actually happens
before merging.

What the dispatch found: two more blockers, both now fixed here

The prediction above held. The lock let nix evaluate for the first time in the
workflow's life, and evaluation immediately died on something new.

# Blocker Where it surfaced
1 flake.lock absent; the line-58 guard is fail-closed 60/60 runs, never got past it
2 system.build.qcow2 never defined — the attribute the workflow builds existed only in comments run 30560720500, the first run to reach nix
3 cargoLock.outputHashes missing for the etch git dependency local eval, after fixing 2

Each was invisible behind the one before it. That is the whole reason this took
60 runs to surface: the outer guard failed closed, so nothing downstream was ever
reached, and "never ran" logged identically to "ran and failed."

Blocker 2 (#365). system.build is a freeform lazyAttrsOf raw, so a member
nobody defines is not a typo the module system catches — it is
error: attribute 'qcow2' missing at evaluation. Fixed by importing nixpkgs' own
make-disk-image.nix as a build product, the same way nixos-generators defines
its qcow format. Two arguments are load-bearing:

  • partitionTableType = "legacy" — not style. rootPartition is a switch on it
    (legacy → "1", efi → "2", hybrid → "3"), and flake.nix hardcodes
    fileSystems."/" = "/dev/vda1". Any other value partitions cleanly, builds
    cleanly, and then cannot find its root — a failure that appears at guest boot,
    not at build.
  • copyChannel = false — the default true copies the whole nixpkgs tree into an
    image that boots once, runs one oneshot service, and powers off.

Blocker 3. etch (pulseengine/rivet, pinned in the root Cargo.toml) is the
workspace's one git dependency. Registry crates carry their checksum in
Cargo.lock; git deps do not, so importCargoLock cannot vendor them without an
explicit hash. Note it is vendored even though gen-fixtures never uses it —
importCargoLock takes the entire lock file, not the subtree cargoBuildFlags
selects.

How this was verified without burning CI runs

nix build needs x86_64-linux. But all three blockers are evaluation errors,
and evaluation is platform-independent — so the whole guest config can be forced
on a macOS laptop in ~2 minutes, instead of one blocker per ~10-minute self-hosted
dispatch:

cd tools/fixture-vm
nix eval --raw --no-update-lock-file \
  '.#nixosConfigurations.fixture-vm.config.system.build.qcow2.drvPath'

That now exits 0 and prints a .drv path, warning-free. Blocker 3 was found this
way rather than by a CI round-trip.

The etch hash was cross-checked two-sidedly against the pinned 24.05
fetchgit
, not trusted from the unstable nix-prefetch-git that produced it —
a fetcher-behaviour difference between channels would evaluate fine and then fail
at build time in CI. The correct hash yields a store path; a deliberately wrong
one errors with got: sha256-x37urQw…, re-deriving the same value independently.
A one-sided check that only confirms the happy path proves nothing.

What is still unverified: that the image builds and the guest boots. Those
are x86_64-linux build-time and run-time concerns that only CI can settle, and a
clean local eval says nothing about either. Dispatched as
run 30562393849; I
will report what actually happens rather than merging on a green evaluation.

Also folded in: boot.loader.grub.timeoutboot.loader.timeout, which was
emitting a rename warning on every evaluation. One line, and it keeps the build
log readable so a real warning stands out.

No rivet artifact, deliberately

Matching #353 and #363: workflow/CI-infrastructure changes in this repo carry no
rivet artifacts, and there are no REQ-CI-* ids. Flagged so the omission reads as
a choice.

Meta

Same shape as #361/#363. Neither nightly is a required context, which is why a
job that has never once passed sat red for two months announcing its own fix in
plain text. A gate nobody reads is not a gate.

🤖 Generated with Claude Code

…out (#362)

Trace-Topology Fixture Generation has failed 60 of 60 runs, 2026-06-01
through 2026-07-30, without ever passing. The cause is not a defect —
it is `trace-fixtures.yml:58` doing exactly its job:

    ##[error]tools/fixture-vm/flake.lock is missing.
    ##[error]The nightly build runs only against a committed,
    ##[error]reviewed lock.

The guard is fail-closed by design: the nightly builds ONLY against a
committed lock and never re-resolves inputs itself, so the lock is the
determinism pin for the guest kernel and the whole closure. It has
simply never been satisfied — `tools/fixture-vm/` has held only
`flake.nix` since it landed.

Produced by the documented procedure: dispatch `fixture-vm flake.lock`
(run 30560238740, `success`), download its artifact, commit it here.

The pin resolves `flake.nix:70`'s declared `github:NixOS/nixpkgs/
nixos-24.05` to rev b134951a4c9f3c995fd7be05f3243f8ecd65d798. Verified
against upstream independently of the container that produced it — that
rev exists in NixOS/nixpkgs and is the current tip of nixos-24.05. So
this commit chooses nothing; it records what the flake already declares.

That tip is dated 2024-12-30. nixos-24.05 stopped moving 19 months ago
and is EOL. Bumping to a supported channel is a real decision and is
deliberately NOT taken here: it would change the guest kernel, and the
guest kernel is what the generated trace fixtures are a function of.
Raised for human judgement rather than folded into a red-to-green fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Verification result — I said I'd dispatch and report what actually happens

I did, and the prediction in the PR body ("it is entirely possible this reveals a
second failure behind the first") came true.

Run 30560720500 on
this branch — conclusion: failure.

The good news is real progress. The fail-closed guard at
trace-fixtures.yml:58 passed for the first time in 60 runs. nix started,
fetched nixpkgs from cache.nixos.org, and got 15 seconds further than any
previous run has ever reached. Then:

error: flake 'git+file:///spar?dir=tools/fixture-vm&shallow=1' does not provide attribute
  'packages.x86_64-linux.nixosConfigurations.fixture-vm.config.system.build.qcow2',
  'legacyPackages.x86_64-linux.nixosConfigurations.fixture-vm.config.system.build.qcow2' or
  'nixosConfigurations.fixture-vm.config.system.build.qcow2'

flake.nix never defines system.build.qcow2. Every mention of it in the
file is a comment — including the header that documents the exact build command
the workflow runs. modules = [ … ] holds exactly one module, and it defines no
system.build.*.

Filed with a full diagnosis and a patch sketch checked against the pinned
nixpkgs rev: #365.

What I changed here as a result

Closes #362Addresses #362. Merging this will not make the nightly
green
, and a PR that auto-closes the issue would have recorded a fix that
didn't happen. That is the precise failure this repo keeps finding: an operation
that produced "nothing happened" rendering identically to "it worked."

Why I am still proposing to merge it

Three reasons, and I'd rather they be judged explicitly than assumed:

  1. It is correct on its own terms. The lock is the deterministic resolution of
    what flake.nix:70 already declares, verified against upstream independently
    of the container that produced it.
  2. It is what made fixture-vm flake.nix never defines system.build.qcow2 — the attribute the nightly builds exists only in comments #365 findable. Two blockers were stacked and the outer one
    is fail-closed, so all 60 recorded failures died at the guard and the second
    defect had literally never been reached. Removing blocker 1 is what converts an
    opaque red into a specific one.
  3. Reverting costs more than keeping. Without the lock, the next failure is
    the missing-file error again, and fixture-vm flake.nix never defines system.build.qcow2 — the attribute the nightly builds exists only in comments #365 becomes unverifiable.

The alternative — hold this until #365 is also fixed — bundles a reviewed
supply-chain pin with an untested Nix module change into one PR. That is the
bundling #365's scope note argues against.

Standing caveat, unchanged

Beyond flake evaluation lie the guest build, the /dev/kvm boot and fixture
generation. None of those has ever executed. I expect more findings behind
#365 and will not describe this pipeline as working until a run reaches the end.

🤖 Generated with Claude Code

#365)

The nightly builds
`.#nixosConfigurations.fixture-vm.config.system.build.qcow2`, but nothing
ever defined that attribute. It existed only in this file's header comment
and in the workflow's build command. `system.build` is a freeform
`lazyAttrsOf raw`, so a missing member is not a typo the module system can
catch — it surfaces as `error: attribute 'qcow2' missing` at evaluation,
before a single byte is built.

That was invisible until #364 committed the flake.lock. The workflow's
"Require committed flake.lock" step is fail-closed, so all 60 prior runs
died there and nix was never invoked at all. Clearing the outer blocker
exposed this inner one for the first time in the workflow's life.

Defining it exposed a third blocker: `etch` (pulseengine/rivet, pinned in
root Cargo.toml) is the workspace's one git dependency, and git deps carry
no checksum in Cargo.lock, so importCargoLock cannot vendor them without an
explicit hash. It is vendored even though gen-fixtures never uses it —
importCargoLock takes the whole lock file, not the subtree that
`cargoBuildFlags` selects.

Verified locally, not merely reasoned about. `nix build` needs
x86_64-linux, but all three failures are *evaluation* errors and evaluation
is platform-independent, so the whole config can be forced on darwin:

    cd tools/fixture-vm
    nix eval --raw --no-update-lock-file \
      '.#nixosConfigurations.fixture-vm.config.system.build.qcow2.drvPath'

now exits 0 and prints a .drv path, warning-free. The etch hash was
cross-checked two-sidedly against the *pinned* 24.05 fetchgit rather than
trusted from the unstable prefetcher that produced it: the correct hash
yields a store path, and a deliberately wrong one errors with
`got: sha256-x37urQw...`, re-deriving the same value independently.

This does NOT prove the image builds or that the guest boots. Those are
x86_64-linux build-time and run-time concerns that only CI can settle.

Also drops a rename warning by moving boot.loader.grub.timeout to the
bootloader-agnostic boot.loader.timeout, so the build log reads clean and a
real warning would stand out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe avrabe changed the title ci(fixture-vm): commit the flake.lock the nightly refuses to run without (#362) ci(fixture-vm): make the nightly actually run — lock, qcow2 attribute, git-dep hash (#362, #365) Jul 30, 2026
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch result: it failed. Blocker #4, and it invalidates a claim in this PR.

Run 30562393849
failure. Reporting it as promised, and correcting myself: the line above that
says this PR "now carries every fix needed for the nightly to actually run" is
false. It carries the fixes needed for the nightly to get as far as
building
, which is where it now dies.

building '/nix/store/2bcww30v01dqicgh51s80mwps07y0xbd-etch-0.2.0.drv'...
error: builder for '…etch-0.2.0.drv' failed with exit code 101;
  > failed to load manifest for workspace member `…/rivet-4c06709/rivet-core`
  > Caused by:
  >   feature `edition2024` is required
  >   The package requires the Cargo feature called `edition2024`, but that
  >   feature is not stabilized in this version of Cargo (1.77.1).

The diagnosis, and what it is not

This is not an etch problem, and dropping the dependency would not help.
etch is simply the first derivation cargo reached. Every one of spar's 23
crates inherits edition = "2024" from [workspace.package], so gen-fixtures
itself fails identically a few derivations later.

The real fault is one line at the top of flake.nix:

nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

Measured, not assumed — rustc.version at each channel:

channel rustc edition 2024 (needs ≥ 1.85)
nixos-24.05 (current pin) 1.77.2
nixos-24.11 1.82.0
nixos-25.05 1.86.0

So the flake has been pinned to a toolchain that cannot compile this workspace
at all
, for as long as spar has been on edition 2024. Not a regression — this
build has never been possible. Consistent with a workflow that has never once
succeeded.

Why my local eval did not catch it, which is the point

It couldn't, and I said so before dispatching:

What is still unverified: that the image builds and the guest boots.
Those are x86_64-linux build-time and run-time concerns that only CI can
settle, and a clean local eval says nothing about either.

That boundary held exactly. Evaluation is platform-independent, so blockers 1–3
(all evaluation errors) were findable on a laptop. Blocker 4 is a compile
error inside a derivation — on the far side of the line, reachable only by
actually building. The local oracle did its job and stopped precisely where I
said it would stop.

The useful generalisation: a cheap oracle is worth having only if you state
what it does not cover.
Had I written "verified locally" without the caveat,
this failure would read as the local check being wrong. It wasn't wrong; it was
scoped.

Running count

# Blocker Class Found by
1 flake.lock absent; guard fail-closed workflow 60 red runs
2 system.build.qcow2 never defined evaluation first run to reach nix
3 cargoLock.outputHashes missing for etch evaluation local eval
4 nixpkgs pin's Rust predates edition 2024 build this dispatch

Four deep, each invisible behind the last. Pushing the pin bump next; the
etch outputHashes value must be re-verified against 25.05's fetchgit
rather than carried over, since a fetcher-behaviour change between channels
would evaluate fine and fail at build time — the same trap, one layer down.

I am not flipping "Addresses" to "Closes" and not merging until a dispatch is
actually green.

@github-actions

Copy link
Copy Markdown

Rivet verification gate

20/20 passed

count
Passed 20
Failed 0
Skipped (no steps) 0

Filter: (and (= type "feature") (or (has-tag "v093") (has-tag "v0100")))

Failed artifacts

(none)

Updated automatically by tools/post_verification_comment.py. Source of truth: artifacts/verification.yaml.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.41667% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/spar-trace-topology/src/fixtures/netns.rs 88.63% 5 Missing ⚠️
crates/spar-trace-topology/src/bin/gen-fixtures.rs 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…on 2024

Dispatch 30562393849 failed at build time, four blockers deep:

    building '...etch-0.2.0.drv'...
    error: builder failed with exit code 101
      > feature `edition2024` is required
      > ...not stabilized in this version of Cargo (1.77.1).

This is not an `etch` problem and dropping the dependency would not help.
`etch` is simply the first derivation cargo reached; all 23 spar crates
inherit `edition = "2024"` from [workspace.package], so gen-fixtures itself
fails identically a few derivations later.

The fault is the nixpkgs pin, which was doing two unrelated jobs: it defines
the guest OS *and* supplies rustPlatform. Nothing in the flake signalled the
second role, so "pin a stable channel for a reproducible guest" silently also
meant "pin a compiler from December 2024". Measured rustc.version per channel:

    nixos-24.05 -> 1.77.2   cannot compile this workspace
    nixos-24.11 -> 1.82.0   cannot compile this workspace
    nixos-25.05 -> 1.86.0   ok (edition 2024 stabilised in 1.85)

So this build was never possible, which is consistent with a workflow that has
never once succeeded.

Verified against the instantiated derivation rather than inferred from
pkgs.cargo: the gen-fixtures drv now takes cargo-1.86.0.drv and
rustc-wrapper-1.86.0.drv as direct inputs. Evaluation of the qcow2 drvPath is
clean and warning-free at the new pin.

The etch outputHashes value was re-derived under 25.05 rather than carried
over, because fetchgit's defaults could differ between channels and that
failure appears only at build time. Two-sided: 25.05's fetcher independently
reports got: sha256-x37urQw97R/ARqvlVpXpp3tJqbvztbOiUyAGNZItlA0=, the same
value. Note the positive side alone proves nothing here — a fixed-output
derivation short-circuits when the path is already in the store, so only the
deliberately-wrong-hash run actually exercises the fetcher.

Still unproven locally, and only CI can settle it: that the image builds and
that the guest boots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e edition floor

Run 30563612749 got further than any previous fixture-vm run and then died
inside the derivation:

    error: rustc 1.86.0 is not supported by the following package:
      smol_str@0.3.6 requires rustc 1.89

This falsifies a claim I wrote into 1ea73b1. That commit moved the pin
24.05 -> 25.05 and recorded, in the flake itself:

    nixos-25.05 -> 1.86.0   [OK] cannot compile -> can compile this workspace

That was wrong, and wrong in an instructive way. Edition 2024 requiring
>= 1.85 is a NECESSARY condition that I mistook for THE condition. The
probe genuinely ran and genuinely returned 1.86.0, so the check felt like
verification while silently scoping the claim to the one variable I had
thought of.

The actual constraint is

    max(rust-version) over the entire RESOLVED DEPENDENCY CLOSURE

which is an emergent property of Cargo.lock, not of this repo: the
workspace declares no `rust-version` and carries no rust-toolchain.toml,
so nothing states the floor and nothing pins it. Derived mechanically
rather than guessed a second time -- 152 of 235 packages declare one; the
max is 1.89 (smol_str 0.3.6), then 1.87.0 (wasip2, wit-bindgen):

    cargo metadata --format-version 1 --locked \
      | jq -r '.packages[] | select(.rust_version) | .rust_version' \
      | sort -V | tail -1

nixos-25.11 ships rustc 1.91.1 and clears both floors. Confirmed against
the INSTANTIATED derivation, not against `pkgs.rustc`: the
gen-fixtures-0.10.0.drv reached by the disk image now references
cargo-1.91.1.drv and rustc-wrapper-1.91.1.drv.

The etch outputHash was re-derived under the new channel rather than
assumed to survive it, since a fetchgit-defaults change would evaluate
fine and fail only at build time. Checked from the side that cannot pass
vacuously -- a deliberately wrong hash forces a real fetch and reports
`got: sha256-x37urQw97R/ARqvlVpXpp3tJqbvztbOiUyAGNZItlA0=`, identical to
the pinned value. (The happy path proves nothing here: a fixed-output
derivation short-circuits when the store path already exists.)

The comment now carries the derivation command instead of a number to
trust, and records how the earlier check failed. Note the floor MOVES ON
EVERY `cargo update` -- a dependency bump can raise it with no change to
this repo, and nothing gates that today; it surfaces only as a red
nightly here.

This still does NOT prove the image builds or that the guest boots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch result: failed. Blocker #5, and it falsifies a claim I put in this branch.

Reporting the actual outcome as committed, not merging on a green evaluation.

Run 30563612749
failed at Build guest + generate fixtures. It got further than any of the
64 runs before it — nix evaluated, the whole NixOS closure built, the Rust
dependency tree built — and then died in gen-fixtures itself:

error: rustc 1.86.0 is not supported by the following package:
  smol_str@0.3.6 requires rustc 1.89

This makes a sentence in 1ea73b1 false

That commit moved the pin 24.05 → 25.05 and wrote into flake.nix:

    nixos-25.05 → 1.86.0   ✓

with defined two lines above as "can compile this workspace". It cannot.
I am flagging that explicitly rather than quietly correcting it, because the way
it went wrong is worth more than the fix.

Edition 2024 requiring ≥ 1.85 is a necessary condition that I mistook for
the condition. The probe genuinely ran and genuinely returned 1.86.0 — so
the check felt like verification, while silently scoping the claim to the one
variable I had happened to think of. A one-variable check that comes back true
is the most convincing way to not verify something.

The real constraint is

max(rust-version) over the entire RESOLVED DEPENDENCY CLOSURE

which is an emergent property of Cargo.lock, not of this repo. The
workspace declares no rust-version and carries no rust-toolchain.toml, so
nothing here states that floor and nothing here pins it. Derived mechanically
this time instead of guessed a second time — 152 of 235 packages declare one:

cargo metadata --format-version 1 --locked \
  | jq -r '.packages[] | select(.rust_version) | .rust_version' \
  | sort -V | tail -1
# 1.89   (smol_str 0.3.6; next is 1.87.0 — wasip2, wit-bindgen)
channel rustc verdict
nixos-24.05 1.77.2 ✗ edition 2024 not stabilised
nixos-24.11 1.82.0 ✗ likewise
nixos-25.05 1.86.0 ✗ clears the edition, fails the closure
nixos-25.11 1.91.1 ✓ clears both

Pushed as aef79c2: pin → nixos-25.11.

Verified against the instantiated derivation, not pkgs.rustc

Same discipline as blocker #4 — the question is what the toolchain reaching
gen-fixtures
is, and pkgs.rustc is not that. The gen-fixtures-0.10.0.drv
reachable from the disk image now references cargo-1.91.1.drv and
rustc-wrapper-1.91.1.drv.

The etch outputHash was re-derived under the new channel rather than
assumed to survive it — a fetchgit-defaults change between channels evaluates
fine and fails only at build time. Checked from the side that cannot pass
vacuously
: a deliberately wrong hash forces a real fetch and reports

got:    sha256-x37urQw97R/ARqvlVpXpp3tJqbvztbOiUyAGNZItlA0=

identical to the pinned value. The happy path proves nothing here — a
fixed-output derivation short-circuits when the store path already exists, so
"it built" would have been true whether or not the hash was still correct.

The blocker ladder so far

# Blocker Surfaced
1 flake.lock absent; the guard is fail-closed 60/60 runs, never got past it
2 system.build.qcow2 never defined run 30560720500, first run to reach nix
3 cargoLock.outputHashes missing for the etch git dep local eval
4 pinned channel's cargo predates edition 2024 run 30562393849
5 closure MSRV 1.89 > channel's rustc 1.86 run 30563612749

Each was invisible behind the one before it. Five deep is not bad luck — it is
what a fail-closed outer guard does to a pipeline nobody has ever run to
completion. Every one of these has been sitting there since the workflow was
written; none of them is a regression.

Known-unfixed, stated so it is not mistaken for coverage

The closure floor moves on every cargo update. A dependency bump can raise
it with no change to this repo and no change to this flake, and nothing gates
that
— it surfaces only as a red nightly here, at build time, which is exactly
how blocker #5 got in. The comment in flake.nix therefore carries the
derivation command rather than a number to trust. A real fix is either a
declared rust-version in [workspace.package] or a CI check comparing the two;
both are out of scope for this PR and I would rather name the gap than imply it
is closed.

Re-dispatched as run 30564693866.
Still Addresses, not Closes, and still not merging until a dispatch is
actually green.

🤖 Generated with Claude Code

Blockers #6 and #7 in the same build script, five levels below anything this
image is for:

  spar-trace-topology -> spar-network -> good_lp -> highs -> highs-sys

Run 30564693866 got further than any of the previous 60: the 25.11 pin cleared
the closure MSRV, every vendored crate compiled, and the build died inside
highs-sys' build script with

    is `cmake` not installed?

highs-sys compiles the HiGHS C++ LP solver from vendored source. gen-fixtures
never calls a solver — but `--bin gen-fixtures` selects what gets LINKED, not
what gets COMPILED, and cargo builds the dependency graph of the whole
`-p spar-trace-topology` package. Same shape as the etch vendoring already
documented here: the lock file decides what must build, not the binary.

`dontUseCmakeConfigure = true` is load-bearing rather than defensive. Adding
cmake to nativeBuildInputs makes nixpkgs' setup-hook install cmakeConfigurePhase
as this package's configurePhase — it guards on exactly this variable at
setup-hook.sh:145, checked in the pinned tree, not from memory — and it would
then try to cmake a Rust workspace root that has no CMakeLists.txt.

BLOCKER #7 WAS PREDICTED, NOT DISCOVERED
----------------------------------------
Rather than dispatch and wait for the next red run, I read the graph. highs-sys
also build-depends on bindgen 0.71, which reaches libclang via clang-sys ->
libloading, i.e. it dlopen()s libclang.so while the build script runs. Nothing
in the sandbox provides it, so "Unable to find libclang" was queued up to
surface the moment the cmake fix cleared the way.

`rustPlatform.bindgenHook` exports LIBCLANG_PATH and BINDGEN_EXTRA_CLANG_ARGS
and nothing else here sets them. Verified present at the pinned rev.

This is the pattern that made this workflow cost 60 red runs to diagnose: each
blocker was invisible behind the one before it, so every dispatch bought exactly
one bit of information. clang-sys and highs-sys are the only two -sys crates in
the graph, so this should close the native-dependency species.

WHAT IS STILL UNVERIFIED
------------------------
Evaluation is clean and platform-independent, and it says nothing about whether
the image builds or the guest boots. Both are x86_64-linux concerns only CI can
settle. "The graph says the dependency exists" is not "the fix is sufficient" —
a clean eval has been wrong about this build three times now.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch result: further than ever, still red — blockers #6 and #7

Reporting run 30564693866
as committed. It failed, and it is the most informative failure this
workflow has produced.

The 25.11 pin worked. Cargo accepted the closure (no MSRV complaint), every
vendored crate compiled, and the build reached gen-fixtures-0.10.0.drv itself
before dying:

error: builder for '/nix/store/…-gen-fixtures-0.10.0.drv' failed with exit code 101
  >   failed to execute command: No such file or directory (os error 2)
  >   is `cmake` not installed?

Blocker #6. highs-sys compiles the HiGHS C++ LP solver from vendored
source via the cmake crate, and nativeBuildInputs had only pkg-config. It
sits five levels below anything this image is for:

spar-trace-topology → spar-network → good_lp → highs → highs-sys

Note gen-fixtures never calls a solver. --bin gen-fixtures selects what gets
linked, not what gets compiled — cargo builds the dependency graph of the
whole -p spar-trace-topology package. Exactly the same shape as the etch
vendoring already documented in this flake: the lock file decides what must
build, not the binary.

dontUseCmakeConfigure = true goes with it and is load-bearing, not defensive.
Putting cmake in nativeBuildInputs makes nixpkgs' setup-hook install
cmakeConfigurePhase as this package's configurePhase — it guards on exactly
that variable at setup-hook.sh:145, which I checked in the pinned tree rather
than recalling — and it would then try to cmake a Rust workspace root that has
no CMakeLists.txt.

Blocker #7 was predicted, not discovered

The thing that has made this PR expensive is that each blocker was invisible
behind the one before it
, so every dispatch bought exactly one bit of
information. Six dispatches, six bits. So instead of pushing the cmake fix and
waiting, I read the dependency graph looking for the next one:

$ cargo tree -p spar-trace-topology -i clang-sys
clang-sys v1.8.1
└── bindgen v0.71.1
    [build-dependencies]
    └── highs-sys v1.12.1

highs-sys also build-depends on bindgen, which reaches libclang through
clang-sys → libloading — it dlopen()s libclang.so while the build script
runs. Nothing in the sandbox provides that, so Unable to find libclang was
queued up to surface the moment the cmake fix cleared the way, costing another
~10-minute round trip to learn one more bit.

rustPlatform.bindgenHook exports LIBCLANG_PATH and BINDGEN_EXTRA_CLANG_ARGS
and nothing else here sets them. Both it and the cmake hook's guard variable
were confirmed present in the pinned nixpkgs source tree, not assumed from
nixpkgs conventions.

clang-sys and highs-sys are the only two -sys crates in the graph, so this
should close the native-dependency species. "Should" is doing real work in that
sentence.

Running tally

# Blocker How it was found
1 flake.lock absent, fail-closed guard 60/60 runs
2 system.build.qcow2 never defined first run to reach nix
3 cargoLock.outputHashes missing for etch local eval
4 nixpkgs rustc predates edition 2024 local eval
5 closure MSRV 1.89 > 25.05's rustc 1.86 CI dispatch — falsified my own claim in this PR
6 cmake missing for highs-sys CI dispatch
7 libclang missing for highs-sys' bindgen read the graph, before dispatching

Still unverified

Evaluation is clean, and evaluation is platform-independent — it says nothing
about whether the image builds or the guest boots. A clean eval has now been
wrong about this build three times. Dispatched as
run 30565973744.

Unchanged commitment: still Addresses, not Closes, and still not
merging until a dispatch is actually green.

The Rust half now builds (blockers #6/#7 closed — gen-fixtures-0.10.0
compiles). Run 30565973744 got all the way to the disk image and died on:

  error: a 'x86_64-linux' with features {kvm} is required to build
  '...-nixos-disk-image.drv', but I am a 'x86_64-linux' with features
  {benchmark, big-parallel, nixos-test, uid-range}

That set is Nix's default minus `kvm`. Nix adds `kvm` only when
access("/dev/kvm", R_OK|W_OK) succeeds, and make-disk-image.nix builds
through vmTools.runInLinuxVM, whose derivation requires the feature.

`--device /dev/kvm` was already passed, and the runner user is in the
`kvm` group on the host — but /dev/kvm is root:kvm 0660 and rootless
podman drops supplementary groups by default, so the in-container
process is in no such group. The device is present and unopenable.
`--group-add keep-groups` carries them through.

Also add a precondition step that asks Nix for its computed
system-features before anything is built. The existing diagnostic was
correct and precise; it was just emitted after ~5 minutes of compiling
and buried under ~700 `building '/nix/store/...drv'` lines. Same fact,
two seconds in, at the top of the log.

The probe asserts on Nix's own feature set rather than a shell
`[ -w /dev/kvm ]` test — the feature set is what the build is matched
against, so a probe of anything else could green while the build fails.

The image digest moves to workflow `env:` so the probe and the build it
guards cannot drift onto different images.

Refs #362, #365.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch result: the Rust half is done — blocker #8 is the disk image

Run 30565973744
failed, as committed. Blockers #6 and #7 are closed: gen-fixtures-0.10.0.drv
built successfully and took 3m22s doing it, so cmake + libclang were the whole
of that problem. The Rust closure now compiles end-to-end inside the image.

It died one layer further out, in the disk image itself:

error: a 'x86_64-linux' with features {kvm} is required to build
'/nix/store/…-nixos-disk-image.drv', but I am a 'x86_64-linux' with features
{benchmark, big-parallel, nixos-test, uid-range}

Blocker #8. That feature set is Nix's default minus kvm. Nix adds kvm
only when access("/dev/kvm", R_OK|W_OK) succeeds (settings.cc,
getDefaultSystemFeatures), and make-disk-image.nix builds through
vmTools.runInLinuxVM, whose derivation carries
requiredSystemFeatures = [ "kvm" ].

The device was already being passed. --device /dev/kvm was in the podman
invocation from the start, and the fleet comment is accurate — the runner user
is in the kvm group on the host. The gap is that rootless podman drops the
caller's supplementary groups by default
. /dev/kvm is crw-rw---- root:kvm,
so inside the user namespace the process belongs to no group that can open it.
The node was present and unopenable — which is why --device looked sufficient
and wasn't. --group-add keep-groups carries them through.

The diagnostic existed; nobody could reach it

Nix's error is correct, precise, and names the exact missing feature. It was
emitted after ~5 minutes of compiling, as line ~700, under a solid wall of
building '/nix/store/…drv'. I only found it by tailing --log-failed.

So this commit also adds a precondition step that asks Nix for its computed
system-features before anything is built — same fact, two seconds in, at
the top of the log. It asserts on Nix's own feature set rather than a shell
[ -w /dev/kvm ] test, because the feature set is what the build is actually
matched against; a probe of anything else could green while the build still
fails. The image digest moves to workflow env: so the probe and the build it
guards cannot drift onto different images.

Status of the new dispatch
(30567928227):

Require /dev/kvm usable inside the containersuccess. So
keep-groups works on this runtime and Nix now advertises kvm. The qcow2
build is running as I write this.

Running tally

# blocker closed by
1 nightly refuses to run without a committed flake.lock f18be8d
2 system.build.qcow2 is not a stock NixOS option 1f5ef9a
3 etch git dependency hash 1f5ef9a
4 nixpkgs 24.05's cargo predates edition 2024 1ea73b1
5 closure MSRV is 1.89, above 25.05's rustc 1.86 aef79c2
6 highs-sys needs cmake a33d0d6
7 highs-sys needs libclang a33d0d6
8 rootless podman drops the kvm group 52fafa0

Eight blockers, each invisible until the one before it cleared — the cost of a
job that has never once run to completion. Every fix so far has been real and
none of them were guessable in advance.

Still Addresses, not Closes, and still not merging until a dispatch is
actually green
. Blocker #8 being diagnosed is not blocker #8 being fixed; the
qcow2 step has never completed, and there are four steps after it — boot the
guest, tshark the capture, round-trip all four fixtures — that have never
executed at all. I will report the outcome either way.

Blocker #9. `Locate qcow2` failed on a tree where the image had just been
built successfully, which reads like a naming mismatch and is not one.

`nix build --out-link /spar/tools/fixture-vm/result` wrote the symlink into
the bind-mounted repo, pointing at `/nix/store/…`. That container is `--rm`
with its own store; nothing of it exists on the host after the run. The
symlink survived, its target did not — the build product was unreachable
from the host by construction, so no amount of globbing for a differently
named file would have found it.

The out-link now goes to /tmp and the image is copied across the mount while
the store it lives in is still alive.

`Locate qcow2` also stops trusting `cp`'s exit code. A copy that exits 0 says
a file arrived, not that a bootable NixOS image did; a truncated or
wrong-path copy would have surfaced three steps later, at `Run fixture-vm`,
far from its cause. The step now asserts existence and a >= 64 MiB floor
(a full NixOS qcow2 is hundreds of MB) and names what went wrong.

Neither `nixos.qcow2` nor `result` was ever gitignored. That stayed harmless
only because the build had never once got far enough to produce them.

Evidence blocker #8 is closed, from the probe's own output in run
30567928227 rather than from the step's exit code:

    container process: uid=0(root) gid=0(root) groups=0(root),65534(nobody)
    crw-rw---- 1 nobody nobody 10, 232 Jul 28 04:16 /dev/kvm
    nix system-features = benchmark big-parallel kvm nixos-test uid-range
    ok: nix advertises kvm; nixos-disk-image.drv is buildable here

The host `kvm` group arrives in the userns as 65534, matching the device's
mapped group — which is exactly what `--group-add keep-groups` carries and
what its absence dropped. `building '…-nixos-disk-image.drv'` then ran to
completion in 13 minutes.

Refs #362

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Blocker #8 is closed — measured, not inferred. Blocker #9 is open.

Reporting the outcome as committed. Run
30567928227:
Build NixOS fixture-vm qcow2 succeeded — 13 minutes, ending at
building '/nix/store/b6lxg7a95bbsz5x66g2vpx9isbfq7w9y-nixos-disk-image.drv'.
That is the first time this workflow has ever produced a disk image.

I said I would read the probe's log rather than infer "keep-groups worked" from
"the step exited 0". Its own output:

container process: uid=0(root) gid=0(root) groups=0(root),65534(nobody)
crw-rw---- 1 nobody nobody 10, 232 Jul 28 04:16 /dev/kvm
nix system-features = benchmark big-parallel kvm nixos-test uid-range
ok: nix advertises kvm; nixos-disk-image.drv is buildable here

The host kvm group arrives inside the user namespace as 65534, which is also
what /dev/kvm's group maps to there — so the process can open it, and Nix's own
getDefaultSystemFeatures() access(/dev/kvm, R_OK|W_OK) probe consequently puts
kvm in the feature set. That supplementary group is precisely what rootless
podman drops and what --group-add keep-groups carries through.

Blocker #9Locate qcow2

The next step failed, and it looks like a naming mismatch. It is not one.

nix build … --out-link /spar/tools/fixture-vm/result

wrote the out-link into the bind-mounted repo, pointing at /nix/store/….
The container is --rm and carries its own store; after the run the symlink
survives on the host and its target does not. The build product was unreachable
from the host by construction — globbing for a differently-named file would
have found nothing, because the problem was never the name.

Fixed in dccc117: the out-link goes to /tmp and the image is copied across the
mount while the store it lives in is still alive.

Locate qcow2 also stops trusting cp's exit code. A copy that exits 0 says a
file arrived, not that a bootable NixOS image did — a truncated or wrong-path
copy would have surfaced three steps later at Run fixture-vm, far from its
cause. It now asserts existence plus a ≥ 64 MiB floor and names what went wrong.

Also: neither nixos.qcow2 nor result was ever gitignored. That stayed
harmless only because the build had never once got far enough to produce them.

Tally

# blocker closed by
1 nightly refuses to run without a committed flake.lock f18be8d
2 system.build.qcow2 is not a stock NixOS option 1f5ef9a
3 etch git dependency hash 1f5ef9a
4 nixpkgs 24.05's cargo predates edition 2024 1ea73b1
5 closure MSRV is 1.89, above 25.05's rustc 1.86 aef79c2
6 highs-sys needs cmake a33d0d6
7 highs-sys needs libclang a33d0d6
8 rootless podman drops the kvm group 52fafa0verified green
9 the qcow2 never leaves the container's store dccc117

Dispatched 30569595760.

Still Addresses, not Closes, and still not merging until a dispatch is
actually green
. Four steps have still never executed: boot the guest, tshark
the capture, round-trip all four fixtures, upload. Each is a fresh opportunity
for blocker #10. I will report the outcome either way.

Run 30569595760 reached `Run fixture-vm` — a step that had never executed
before — printed SeaBIOS, iPXE and "Welcome to GRUB!", then produced
nothing at all for 900 seconds and hit the harness timeout.

That silence is not the guest hanging at GRUB. It is the point where we
went blind.

The host side was never wrong: the harness runs `-nographic -serial
mon:stdio` and inherits stdout. What survived it was only *firmware*
output. Under `-nographic` SeaBIOS redirects the BIOS text console onto
the serial port, and GRUB draws through BIOS calls, so GRUB rode that
redirect for free. Linux does not use BIOS calls — at handoff it switches
to whatever `console=` names, and the default is `tty0`, the VGA
framebuffer `-nographic` has disconnected. The kernel, systemd, and
gen-fixtures have all been logging into a void.

flake.nix set boot.kernelPackages, boot.kernelModules and
boot.loader.grub, but never boot.kernelParams. Adding it:

  boot.kernelParams = [ "console=tty0" "console=ttyS0,115200" ];

Order is load-bearing. Every `console=` receives kernel messages, but the
LAST one becomes /dev/console — which is what init and the already-enabled
`serial-getty@ttyS0` attach to. ttyS0 must come last or the getty lands on
the framebuffer again.

This does not by itself prove the guest completes. It makes the next
failure legible instead of a 900-second blank, which is the difference
between debugging and guessing — the same shape as every other blocker in
this series: an operation that produced "nothing happened" rendered
identically to one that worked.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Reporting the outcome, as committed: the dispatch was not green.

Run 30569595760
failed. Honest summary first, diagnosis after.

What passed — including new ground

# step result
3 Require committed flake.lock
4 Require /dev/kvm usable inside the container crw-rw---- 10, 232
5 Build NixOS fixture-vm qcow2 ✅ (~14 min)
6 Locate qcow2
9 Build fixture-vm harness
11 Run fixture-vm (boot NixOS guest) failed after 900s
12–14 tshark / round-trip / upload ⏭ skipped

The qcow2 byte count I said I'd read back once logs were servable:

fixture-vm: qcow2 = …/tools/fixture-vm/nixos.qcow2 (1995309056 bytes)
-rw-r--r-- 1 runner9 runners 1.9G Jul 30 20:31 …/nixos.qcow2

1,995,309,056 bytes — 1.9 GiB, about 30× the 64 MiB floor that step
enforces. The image is a real NixOS system, not a truncated copy. Blocker #9
(the container-store copy) stays closed.

Step 11 is a step that had never executed in this workflow's history. So
the guest genuinely booted for the first time — and then this:

18:34:57  fixture-vm: booting QEMU guest …
18:34:58  SeaBIOS (version 1.16.3-debian-1.16.3-2)
18:34:58  iPXE (https://ipxe.org) 00:03.0 …
18:34:58  Booting from Hard Disk...
18:34:58  GRUB loading.
18:34:58  Welcome to GRUB!
          ← 900 seconds of nothing —
18:49:57  fixture-vm: error: QEMU guest did not shut down within 900 seconds; killed

The silence is not the guest hanging at GRUB — it is where we go blind

The obvious read is "the guest wedged right after GRUB." I don't think that's
what happened, and the reason is specific.

The host side is correct and was never the problem: the harness runs
-nographic -serial mon:stdio with Stdio::inherit()
(crates/spar-trace-topology/src/bin/fixture-vm.rs:198). What survived it is
only firmware output. Under -nographic, SeaBIOS redirects the BIOS text
console
onto the serial port; GRUB draws through BIOS calls, so GRUB rode that
redirect for free.

Linux does not use BIOS calls. At handoff it switches to whatever console=
names — and the default is tty0, the VGA framebuffer -nographic has
disconnected. tools/fixture-vm/flake.nix sets boot.kernelPackages,
boot.kernelModules and boot.loader.grub, but never boot.kernelParams.
So from kernel handoff onward the kernel, systemd, and gen-fixtures have all
been logging into a void — and serial-getty@ttyS0, which the flake does
enable, attaches to a /dev/console that isn't the serial port either.

"Welcome to GRUB!" is not where the guest stopped. It is the last line we were
able to see.

The fix (8f581aa)

boot.kernelParams = [ "console=tty0" "console=ttyS0,115200" ];

Order is load-bearing: every console= receives kernel messages, but the
last one becomes /dev/console, which is what init and the getty attach to.
ttyS0 must come last or the getty lands back on the framebuffer.

What this does and does not claim

It does not claim the guest now completes. I have no evidence for that and
won't assert it. It makes the next failure legible instead of a 900-second
blank — which is the difference between debugging and guessing.

That is the same shape as every other blocker in this series, and worth naming
because it keeps recurring: an operation that produced "nothing happened"
rendered identically to one that worked.
#365 was an attribute that existed
only in prose; #9 was a cp that exited 0 into a store that no longer existed;
this is a console that was configured on the host and never on the guest.

Still not merging until a dispatch is actually green. Re-dispatching on 8f581aa
now, and I'll report that outcome either way too.

@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch outcome on 8f581aa: failed. Run 30572572863 — reporting as promised, still not merging.

It failed earlier than the commit was written for. 8f581aa addressed step 11 (Run fixture-vm, the 900-second blind boot); this run died at step 5, Build NixOS fixture-vm qcow2. So the console fix was never exercised — it is neither confirmed nor refuted by this run.

What actually happened

nixos-disk-image.drv exited 134 (SIGABRT). Two lines carry it:

qemu-system-x86_64: Could not access KVM kernel module: Permission denied
qemu-system-x86_64: failed to initialize kvm: Permission denied
qemu-system-x86_64: falling back to tcg
...
qemu-system-x86_64: ../system/physmem.c:758: iotlb_to_section:
    Assertion `section_index < d->map.sections_nb' failed.

make-disk-image.nix builds the qcow2 by booting a transient VM. That VM could not open /dev/kvm, fell back to software emulation, and TCG then aborted on a QEMU assertion at -smp cpus=32. The abort is a consequence; the EPERM is the cause.

Why the KVM precondition passed anyway

This is the part worth writing down, because the precondition step was added specifically to stop this class of failure, and it reported green two minutes earlier:

container process: uid=0(root) gid=0(root) groups=0(root),65534(nobody)
crw-rw---- 1 nobody nobody 10, 232 /dev/kvm
nix system-features = benchmark big-parallel kvm nixos-test uid-range

Both readings are correct. /dev/kvm is mode 0660, group = the host kvm gid, which is unmapped in the rootless userns and so renders as 65534/nobody. The container process is uid 0 with supplementary group 65534--group-add keep-groups worked. So access("/dev/kvm", R_OK|W_OK) succeeds, Nix adds kvm to system-features, and the derivation is legally schedulable here. All true.

The build still cannot open the device, because the process that answered the probe is not the process that runs the build. Nix computes system-features in the parent (uid 0, groups 0,65534); it then runs the builder as a nixbld build user, which carries no such supplementary group. Root may open /dev/kvm; nixbld may not.

The step's own comment defends the design:

It asserts on Nix's OWN computed feature set rather than on a shell [ -w /dev/kvm ] test: the feature set is what the build is actually matched against, so a probe of anything else could pass while the build still fails.

That is right about scheduling and blind to execution. system-features governs whether the derivation may be placed on this machine. The failure was in whether its builder may open the device. Different principal, different syscall, different question — and the more rigorous-looking probe asks the one that was not at stake. A [ -w /dev/kvm ] test would have been equally wrong, for the same reason: both run as the wrong user.

Same shape as every other blocker in this series: an operation that produced "nothing happened" — here, "no KVM for you" — rendered identically to one that worked.

What I am changing

  1. Probe by doing the thing. Replace the config read with a real derivation, requiredSystemFeatures = ["kvm"], whose builder is /bin/sh -c 'exec 3<>/dev/kvm'. That runs as the build principal and performs the actual open(O_RDWR). It costs seconds and needs no nixpkgs. It also prints id from inside the builder, which is the line that would have made this obvious immediately instead of 12 minutes into a 700-line build log.
  2. Make the probe container and the build container share one nix.conf. They currently differ — the build appends experimental-features and sandbox = false; the probe appends nothing. This workflow already hoisted NIX_IMAGE_DIGEST into env: on exactly this reasoning ("a probe that greens a different image than the one built proves nothing about the build"); a probe with different Nix settings than the build has the same defect.
  3. The leading fix: run builds as the container root (build-users-group =), so the builder keeps the keep-groups supplementary group. I am labelling this a hypothesis, not a finding — I have confirmed root can open the device and the builder cannot, but I have not yet confirmed the image sets build-users-group = nixbld. The new probe settles it either way, in seconds, and prints nix config show build-users-group alongside.

Note that TCG is not a usable fallback here regardless — it aborts. So KVM has to genuinely work in the builder; failing loudly at the top is the only correct behaviour.

I will report the next dispatch outcome either way, as before.

Run 30572572863 passed the KVM precondition and then failed the build it
guards:

  probe:  nix system-features = benchmark big-parallel kvm nixos-test …
  build:  qemu-system-x86_64: Could not access KVM kernel module:
          Permission denied  ->  falling back to tcg  ->  SIGABRT (134)

Both readings were correct. /dev/kvm is mode 0660 owned by the host's
root:kvm, neither of which is mapped into the rootless userns — so both
render as 65534/nobody, and access is granted purely by the supplementary
group `--group-add keep-groups` carries in. The container's uid 0 has it:

  container process: uid=0(root) gid=0(root) groups=0(root),65534(nobody)
  crw-rw---- 1 nobody nobody 10, 232 /dev/kvm

Nix computes `system-features` in that parent process, so access() there
succeeds and `kvm` is advertised. It then runs the builder as a nixbld
build user, which carries no such group, and QEMU's open(O_RDWR) got
EPERM. Scheduling was never the problem; execution was.

The step's own comment defended reading `system-features` as strictly
better than `[ -w /dev/kvm ]`, "the feature set is what the build is
actually matched against". That is true and it is the wrong question.
`system-features` decides whether the derivation may be PLACED here; the
failure was in whether its builder may OPEN the device. Different
principal, different syscall. `[ -w /dev/kvm ]` would have been wrong the
same way, because it too runs as the parent. Neither probe can speak for
the build user, so the probe now IS a build.

tools/fixture-vm/kvm-probe.nix is a dependency-free derivation shaped like
the thing it stands in for: `requiredSystemFeatures = [ "kvm" ]` so Nix
schedules it through the same path as nixos-disk-image.drv, and a builder
that performs the actual open(3<>/dev/kvm). It prints `id` first — the one
line whose absence made this cost twelve minutes and a 700-line log to
see. Verified by instantiation, not by eye: builder /bin/sh, system
x86_64-linux, requiredSystemFeatures=kvm, script text intact through Nix's
'' quoting.

Two supporting changes:

* NIX_CONF_EXTRA hoists the nix.conf settings into workflow `env:`, shared
  verbatim by probe and build. They had drifted — the build appended
  experimental-features and sandbox=false, the probe appended nothing. This
  workflow already hoisted NIX_IMAGE_DIGEST on exactly this reasoning ("a
  probe that greens a different image than the one built proves nothing");
  differing Nix settings have the same defect.

* `build-users-group =` makes builds run as the container root that
  keep-groups actually granted, rather than dropping to nixbld.

The second is a hypothesis, not a finding: I confirmed root can open the
device and the builder cannot, but not that the image sets
build-users-group=nixbld. The new probe settles it either way in seconds
and prints build-users-group alongside the builder's id, so if the guess is
wrong the next run says so precisely instead of failing blind twelve
minutes in.

TCG is not a usable fallback here — with KVM denied QEMU fell back to
software emulation and aborted on an assertion at -smp 32. There is no
degraded mode to tolerate, which is why this stays a hard gate.

This does not by itself prove the guest completes; it removes the blocker
in front of the boot fix landed in 8f581aa, which this run never reached.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: the build-users-group item is no longer a hypothesis. I said I would settle it, and it settles from the image's own definition rather than from waiting on the run — NixOS/nix@2.24.9 docker.nix:

nixbld${toString n} = { uid = 30000 + n; gid = 30000; groups = [ "nixbld" ]; };

groups = { root.gid = 0; nixbld.gid = 30000; nobody.gid = 65534; };

defaultNixConf = { sandbox = "false"; build-users-group = "nixbld";};

Three things fall out.

1. The diagnosis is confirmed. The image ships build-users-group = "nixbld", so builds drop to uid 30001+. Each nixbld user's supplementary group list is exactly [ "nixbld" ] — gid 30000, never 65534. /dev/kvm in the container is group 65534. So the builder cannot open it, while the parent (uid 0, carrying 65534 via --group-add keep-groups) can. That is the whole failure, and build-users-group = is the right fix.

2. The daemon caveat is refuted. I flagged that if the image ran a nix daemon, appending to /etc/nix/nix.conf after startup would not reach the builder and the fix would be inert. There is no NIX_REMOTE and no daemon in the image — nix runs standalone as root and re-reads /etc/nix/nix.conf on each invocation. The override will take effect.

3. An incidental correction. sandbox = "false" is already the image default, so this workflow's appending of it has been redundant since it was written. Harmless, but the comment above it implies it is doing work it is not. I will fold that into the same follow-up.

It also explains a cosmetic detail from the failing run's log that looked like noise:

crw-rw---- 1 nobody nobody 10, 232 /dev/kvm

nobody there is not a real owner — the host's root and kvm ids are unmapped in the rootless userns, so both fall through to the overflow id 65534, which this image happens to name nobody. Two different unmapped ids printing the same name is exactly the kind of rendering that made "present but unopenable" look like "present and fine".

Run 30575158798 is still queued behind the single podman runner, and I will report it either way. But note what its job now is: it is no longer the experiment that decides the diagnosis — that is decided. It is the confirmation that the fix works end to end, and the first chance the boot fix in 8f581aa gets to run at all.

Still not merging until a dispatch is actually green.

The NIX_CONF_EXTRA comment claimed `sandbox = false` was required for the
build to work. It is not: the nixos/nix image already ships it. Verified
against the image's own definition rather than inferred — NixOS/nix
docker.nix at 2.24.9 (the version this digest resolves to, per the note in
the run step) defines:

    defaultNixConf = {
      sandbox = "false";
      build-users-group = "nixbld";
      ...
    };

So of the two settings we append, `sandbox = false` is a no-op today and
`build-users-group =` is the one doing the work — it overrides the image's
`build-users-group = nixbld`, which is what keeps the supplementary group
`--group-add keep-groups` carries in, and therefore what makes /dev/kvm
reachable at BUILD time.

The setting stays. NIX_IMAGE_DIGEST is a pin we will bump, and a bump must
not be able to silently flip a default this build depends on; stating the
assumption explicitly is the point. Only the claim about it changes.

Also de-duplicates the second copy of the explanation in the run step,
which is where the wrong claim would otherwise survive the fix — it now
points at NIX_CONF_EXTRA as the single source instead of restating it.

No behaviour change: comments only. NIX_CONF_EXTRA parses byte-identically
(experimental-features / sandbox / build-users-group, empty value intact).

Refs #362

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Reporting run 30575158798, as promised — it failed

I said I would report it either way and would not merge until a dispatch is
actually green. It is not green, so this PR stays open.

conclusion: failure. Queued 19:31:02 → started 20:07:16 (36m14s waiting for
runner-9, the only podman host — see #380) → failed 20:16:53.

What the run does establish

step result
3. Require committed flake.lock success
4. Require /dev/kvm openable by a Nix BUILD process success
5. Build NixOS fixture-vm qcow2 failure

Step 4 is the fix in 62610b5, and it did the thing it was built to do:

container process: uid=0(root) gid=0(root) groups=0(root),65534(nobody)
crw-rw---- 1 nobody nobody 10, 232 Jul 30 18:49 /dev/kvm
nix build-users-group = []
nix system-features   = benchmark big-parallel kvm nixos-test uid-range
building '/nix/store/ygpsx197vb02an2dw3ad209w78q9c1wb-kvm-open-probe.drv'...
ok: a Nix BUILD process opened /dev/kvm O_RDWR.

Step 5 then ran for 5m21s and built several hundred derivations — the whole
NixOS unit graph, then the Rust closure through wit-bindgen, wasmparser,
cargo-vendor-dir, and into gen-fixtures-0.10.0.drv. The previous run died at
this step in seconds with Could not access KVM kernel module: Permission denied. So the KVM class of failure is gone, and this is a new wall.

nix build-users-group = [] also empirically confirms what the (still unpushed)
comment commit 56a9b26 asserts: the empty override is the load-bearing line,
not the sandbox = false that the old comment credited.

Why it failed — blocker #12, and it is caused by sandbox = false

error: home directory '/homeless-shelter' exists; please remove it to assure
purity of builds without sandboxing

With the sandbox off, Nix runs every builder with HOME=/homeless-shelter and
refuses to start a build if that path actually exists — an existing home
directory would let builders read state from outside the derivation. Some
builder earlier in the graph created it (a cargo/rustc build phase doing the
usual mkdir -p "$HOME/.cargo" is the standard culprit), and the next
derivation's purity check then refused to run.

Note the coupling: this check only exists when the sandbox is off, which is
the image default we are relying on. It is the direct downstream cost of that
choice, so the fix has to be chosen with that in mind rather than papered over
with an rm -rf in the wrapper — a pre-step removal cannot help when the
directory is created mid-graph.

A defect in the probe itself, found by the same log

kvm-open-probe> sh: line 2: id: command not found
kvm-open-probe> builder principal:

kvm-probe.nix runs echo "builder principal: $(id)" so that, per its own
comment, "it prints id first, so the log names that principal outright."
It printed nothing. Nix clears PATH for builders and this derivation is
deliberately dependency-free, so there is no coreutils and therefore no id.
echo still succeeded, so set -eu did not catch it.

That is exactly the failure this probe was written to fix, one level in: a line
whose whole job was to be evidence rendered "nothing happened" identically to
"it worked". The assertion is unaffected — exec 3<>/dev/kvm succeeded and
the derivation produced its output, so a Nix build process really did open
/dev/kvm O_RDWR — but the identification did not survive its own environment.

Fix is to read the principal with shell built-ins only, and to fail if it comes
back empty rather than print a bare label:

uid=""
while read -r k v; do case "$k" in Uid:) uid="$v" ;; esac; done < /proc/self/status
[ -n "$uid" ] || { echo "cannot identify the builder principal"; exit 1; }
echo "builder principal Uid: $uid"

Next

Both fixes go on this branch and I re-dispatch. Not merging on a red dispatch.

avrabe and others added 3 commits July 30, 2026 22:26
Run 30575158798 got 5m21s into the NixOS graph and then died with

  error: home directory '/homeless-shelter' exists;
  please remove it to assure purity of builds without sandboxing

Nix runs builders with HOME=/homeless-shelter and refuses to START any
build if that path exists, once the sandbox is off — the check sits in
local-derivation-goal.cc behind `!useChroot`, so it is a direct
consequence of the `sandbox = false` this container relies on.

That error names the derivation that TRIPPED over the directory, never
the one that made it. Attribution here is by timing: the error fired
5m21s after gen-fixtures started with no intervening `building '…'`
line, and the check runs at build START, so the directory was created
inside that window. Which tool inside it wrote there is inferred, not
measured (cmake's $HOME/.cmake/packages registry is the usual culprit
and cmake is on this build's PATH for highs-sys). The fix does not rest
on that inference: redirecting HOME covers whichever tool it was.

Two alternatives rejected. `rm -rf /homeless-shelter` in the workflow
cannot work — the directory appears mid-graph, so clearing it before
`nix build` leaves the failing window untouched. Enabling the sandbox
would also fix it and may be the better end state, but it would make
kvm-probe.nix's out-of-store `builder = "/bin/sh"` illegal and its
interaction with nested userns under rootless podman is untested; not
bundling that into a dispatch already carrying two other fixes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The probe exists because the old KVM precondition measured the parent
process instead of the build principal. Its own header claimed "it
prints `id` first, so the log names that principal outright" — and in
run 30575158798 that line printed:

  sh: line 2: id: command not found
  builder principal:

Nix clears PATH for builders and this derivation is deliberately
dependency-free, so there is no coreutils. `echo` still exited 0, so
`set -eu` did not catch it. The assertion itself was fine — the real
`exec 3<>/dev/kvm` succeeded from the real build principal, which is
what the probe is for — but the line whose entire job was to be
EVIDENCE rendered "nothing happened" identically to "it worked". That
is the same defect the probe was written to fix, one level inside it.

Read /proc/self/status with shell built-ins only, and exit non-zero
when the read comes back empty: a probe that cannot name the principal
it tested produces an unattributable result, which is worse than no
result. A diagnostic line is only evidence if its absence fails the
build.

Also records what the Groups: line is actually for. `build-users-group =`
(empty) makes Nix run builders as the invoking user rather than dropping
to a nixbld account, which is precisely why the supplementary group that
`--group-add keep-groups` carries in survives into the build — and
therefore why /dev/kvm is openable at all. Printing Uid/Gid/Groups makes
that mechanism visible instead of inferred.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nix throws the impure-HOME error when a build STARTS, so it names the
derivation that tripped over the directory and never the one that
created it. Working out the creator for run 30575158798 meant reading
timestamps against the build graph. Whatever a builder dropped in there
is a fingerprint of the tool that wrote it — a .cmake/packages entry, a
.cargo, a .npm — so one listing turns that inference into a reading.

It has to happen inside the same `sh -c`: the container is --rm with
its own store, so a later step has nothing left to look at.

The exit status is captured as `nix build … || rc=$?`, not from inside
an `if ! …; then rc=$?` block. In the latter, $? is the status of the
negation — which SUCCEEDED — so rc would be 0 and the failure path
would exit green. Verified by execution rather than by reading: with a
stubbed `nix` exiting 42, the fragment exits 42 with the shelter absent,
exits 42 and prints the tree with it present, and still reaches the
copy when the stub exits 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Dispatch re-run: red. Not merging. Here is what moved and what did not.

Run 30579194737 on b57200b
failure, 20:28:20 → 20:39:31 UTC.

I said on this PR: "Both fixes go on this branch and I re-dispatch. Not merging on
a red dispatch."
It ended up being three fixes, not two, and the dispatch is red.
So: not merging. Correcting my own count while I am here.

What the three fixes actually bought

step result
3 Require committed flake.lock success
4 Require /dev/kvm openable by a Nix BUILD process success
5 Build NixOS fixture-vm qcow2 failure

Each fix is discharged by evidence, not by the run being green:

  • 77e66b3 (keep gen-fixtures out of /homeless-shelter) — worked.
    gen-fixtures-0.10.0.drv started 20:32:25 and finished 20:38:05: five minutes
    forty, completed. In run 30575158798 that same derivation died 5m21s in with
    home directory /homeless-shelter exists. The preConfigure HOME redirect is
    the difference.

  • 4baeb47 (probe the real build principal) — worked. Step 4 is green, and
    green now means an actual open(3<>/dev/kvm) from inside a derivation with
    requiredSystemFeatures = ["kvm"], with the uid read from /proc/self/status
    by shell built-ins and a hard exit 1 if it comes back empty.

  • b57200b (name the culprit before the container evaporates) — worked, and it
    is worth saying how.
    It printed:

    --- nix build exited 1; impure-HOME diagnostic ---
    /homeless-shelter is absent, so this is NOT the impure-
    HOME failure -- read the build log above instead.
    

    That is the negative branch. A diagnostic that only speaks when it is the
    culprit reads identically to a diagnostic that never ran — which is the precise
    bug 4baeb47 fixed one level down, where builder principal: printed empty
    because id did not exist. Ruling a hypothesis out is the output, not the
    silence.

The failure moved roughly six minutes deeper into the build graph. That is the
measurement; it is not a green run.

The new failure is a different one

nixos-disk-image.drv fails. Two virtiofsd instances die identically:

ERROR virtiofsd] can't apply the child capabilities: failed to sync capabilities with the kernel

and then, inevitably:

qemu-system-x86_64: -chardev socket,id=store,path=virtio-store.sock: Failed to connect to 'virtio-store.sock': Connection refused

The socket refuses because nothing ever bound it. virtiofsd is what serves the
Nix store into the builder VM; it exits during sandbox setup, so qemu has nothing
to connect to. The Connection refused is the symptom, three lines below the cause.

This is the same species as fixture-vm blockers #8 and #11, one axis over.
Both of those were about what a rootless container hands the build: #8 was the
supplementary group (--group-add keep-groups), #11 was which principal
the probe ran as. This one is the capability set. The container gets
--device /dev/kvm --group-add keep-groups --security-opt label=disable and
nothing about capabilities, so it runs with podman's rootless default set —
which omits SYS_ADMIN, MKNOD and DAC_READ_SEARCH, and virtiofsd's
sandbox needs all three plus SETPCAP to drop the bounding set afterwards.
capng_apply failing on a bounding-set change is exactly what "failed to sync
capabilities with the kernel" is.

What happens next

The fix goes on this branch, with a probe that asserts the capabilities are
present rather than a flag added on faith — same discipline as step 4, so that
deleting the flag turns the probe red instead of turning the build red eleven
minutes later.

Nothing is blocked by holding this open: trace-fixtures is not one of the
18 required contexts on main (checked against
branches/main/protection/required_status_checks), so this PR gates only itself.

🤖 Generated with Claude Code

Run 30579194737 got six minutes further than 30575158798 and then died in
nixos-disk-image.drv. The visible line was

  qemu-system-x86_64: -chardev socket,id=store,path=virtio-store.sock:
  Failed to connect to 'virtio-store.sock': Connection refused

which reads like a socket-path or ordering bug. It is neither. Three lines
above it, twice:

  ERROR virtiofsd] can't apply the child capabilities: failed to sync
  capabilities with the kernel

virtiofsd is what shares the Nix store into the transient VM that
nixos-disk-image.drv boots. It raises a fixed capability set for its child
with capng_apply(BOTH); raising one that is not already permitted returns
EPERM, so it exits before binding the socket. The refused connection is the
absence of a listener, not a connection problem.

Two of virtiofsd's required capabilities are absent from podman's rootless
default set: MKNOD and DAC_READ_SEARCH. PODMAN_CAPS grants exactly those,
hoisted into the workflow env for the third time and for the same reason as
NIX_IMAGE_DIGEST and NIX_CONF_EXTRA -- the precondition probe has to run
under the same grant as the build it guards. They are confined to the
rootless user namespace and give the container nothing on the host the
invoking user did not already have.

The assertion goes in the BUILD principal, not the container. virtiofsd is
spawned by a Nix builder, so the builder's capability set is the one that
decides -- and testing the container's instead would repeat the mistake this
probe was written to fix, one axis over: /dev/kvm was about the principal's
groups, this is about the same principal's capabilities.

Only the two capabilities the workflow grants are asserted. Both masks are
printed -- the container's from the step, the builder's from the probe --
so a capability missing for some other reason is readable from the log
instead of guessed at from a list of podman defaults recalled from memory,
and so that a grant lost between podman and the builder is distinguishable
from a grant never made. Asserting a capability nobody has shown to be
required would only be a second way to be confidently wrong.

Verified locally by executing the emitted builder script against constructed
/proc/self/status fixtures, not by reading it:

  * podman rootless default mask (00000000800405fb) -> exit 1, naming both
    DAC_READ_SEARCH and MKNOD
  * the same mask with bits 2 and 27 set   -> exit 0
  * CapEff absent entirely                 -> exit 1, rather than expanding
    to a bare 0x and dying with a parse error that names neither the file
    nor the cause

Also checked: the workflow YAML parses, all nine step scripts pass bash -n,
both sh -c bodies pass sh -n and contain zero apostrophes, and both .nix
files parse.

kvm-probe.nix is renamed to build-principal-probe.nix because it no longer
probes only KVM, and a file whose name misdescribes what it measures is the
exact defect this branch keeps fixing.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eeds

Run 30580839772 got all the way to `<<< NixOS Stage 1 >>>` and then:

    waiting for device /dev/vda1 to appear.......................
    Timed out waiting for device /dev/vda1, trying to mount anyway.
    mount: /mnt-root: fsconfig() failed: /dev/vda1: Can't lookup blockdev.

`nixpkgs.lib.nixosSystem` imports neither profiles/base.nix nor
profiles/qemu-guest.nix, and the latter is the only place the virtio module
set comes from. Measured on the config as it stood:

    $ nix eval …#config.boot.initrd.availableKernelModules --json
    ["ahci","ata_piix","atkbd",…,"nvme",…,"sd_mod","sr_mod",…,"usbhid",…]

IDE, SATA, NVMe and USB keyboards, and not one virtio_*. The harness attaches
the disk `if=virtio`, so /dev/vda1 could not appear no matter how long stage 1
waited. Same command after the import:

    ["9p","9pnet_virtio","virtio_blk","virtio_mmio","virtio_net",
     "virtio_pci","virtio_scsi", …]

WHY IT GOT THIS FAR. Nothing cross-checks `fileSystems."/"` against the drivers
the initrd can actually load, so the image evaluated and built cleanly. SeaBIOS
and GRUB then reached the disk through BIOS int 13h, which works for any
controller — so firmware, bootloader, kernel and initrd all loaded off a disk
the kernel itself could not address. A disk that boots is not a disk the kernel
can mount; every stage before stage 1 was reading it a different way.

The import also carries 9p and 9pnet_virtio, which the /fixtures share needs.
That mount is `nofail`, so without them gen-fixtures would have written its
output into an ordinary directory inside the guest, the host would have
collected nothing, and the step would have reported success. Two failure modes,
one import — and the second is the more dangerous, because it is silent.

NOT VERIFIED HERE: this is measured at the evaluation layer only. Whether the
guest now boots to multi-user.target and writes four fixtures can only be
answered by a dispatch on x86_64-linux.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
avrabe and others added 3 commits July 31, 2026 00:31
Run 30583284365 failed twice over, and the serial console added in b57200b
is the only reason either half is legible. Both defects are in this file.

1. THE UNIT HAD NO PATH TO THE TOOLS THAT WERE IN THE IMAGE.

   gen-fixtures died on:

     capability missing: cannot create network namespace (is this runner12
     with the `netns` label?): command `ip` failed: could not spawn:
     No such file or directory (os error 2)

   `pkgs.iproute2` was in `environment.systemPackages` the whole time. That
   option does not do what the name suggests for a service: it populates
   /run/current-system/sw/bin, which is on the PATH of an interactive login
   shell. A systemd unit gets NixOS's minimal default (coreutils, findutils,
   gnugrep, gnused, systemd, util-linux) and nothing more. `ip` was in the
   closure and unreachable from the one process that calls it.

   `systemd.services.<name>.path` is the unit-scoped list, so that is where
   the tools now go. `ip netns exec` passes the caller's PATH down to the
   child, so this one list covers both the direct spawns and the namespaced
   ones.

   The list is DERIVED from the source, not recalled: every literal passed
   to run_cmd / netns_exec / netns_capture / netns_spawn_bg in
   crates/spar-trace-topology/src/bin/gen-fixtures.rs and
   src/fixtures/netns.rs. That mattered — writing it from the comment above
   systemPackages produced a list missing `arping`, because that comment was
   itself already incomplete. Copying it would have propagated the gap.

   `arping` turned out to be missing from `environment.systemPackages` too,
   so it was not in the guest at all, and its call site discards the error
   (see the companion commit). It has therefore never run, and nothing said
   so. It comes from iputils rather than pkgs.arping: the call site passes
   `-I <dev>`, which is iputils' spelling; Habets' arping (pkgs.arping 2.26)
   uses `-i` and would fail on the interface. Confirmed against the pinned
   channel — nixos-25.11's iputils is 20250605 and its own longDescription
   enumerates arping, with no meson flag disabling it.

2. A FAILING RUN COULD NOT POWER THE GUEST OFF.

   Power-off was `ExecStartPost`, which systemd runs ONLY when ExecStart
   succeeded. So the unit went to `failed`, nothing shut the machine down,
   and it idled at the login prompt until the harness killed it at 900s:

     fixture-vm: error: QEMU guest did not shut down within 900 seconds

   A failing run was therefore indistinguishable from a hanging one, and
   cost fifteen minutes to say so. `ExecStopPost` runs on both paths and is
   documented for exactly this. `TimeoutStopSec = 60` bounds the stop
   sequence itself, so a wedged poweroff is SIGKILLed rather than drifting
   out to the harness watchdog. That watchdog stays as the backstop; it just
   should not be the first thing that notices anything.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run 30583284365 reported a missing `ip` like this:

  capability missing: cannot create network namespace (is this runner12
  with the `netns` label?): command `ip` failed: could not spawn:
  No such file or directory (os error 2)

The sentence blames the runner label. The actual fault was the guest's
systemd unit PATH — `ip` was in the image, just not reachable from the
service. Two different owners, two different fixes, one message. The
os-error-2 tail was the only thing that distinguished them, and it is at
the far end of a line that opens by naming the wrong suspect.

So the two are now different values, not one:

  * ToolNotFound  — spawn returned ENOENT. The binary is not on PATH. That
    is a provisioning fault in the image we build. Its message points at
    the unit `path` and states outright that environment.systemPackages
    does not reach a unit, because that is the trap that produced it.
  * CapabilityMissing — the binary ran and was refused. A privilege fault
    in the environment we run under, and the runner-label question is a
    fair one to ask.

`diagnose_create` is where the split is enforced: it wraps a failed
`ip netns add` in the capability framing EXCEPT when the inner error is
ToolNotFound, which passes through unwrapped so the message names the
image rather than the label.

Three tests, and they cover two different halves:

  * absent_tool_and_refused_tool_are_distinguishable spawns a name that
    cannot exist and compares its rendering against a program that runs and
    exits non-zero, asserting the two strings differ. This is the detection
    half. Mutation-tested: collapsing the ENOENT arm back into
    FixtureError::Command — the pre-fix behaviour — fails this test and
    only this test, exit 101.
  * the two diagnose_create_* tests construct the error directly, so they
    survived that mutation. They cover attribution, not detection. Stated
    here because "three tests passed" would otherwise imply three
    independent guards, and it is two.

Also: gen-fixtures spawned arping behind `let _ =`. Non-fatal is right —
arping exits non-zero with no reply and the capture is still usable — but
non-fatal and unobserved are different things. That discarded Result was
the one signal that would have said arping was absent from the guest
entirely, which it was (see the companion commit); the call has therefore
never done anything and never said so. It now warns, matching the lldpd,
lldpctl and pmc calls around it.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Neither job had one, so both inherited GitHub's 6-hour default on a
self-hosted pool. A wedged job does not just fail late — it holds a runner
slot the whole time, and these run on the `podman` label, which is one
machine.

generate-fixtures already has an inner watchdog: the harness kills the
QEMU guest at 900s. That covers the guest and nothing else. The nix build
of the qcow2, a podman that will not start, a runner that loses /dev/kvm —
all of it sits outside the watchdog and was unbounded. 180 minutes is set
against a measured warm-cache run (30583284365: 21:27:49 -> 21:57:50, ~30
min) with room for a cold rustc + nixpkgs build.

resolve-lock is a network operation with no watchdog at all; 30 minutes is
generous for resolving flake inputs and still bounds a stalled fetch.

These are backstops, not the fix for anything currently broken — the two
commits before this one address why 30583284365 actually hung. A backstop
is worth having anyway: the next unknown failure should cost minutes, not
a working day of runner time.

Refs #362, #365

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant