Lessons from wootc's boot chain and E2E harness. Six defects in the harness hid the causes of real failures in one session. About half the failures came from our own changes.
Read this before you change the E2E harness, deployer, or runners. Most entries cost at least one run of 60–90 minutes.
A proxy can produce a status without the required observation. Every serious defect in this session followed that pattern. Ask what a check would report if the event it asserts never occurred.
These checks all shipped with defects:
| Check | Passed when | Consequence |
|---|---|---|
qga_read marker >/dev/null |
a stale marker from a previous run existed | harness monitored a deployer that was never staged |
| Phase-2 boot checks | advisory only | ALL TESTS PASSED for a boot that never happened |
compose_up_windows (return ignored) |
always | 15-minute wait, then "QEMU did not start" — wrong cause entirely |
snapshot_serial failure swallowed |
podman cp failed silently |
harness read a frozen serial file for 2 hours |
[ -f "$PTY" ] |
a previous run's file existed | a run analysed another run's serial |
counter ELAPSED |
never | timeouts 1.5× nominal; progress lines off by 30 minutes |
Rule: a check must fail when the required event is absent. Break the code and confirm that the test fails.
These signals were unreliable in this session:
pgrep -f "run-e2e.sh"over ssh matches your own ssh command. Its command line contains the pattern, so it can report a run that does not exist. Filter your process withpgrep -f ... | grep -v $$, or inspect the process tree.systemctl --user is-activecannot report a run that you launched withnohupoutside that unit. Check how you launched the run.- Log tail can mix runs when
StandardOutput=file:truncates a file butStandardError=append:preserves old errors. Use append for both streams. Locate the current run by itsRun IDline.
Use these observations to diagnose the current run:
- log mtime (
stat -c %Y) — did the log change? - guest CPU (
podman exec <container> ps -eo pcpu,args) — does QEMU consume CPU in the current run? - process tree (
systemctl --user status <unit>) — which children does this unit own? Asleep 3child can explain a pause.
These observations do not prove the identity of a guest, its installation, or a successful boot. Each claim needs a successful command and current facts.
bootc install can produce no serial output for 10+ minutes as it extracts layers.
The time since the last output cannot prove a failure.
In the recorded runs, useful work continued without output at high CPU (130–170%). An idle CPU with no output was a useful clue. A deploy that appeared stuck for 13 minutes succeeded. A similar run did not. Check CPU as a diagnostic (#40), then inspect the guest for the cause.
A sample of CPU use cannot identify a prompt in Setup or prove a product verdict. If guest identity remains unknown, record an infrastructure failure.
Two defects affected the timeouts.
Counters drift. ELAPSED=$((ELAPSED+5)) beside sleep 5 does not measure elapsed time.
QGA probes and snapshots consume time that the counter omits.
The measured rate was 0.68× wall clock: a wait of 45 minutes took about 66 minutes.
Use deadline_in and past_deadline.
The defect recurred on 2026-07-22 in wootc-attach-loop.sh.
A nominal 60-second wait for host NTFS added 3 seconds per iteration.
udevadm settle returned at once on an empty queue.
The budget expired in about 2 wall-seconds, before virtio-scsi discovered the disk.
Phase 2 reached the emergency shell.
The timestamps in kernel messages showed the error: the hook entered at 1.07 seconds and exited at 3.37 seconds.
In an initramfs, use a /proc/uptime deadline and an unconditional sleep per iteration.
Never let a probe's status control that sleep.
The raw-loopback.bats check is "wall-clock with an unconditional sleep".
A deadline cannot stop a call that never returns.
qga_call had no timeout. A stuck podman exec stopped the loop before its next deadline check.
Set a timeout on every external call that can wait.
An accurate clock can reveal another defect: a budget that was never enough for the elapsed time. Recheck the budget after you correct the clock.
The snapshot before the deployer spent 10–20 minutes on an fsfreeze and a 28 GiB copy. That delay gave time for Windows OEM setup to stage BootNext. When we removed the snapshot (see §7), a barrier could pass on a stale marker before setup finished.
Both changes were necessary. When you remove a slow step, check which races its delay concealed. Check a current observation at the barrier; another sleep cannot prove the event occurred.
/tmpisnoexecon the dev box. PATH stubs inBATS_TEST_TMPDIRcannot execute there. A test can then report that no command ran because the stub itself failed.setup()must create and execute a stub; use$HOME/.cacheif that fails. Seego-native.batsandpick-blank-disk.bats.- Case-sensitive guards cover only their pattern.
A check for
$ELAPSEDmissed three loops that used lowercase$elapsed. - A test can match its own documentation.
A check for
need at leastmatched a comment about the guard, not the code. It then reported a defect that did not exist. - Test the counterexample. Break the code, confirm failure, then restore it. Do this for every test that protects a safety requirement.
- Never
podman system prune -afon a host with a live run. It killed three runs at once and deleted the local imagewootc-e2e-windows-ssh:latest. Compose then tried to pull from a registry namedlocalhost. loginctl enable-linger <user>is required. Without it, systemd killed runs when the ssh session closed.- Launch with
systemd-run --user. PassXDG_RUNTIME_DIRandHOMEexplicitly. Otherwise rootless podman can use storage paths for root and fail withpermission deniedon/run/containers/storage. - Check for a live run before any cleanup. Keep its storage, snapshots, and evidence. Remove only disposable files that you own.
- Prefer runners hosted by GitHub (
e2e-hosted.yml, ubuntu-latest with/dev/kvm). The laptops each failed for a different reason; see the table below.
dracut/printk logging. Neither info() nor warn() is reliable in an
initramfs:
info()writes<30>(KERN_INFO) and only echoes to stderr whenDRACUT_QUIET != yes— whichcheck_quiet()defaults toyes.warn()writes<28>(level 4), butquietsetsconsole_loglevel=4and printk prints only levels strictly below it — so warn is dropped too.
Use <27> (KERN_ERR, level 3) to kmsg. Do not also write to /dev/console in
high-volume paths — deploy.sh emitting every line three times saturated a
115200-baud serial and stalled every deploy. Low-volume boot hooks may.
Windows VSS freeze limits. guest-fsfreeze-freeze on Windows goes through
VSS, which enforces ~10s for writers and ~60s overall. A freeze held across a
20-minute copy is not honoured — the guest auto-thaws mid-copy, so the "crash
consistent" snapshot is not, and the volume can be left dirty.
NTFS dirty bit. ntfs3 refuses a dirty volume even read-only. A volume
formatted by Windows and rebooted immediately is dirty. Mount fallbacks:
ntfs3 → ntfs3 -o ro,force → ntfs-3g. This was the BitLocker bug (#36).
Cross-image binaries need their whole closure. The target bootc image ships
no qemu-nbd, so the deployer stages its own — but the deployer is Fedora-based
and the initramfs is assembled from the target's libraries. Measured skew:
libfuse3.so.4 vs .so.3 (a soname major bump). The binary lands and dies
at runtime, failing exactly like a missing one.
- Ship the full closure: binary + every
NEEDEDlib + the loader, invoked asld.so --library-path <dir> <binary>. - Never symlink
.so.4onto.so.3— a soname major bump is an ABI break, and this driver writes the root filesystem. - Never match the deployer base to the target image — wootc supports arbitrary bootc images, so target library versions are unknowable.
lddreports only the first missing library. Test by actually running the binary in the target image.
Container probes report the host kernel. /proc/filesystems inside
podman run shows the host's kernel, not the image's. It says nothing about
whether the image's kernel supports ntfs3. This produced one wrong root cause
already.
Wrong root causes reached confidently in one session: "EL10 lacks NTFS support", "multi-disk layout breaks Phase 2", "the deploy wait has no timeout", "netavark errors are killing the deploy", "the QGA MSI was re-downloaded with hardened defaults". Each felt convincing.
What actually worked:
- Reproduce outside the VM when possible. The qemu-nbd library mismatch was diagnosed and its fix verified in a container in minutes, versus a 90-minute run. Always ask: can this be tested without booting?
- Check the discriminator before asserting. Guest CPU for silence. Marker
contents for barriers.
lddinside the target image, not the host. - n=1 is not evidence. The multi-disk hypothesis came from one run per arm.
- Compare against a known-good run before blaming something new. The netavark errors looked damning until they turned up in the morning's working runs too.
- State what is established vs claimed.
docs/phase2-debug-plan.mdkeeps these separate deliberately.
- One deployer/initramfs change per run. Every change costs 60–90 minutes; batching makes attribution guesswork. Three times a "fix" was applied to something that was not broken while introducing something that was.
- A fix for an observability gap is not free. The
/dev/consolechange was meant only to make failures visible and it stalled every deploy. - Put the reasoning in the test, not just the commit. Tests here carry the failure they prevent, so the next person cannot "simplify" it away.
- Retention matters. Each run writes ~3 GiB of artifacts and needs ~45 GiB resident. Without pruning, a successful run breaks the next one.
| Host | Issue |
|---|---|
| runner-a | podman resolves root storage under systemd-run --user (#41); runs die without a [FAIL] line |
| runner-b | container QEMU lost KVM after podman system migrate (#42); 238 GiB disk is undersized for a ~45 GiB/run workload |
| runner-c | healthiest of the three; 952 GiB |
Prefer e2e-hosted.yml on ubuntu-latest over all of them.
The first half of this document was written mid-session. What follows is what the rest of the day taught, including six regressions I introduced myself. Read §12 first; it is the one that would have saved the most time.
The single highest-value technique of the entire session, and it came from the user telling me to stop waiting.
Four consecutive 90-minute runs failed without producing an attributable cause. Then one command answered it:
podman exec <container> python3 /tmp/qga.py powershell '$env:OS'
# -> Windows_NT ... while the harness was 61 minutes into "Deploying..."The guest was not running the deployer at all. From there, two minutes of live inspection found what four runs had not:
# which OS is actually running?
qga.py exec /bin/sh -c "uname -sr"
# what is the process actually doing?
qga.py exec /bin/sh -c "ps -eo pid,ppid,stat,wchan:20,args"
# what did it last say? (the deployer's own journal, live)
qga.py exec /bin/sh -c "journalctl --no-pager | grep -a wootc | tail -20"wchan is the key column: do_wait means blocked on a child,
hrtimer_nanosleep means a sleep, anon_pipe_read means blocked on a pipe.
Post-hoc artifacts repeatedly failed where live inspection worked: the deployer log could not be read because the guest was in an emergency shell, and the journal artifact came back at 111 bytes. If a VM is hung, go in NOW — the evidence disappears when the run cleans up.
The deployer initramfs ships qemu-ga, so this works during Phase 1 too, not
just once Windows is back.
Worth internalising as a class of mistake, not just a fact.
For most of the session I debugged Phase 2 as though it were broken: the attach hook, the qemu-nbd closure, the NTFS driver, hypotheses A1–A6. All of it was reasoning about components that were never installed, because the deploy died before staging them.
The proof took one manual boot: force the BCD one-shot by hand and look at the GRUB menu.
*Install wootc (automatic)
Install wootc (debug)
No entry for the installed system — while root.vhdx held a complete 6.6 GB
ostree deployment. The OS was installed and unreachable.
Lesson: before debugging why a stage fails, verify the stage can be entered at all. One QGA call would have shown this on day one.
Three designs, two of which I made worse:
-
( sleep 2700; force_reboot ) &— never cancelled.dracut-initqueueblocked inwait()for the full 45 minutes after the deployer returned, so Phase-2 setup never ran and the exit status was never printed. -
Added
kill "$pid"; wait "$pid". When the kill misses, thatwaitblocks forever — a permanent hang replacing a 45-minute one. -
Wrapped the sleep in
setsidso it could not outlive its subshell. That put it in its own session, beyond the reach of both the pid kill and the process-group kill. Strictly worse. Confirmed live:453 1 S do_wait /usr/bin/sh /usr/bin/dracut-initqueue 455 453 Ss hrtimer_nanosleep sleep 2700 <- Ss = session leader
The working design signals nothing: the watchdog polls a flag file, cancelling
is : > /run/wootc-deploy-done, and the loop exits within one tick by itself.
Generalisation: in a shell, prefer a background task that observes a
condition and exits over one you must find and kill. wait on a pid you do not
control is an unbounded block; kill is unreliable the moment process groups or
sessions are involved.
Three variants bit in one day. All produce confident, wrong answers.
pgrep -f "run-e2e.sh"over ssh matches the ssh command running it. Reported dead runs as alive.- Polling
journalctl | grep verify:logs your own command, which then matches the grep. The output was entirely my own polling. - A test grepping for a string that appears in its own comment. Happened
three times: a preflight guard matched the comment quoting the error, and a
setsidremoval test matched the comment explaining the removal.
Rule: when grepping for a pattern, exclude the searcher. grep -v qemu-ga,
grep -v $$, grep -nE '^[^#]*pattern'.
Six regressions introduced while fixing things, in one session:
| # | Change | Damage |
|---|---|---|
| 1 | podman system prune -af on live hosts |
killed 3 runs; deleted the built ssh image |
| 2 | triple /dev/console logging |
saturated serial, stalled every deploy |
| 3 | folding kernel reboot into DEPLOYER_REBOOT_SEEN | false [PASS] on a dead deploy |
| 4 | kill + wait in cancel_watchdog |
permanent hang |
| 5 | setsid on the watchdog sleep |
made it uncancellable |
| 6 | RunId barrier without refreshing C:\OEM | mutual deadlock, guest timed out |
The cause is single and structural: I changed code faster than a 20–90 minute feedback loop could validate it. Every one of these looked correct when written and was wrong in an interaction I could not test locally.
Mitigations that actually work, in order of value:
- Reproduce in a container first. The qemu-nbd closure was diagnosed AND its fix verified in a container in minutes.
- One deployer change per run. Repeatedly violated under pressure to show progress; every violation cost more than it saved.
- Round-robin the runners (§17) so a fix is validated sooner.
- Prefer designs that cannot fail the same way — the flag-file watchdog over any amount of careful signalling.
With three runners staggered by ~15 minutes, a fix is validated against whichever reaches the interesting stage first, instead of waiting a whole cycle. It also supplies what #34 needs (a pass RATE at one commit) and covers "multiple cases".
Launch notes per host are in AGENTS.md. runner-a needs nohup rather than
systemd-run (#41); all hosts need loginctl enable-linger.
I wrote an ad-hoc loop to push files into the guest, using $OEM_DIR — a HOST
path — while qga.py runs inside the container and sees that mount at /oem.
Every write failed. qga_sync_oem() already existed forty lines away, did it
correctly, and was merely gated on --skip-install.
Before writing a helper, grep for one. Before passing a path to something that runs in a container, ask whose filesystem that path is on.
C:\OEM is populated from the ISO at Windows install time. Any guest whose
Windows was installed by an earlier run carries THAT run's scripts. Introducing a
protocol change (the RunId barrier) without refreshing the guest deadlocked both
sides: the guest stamped an old constant, the host never matched, and each waited
on the other until the guest's 10-minute deadline expired.
If you change a host/guest protocol, push the guest half of it in the same change.
qga_call retries three times to survive flaky agents. Sent through a boot
transition, the retry is poison: a systemctl reboot the dying guest never
consumed sat in the virtio-serial channel until the NEXT agent opened it — the
freshly booted native install, which obeyed and rebooted straight back to
Windows (BootNext one-shot, already consumed). Earlier runs "survived" only
because guest-exec was still blacklisted natively; fixing the blacklist armed
the trap.
Side-effecting commands must never ride a retried channel across a boot
boundary. Use the QEMU monitor (system_reset) — it queues nothing anywhere.
And remember any single reboot of the native system lands in Windows: BootNext
fires once, BootOrder still lists Windows first.
wootc-mount-user-dirs exited 0 with zero binds and an empty journal — each
profile was skipped by a silent continue. The condition it skipped on
([[ -d $home ]]) was itself a deployment bug: fisherman's
useradd --create-home followed the deployment's /home -> var/home symlink
into the deployment's OWN var, which the stateroot var mount masks at runtime.
Two rules: every skip on an "impossible" condition logs an ERROR naming the
bug, and every unit that can no-op logs a final summary line
("N binds across M users") so the journal alone convicts or acquits it.
The failed run's whole story was recoverable offline: qemu-nbd the graduated
qcow2 → native journal showed the exact reboot command and its source pid;
loop-mount root.disk inside the NTFS qcow2 → the orphaned home in the
deployment's masked var. Two dead ends to skip next time: a qemu-nbd --connect dies with its ssh session (do all reads in ONE session, or it reads
as a corrupt/empty GPT with fresh random GUIDs per call), and the serial pty
spans every boot of the run — sed from the FIRST "GRUB version" banner lands
in Phase-1, not the boot you care about (anchor on BdsDxe: starting BootNNNN
line numbers instead).
useradd --root <dir> chroots — after initializing the HOST's PAM/SELinux
stack. From the deployer initramfs it worked for months; from booted Phase 2
it failed with "failure while writing changes to /etc/passwd" against an etc
that append/touch proved writable. chroot <dir> useradd succeeded on the
same files, same second. Offline user tooling must run the TARGET's binaries
via plain chroot; the flag that looks equivalent is environment-sensitive in
exactly the way an initramfs test bed cannot reveal.
The debugging move that cracked it in minutes: while the failed Phase 2 was still running, reproduce the exact failing command by hand over QGA, then bisect the variants (--root vs chroot vs --prefix) live. One boot's evidence beats three relaunch cycles.
run-matrix.sh polled for any [FAIL] line and concluded on the first one.
But several run-e2e checks are invoked as ... || true, so a non-fatal
failure line looks identical in the log to a fatal one. A cosmetic
seed_user_data failure therefore ended the poll ~35 minutes early, recorded
itself as the case's verdict, and sent me hunting a "composefs" bug that was
neither composefs nor the reason the case died — while the run was still
deploying.
The rule: conclude on a terminal state, never on the presence of a scary
line. run-e2e.sh stamps stage=exited from an EXIT trap, so an ending is
always observable; the last [FAIL] then supplies the reason — it does not
supply the fact of failure. And delete the previous case's state file with
its log, or a stale stamp declares the new case finished the moment it starts.
This is lesson §21's sibling: there, a service could succeed while doing nothing; here, an observer could report a failure that had not happened yet.
On the BitLocker axis setup-wootc.ps1 carves an unencrypted volume and puts
the whole tree on it — E:\wootc\{disks,install,logs}. The harness hardcoded
C:\wootc\... in four places. So el10-gnome-win11pro-bitlocker deployed
successfully, passed both FDE assertions (C: still encrypted, Linux on
unencrypted E:), and then died 39 minutes in on "Could not read wootc BCD GUID
from Windows" — reading a file from a drive it was never written to. The VDL
extension had already silently skipped for the same reason, reporting only
size='0' at INFO level.
Two habits fall out of this:
- When the guest-side script computes a location (
$storageRoot), the host-side must ask for it, not re-derive it.guest_wootc_root()queries the guest and caches only a positive answer. info "Could not read X (size='0'), continuing"is a bug report wearing a progress message. A read that returns nothing where something must exist is never a continue-quietly condition.
A GUI E2E died at 6 minutes with what read as a broken package list:
Error: building at STEP "RUN dnf install -y systemd systemd-udev ...": while running runtime: exit status 1
[FAIL] Deployer build failed
Nothing was wrong with the packages. Two lines up, unhighlighted, was the real
event: error running container: from /usr/bin/crun creating container /
did not get container create message from subprocess: EOF. The container was
never created, so dnf never ran — but the error names the RUN step, so the
instinct is to go read the package list.
The cause was a hosted runner-image bump: podman 4.9.3 → 5.8.4 with
/usr/bin/crun left at 1.14.1. Podman 5 emits an OCI spec version crun 1.14
rejects (crun: unknown version specified). Note the good error text exists —
it appears verbatim in podman run, and it was podman build that buried it.
Three habits:
- Check the blast radius before diagnosing. The same push turned the
container test suite red, and that log printed the unswallowed
crun: unknown version specified. A failure appearing in two unrelated workflows is environmental; one that appears in only one is yours. This is what turned a wrong guess (AppArmor userns restrictions — the other cause ofEOFat create) into the actual answer in one step. crun --versiondoes not tell you what podman runs. Hosted runners carry more than one crun;$PATHprefers/usr/local/binwhile podman prefers/usr/bin. A barecrun --versionreported 1.28 before and after installing 1.28, which nearly credited the fix to an unrelated image patch. Ask the engine:podman info --format '{{.Host.OCIRuntime.Path}} {{.Host.OCIRuntime.Version}}'. For the same reason.github/actions/podman-runtimeinstalls over/usr/bin/crun— a/usr/local/bincopy is on$PATHbut invisible to podman.- The hosted pool is mixed. Jobs in the same run landed on podman 4.9.3 and
5.8.4, so an environment repair must be unconditional and then verified by a
real
podman run, not gated on a version comparison. Version strings are a proxy; a container that starts is the fact.
Both fedora-gnome cells of run 30704513401 failed identically — so, not a
flake — with [FAIL] QGA did not become available for Windows return after Phase 2 Linux within 10 minutes. Phase 2 itself was flawless: it booted,
passed every passthrough and user-data check, and rebooted on cue. The damage
was done on the way out, and it was visible in five lines of serial nobody had
been reading:
(sd-remount)[...]: Failed to remount '/run/initramfs/wootc-host' read-only: Device or resource busy
systemd-shutdown[1]: Not all file systems unmounted, 1 left.
systemd-shutdown[1]: Not all loop devices detached, 1 left.
systemd-shutdown[1]: Cannot finalize remaining file systems, loop devices, continuing.
reboot: machine restart
Windows then showed a desktop for ~26 s, rebooted itself, looped the boot manager three or four times and died in Startup Repair. The QGA timeout was a symptom four reboots downstream of the actual bug.
- A boot stack that lives on the thing it must release cannot release it.
Phase-2
/is a loop device backed byroot.disk, a file on the rw NTFS mount. Nothing running inside that stack can unmount the volume. The deployer already knew this —deploy.shhas a whole block ending "a still-mounted rw NTFS would be flagged dirty" — and Phase 2 simply never got the same treatment. When one side of a symmetric operation has a hard-won teardown, go look for the other side. - A missing file can disable an entire subsystem in total silence.
dracut-shutdown.servicepopulates/run/initramfsfrom/boot/initramfs-$(uname -r).img. On ostree/composefs that path does not exist,dracut-initramfs-restoreno-ops, and systemd — which only pivots when/run/initramfs/shutdownis executable — quietly skips the whole shutdown-initramfs mechanism. No error, no warning, and the absence of "Returning to initrd..." in the log is the only tell. - Corruption sorts by driver, and the passing cells were the clue. Every
cell mounting the host volume with kernel
ntfs3failed; every cell that fell back to thentfs-3gFUSE driver passed. That correlation is what turned "flaky Fedora cell" into "structural teardown bug" — a green cell is evidence too, and diffing it against the red one costs minutes. - Make the dangerous new path revert to the old one. The fix arms a
shutdown pivot that did not previously happen, on every cell's boot path,
and cannot be tested outside a 60–90 minute VM run. So every failure path in
the staging hook ends by deleting
/run/initramfs/shutdown— without it systemd does not pivot, which is exactly today's behaviour. A partial or broken staging can only reproduce the bug it is fixing; it cannot invent a new one.
bluefin-dakota-win11pro (run 30707067821) died 11 minutes into the deploy on
two lines:
[wootc] ABORT: line 1281: awk '{for(i=1;i<=NF;i++) if ($i ~ /^\//) print $i}' (exit 127)
[FAIL] qga: ldd on the deployer's qemu-ga surfaced no dynamic loader
- A dracut initramfs contains exactly what
module-setup.shnames. Every closure builder indeploy.shresolved libraries withldd "$bin" | awk ….lddis a glibc-common shell script; no library dependency drags it in, nothing listed it, so it was never in the image. Reach for the thing whose presence is structurally guaranteed instead: the dynamic loader has to be there or the deployer itself could not run, and$ldso --listis whatlddexecs anyway.dso_closure()now does that in pure bash, so no missing text tool can break a closure either. - Exit 127 names the wrong command. Under
set -o pipefailthe ERR trap reported theawkstage of a pipeline whose first stage was the missing one. When a trap blames a command that obviously exists, suspect the rest of the pipeline before you suspect the trap. [FAIL]on the deployer serial is an API, not a severity.run-e2e.shgreps the serial forfatal|panic|[FAIL]and ends the deploy on the first hit. The qga stager printed[FAIL]for a condition its own caller absorbs with[WARN] no fallback qemu-ga staged— so a survivable best-effort miss killed a cell that was otherwise fine. A function may only shout[FAIL]if every one of its callers treats the failure as fatal.- The same rule bites the harness.
run-e2e.shalso printed[FAIL] run-e2e.sh aborted: awk …on every hosted cell, because-Epropagates the ERR trap into the command substitution probing for atailscale0that hosted runners do not have. Nothing aborted. The matrix takes the last[FAIL]as the verdict, so an optional probe was one silent timeout away from becoming a run's official cause of death.
fedora-gnome-win11pro-btrfs (run 30710282779) failed on:
[FAIL] Windows QGA did not become available within 10 minutes
It had already succeeded. Every Linux assertion passed, §27's shutdown pivot
worked (wootc: shutdown: unmounted the Windows host NTFS at /oldsys/run/initramfs/wootc-host), and the serial shows the return happening
two seconds after the reboot request:
[ 75.9] reboot: machine restart
BdsDxe: starting Boot0003 "Windows Boot Manager" ...
Then the harness power-cut it. The two boot-manager loads after that one are
its own system_reset and the loop that followed; the final screenshot is a
recovery screen.
- A ping is not an identity. The reboot step watched for
qga_probeto fail and reset the VM if it never did.qga_probeisguest-ping, which answers for whichever agent is up — so "Phase 2 has not rebooted" and "Windows is already back" produced the identical reading, and the fallback for the first is a hard power cut to the second. §1 again, in its purest form: the observable is which OS answers, and the code only ever sampled whether one did.qga_windows_probehad existed for months, six lines above;qga_wait_downalready used it. This loop just didn't. - A destructive fallback needs positive evidence, not the absence of
evidence.
p2_reboot_observe()now returns one ofdown/windows/linux/unknown, and onlylinux— a guest that answereduname -s— may be reset.unknown(a Windows agent pinging before PowerShell is up) deliberately does nothing: the cost of waiting is one honest timeout, the cost of guessing wrong is the whole run. - Blind time is where the state changes underneath you. The request went
out through an unbounded
qga_call exec, which pollsguest-exec-statusuntil exit and then retries three times at 60s. Against a guest that is busy dying that is ~110 seconds of not looking — long enough for Phase 2 to leave and Windows to come back, so the observation opened its eyes on the wrong OS. §4 says bound the blocking call; the corollary is that anything you do not watch is free to become its own opposite while you wait. - This is why §27 read as a partial fix. That session saw Windows "show a desktop for ~26 s, reboot, and die in Startup Repair" and attributed the reboot to the dirty volume. The unclean unmount was real and worth fixing, but the reboot at ~26 s was this bug: the harness resetting a Windows that had come back fine. When a fix lands and the symptom shifts but survives, check whether you are now looking at a second cause rather than a an incomplete first one.
el10-gnome-win10pro, the first GUI-driven Win10 cell (run 30717631172),
failed with the app's own words:
[FAIL] the GUI REFUSES this configuration — Install is disabled, so no install can be driven
[FAIL] the app says: Windows has an update waiting to finish (servicing,windows-update).
Restart the PC, let it complete, then run wootc again
Nothing was broken. pendingReboot() read the two registry keys it is meant
to read, both were set, and the preflight declined a machine that is
mid-servicing — exactly the gate #63 exists for.
- A red cell is not automatically a bug in the thing under test. The harness had handed the product a machine in a state no user is supposed to install from: Win10 media stage servicing work that outlives OOBE, and the harness's own WebView2 bootstrapper (installed seconds earlier, because wails cannot render without it) adds an update of its own. The failing component was the setup, and the fix is to provision the guest into a state a real user would be in — not to loosen a gate that protects boot configuration during a migration.
- When the product tells the user what to do, the harness should do it.
gui_settle_pending_servicing()reads the same two keys the app reads, restarts the guest when either is set, and re-reads them. If a restart does not clear them, it says so and lets the app's refusal stand as the verdict: the harness must never assert a machine is ready, only observe that it is. - Coming back is not being logged on. QGA answers from session 0 long
before autologon finishes, and the GUI is launched via
schtasks /IT, which needs a real interactive session or fails with "the system cannot find the file specified". §1 again: ping is a proxy, the logged-on user is the observable, so the restart waits forWin32_ComputerSystem.UserName.
el10-gnome-win11pro-bitlocker (run 31076749824) died three seconds after the
container came up:
[INFO] Container wootc-e2e-windows started
[FAIL] QEMU is not using KVM acceleration
The diagnostics dump 300 ms later printed the very same PID with
accel=kvm -enable-kvm on it, at 0 s of CPU time. Nothing was misconfigured:
the harness read ps -ef while QEMU was still inside execve, where
/proc/<pid>/cmdline is only partially written, and judged a command line that
had been truncated mid-argument.
- A racy read is a proxy (§1). The observable is the settled argv, not the
first one that happens to be readable.
-enable-kvm,-tpmdev emulator,property=secure,value=onand/storage2/data2.qcow2all sit late on a ~2 KB command line, so a short read fails every one of those assertions and reports the most alarming of them as the verdict. - Poll until the assertion is satisfiable, then fail on a deadline. The check re-samples for up to 60 s and prints the settled command line with the failure, so a genuine "dockur disabled KVM" still fails, and fails legibly.
- The generic form: when polling for a process, the appearance of the process and the readability of its state are two different events. Waiting for the first tells you nothing about the second.
bluefin-dakota-win11pro and fedora-gnome-win11pro (run 31081727936) both
died at 65% with the product's own foreign-ESP refusal:
esp-sweep: before: clean
esp-sweep: after: clean
...
Setting up ESP: this PC's EFI boot partition already contains
EFI\fedora\shimx64.efi, which wootc did not put there
One cell named shimx64.efi, the other grubx64.efi — two different members of
the four files setupSignedChain copies, which is what reading a set mid-write
looks like, not what leftover state looks like. (reset_oem_attempt reads the
same run differently — that before: clean was itself an unreliable print,
because a drive letter assigned mid-process is not visible to that process's
FileSystem provider. Both can be true; the argument below rests on neither.)
gui_install_arm launched the app with
schtasks /Create /TN wootc-gui-e2e /SC ONCE /ST <now+1m> ... ; schtasks /Run ...
/Run starts the task immediately and leaves the ONCE trigger armed, so Task
Scheduler started launch-gui.cmd again inside the minute. Nothing makes
wootc.exe single-instance, both apps polled the same e2e-drive.json, and both
clicked Install. The install mutex in StartInstall is per-process, so two
pipelines raced onto one ESP — and because recordESPOwnership runs only after
all four copies land, whichever pipeline reached the guard during the other's
copy loop saw an unattributable vendor binary and refused, correctly.
- A manual start and a schedule are two launches.
/Runis not "run instead of the trigger"; it is "run as well as". - The bug was in the launch, not in the logs. Two schtasks calls are two launches whatever the run printed, so this needed no verdict to be trusted — and every GUI cell to date had been running two installers.
- Which file it blamed was the tell. A random member of a set that one code path writes in Go map order is being read mid-write.
- The harness now drops the task once the app is up and then counts
wootc.exebefore writing the directive — no instance can install until the directive exists, so an extra killed at that point provably never installed. - The drop was a silent no-op for a month (2026-10). It was written
cmd.exe /d /c "schtasks.exe /Delete /TN \"wootc-gui-e2e\" /F >NUL 2>&1". PowerShell has no backslash escape, so\"ended the string; cmd.exe got two arguments, schtasks tried to delete a task named", and>NULhid the error. The count still caught the extra while launch confirmation was slower than the trigger's minute. When the ready marker (#364) made confirmation fast, the count ran first, the trigger fired after it, and the second app's pipeline died on the first one's file:Making room for Linux: root.disk already existson every image (runs 36450824541, 38735109866). The ESP at failure time held a complete deployer chain from the other pipeline — the failing one had stopped before it ever wrote there. The delete is now verified from a successful task listing, and the directive is refused without both "task absent" and "one wootc.exe" (tests/unit/test_gui_launch_task.py, which also parses the line withpwshwhen present). A fix that hides its own failure behind>NULis a proxy for itself (§1).
el10-gnome-win11pro (run 32556250889) reported "install stalled at Finding your files" after waiting out its full 30-minute drive deadline. Printed underneath that verdict, in the same block, was the fact that invalidated it:
[FAIL] GUI-driven install did not reach the done screen in 30m
[FAIL] QGA does NOT answer ping — the verdict above may be a lost channel
The QGA virtio-serial channel had gone deaf. A stalled installer and a dead channel produce the same observable — the drive-state file stops changing — and opposite causes, so the run had no standing to say anything about the install. It said it anyway, thirty minutes late, and a human had to read the log to find out the red was not real.
- Ask the discriminator first, not last.
guest-pingdecides which verdict is even available, so it runs before anything is written down. Appending the channel state under a product verdict is the wrong order: the caveat does not retract the claim, it just sits below it. - Waiting longer cannot make a deaf socket talk. The remaining ~28 minutes bought nothing except the appearance of diligence. Once ping is dead, one bounded reconnect cycle — reap the clients that may still hold the single-client socket, drain what the killed one left queued (§20), reopen with a delimited sync — and then a verdict either way.
- Say what you do not know. The classification is
qga-channel-lostand it states that the run has no verdict on the product. The install may have finished, stalled or failed; the harness cannot tell, so it does not guess. - Name the class in the ledger.
note_flakewrites the machine-readable verdict the retry gate reads, and the ledger line names the same class, so the re-dispatch decision needs no human in it. - Exit 1 from a socket that will not open was the same mistake in miniature.
qga.pyconnected outside its own error handler, so ENOENT on a dead channel exited 1 — indistinguishable from a guest command that ran and returned 1, whichqga_call_retrymust never replay (§#40). A transport failure has to say so: it exits 42.
Guarded by tests/unit/qga-channel-lost.bats and a synthetic channel kill
against a real socket in tests/unit/test_qga_reconnect.py.
The GUI nightly failed in run 36240171646 (2026-09-26, #399). QGA answered,
schtasks /Run exited 0, but no interactive user existed. The final screenshot
was black. The video showed this Windows prompt:
Your password has expired and must be changed. The restored fixture had
aged past its account's password lifetime.
- Use the video if the final screenshot hides the original screen.
- Set
PasswordNeverExpireson the local account that the fixture uses for autologon. Apply this policy when you create and restore snapshots. Keep the credentials intact. Do not change the policy for other accounts. - After the repair of an expired fixture, restart once so Windows retries autologon. Then verify that an interactive user exists. The policy write alone does not prove this.
- An interactive-session deadline must block GUI launch. The old loop timed
out, substituted a username, and scheduled a task that could never run.
autologon-no-sessionis an environment failure. It is not a product verdict or an automatic retry trigger. A second identical snapshot has the same bug.
On 2026-09-26, storage pressure on the shared node stopped a helper trial inside Windows after 4,710.74 seconds (78.5 minutes). The helper had started to install OS layers. It had not returned a success receipt. Another test pod had already left the node. Free space had fallen below the kubelet threshold of 23,502,050,623 bytes. The experiment's former 8 GiB node guard was too low.
- Check the node filesystem
availableBytes, ordf -B1on the volume that stores VM disks, before and during a trial. Windows C: free space and a sparse disk's logical size do not measure this shared capacity. - This rig now reserves at least 26 GiB on the node, above the observed eviction threshold with headroom. Recheck the node's threshold before a future run. Other pods can consume the same filesystem.
- Keep this infrastructure reserve separate from the application's 8 GiB consumer reserve. A kubelet eviction policy is not a laptop requirement.
- If the node crosses the rig reserve, stop only the identified test writer. Do not wait below the reserve for another cleanup job. Preserve the outer Windows fixture and record a storage-pressure stop, not a helper failure.
- After the writer exits, archive the evidence, then delete its disposable scratch disk. A file deletion in Windows does not prove that the node has recovered physical space. This trial needed ReTrim inside Windows. A new node measurement showed that available bytes rose from 24,140,267,520 to 34,515,693,568. Confirm free space on the node before you start another trial.
The recorded result retains the empty helper receipt and the harness termination reason. No claim of an installed desktop or a complete installation follows from this run.