phase2 debug plan
Status as of 2026-07-18. Written after four consecutive failed E2E runs, none of which produced enough evidence to attribute the failure.
Historical (2026-08-11). The open hypotheses here were resolved: the qemu-nbd closure (A3) was root-caused, the attach chain was rebuilt around a raw
root.disk(8136ae6), and Phase-2 boot has been green since 2026-07-23 (rung 2,docs/milestones.md). The full debugging story is indocs/phase2-attach-postmortem.md; current status lives in the README build/test matrix. Keep this file for its observability lessons β its hypotheses are not live.
What we actually knowβ
Stated precisely, because this session has already burned time on three confidently-wrong root causes (ntfs-3g injection, the multi-disk hypothesis, and "the deploy wait has no timeout").
Established:
- Phase 1 works end to end on kanpur. Serial shows
wootc: deployer active, a 19-minute deploy,deployer requested reboot, Windows QGA returning, and the BCD one-shot scheduled with bootsequence verified. - kanpur and himachal both fail Phase 2 identically: emergency shell,
sysroot.mountdependency failed,/dev/disk/by-uuid/<root>absent. - dilli fails differently. Its serial ends at
BdsDxe: starting Boot0003 "Windows Boot Manager" ... bootmgfw.efi. Firmware went straight back to Windows; Phase 2 was never attempted. - The staged payload is complete and correct on disk (
shimx64.efi,grubx64.efi, deployer kernel + initramfs,root.vhdx). ntfs-3ginjection into the target image fails outright ([WARN] ntfs-3g install failed ... relying on the image's own NTFS support).
NOT established, despite earlier claims:
- That Phase 2 has ever booted. Every prior "rung-2 GREEN" predates the
proof-of-life gate (
d92c279) and was not conditioned on any observable. - What actually mounts NTFS in Phase 2 (#35).
- Whether the loop-attach hook is present in the Phase-2 initramfs.
Why we could not tellβ
Three independent observability defects, all now fixed. They are listed because the pattern matters more than the individual bugs: every one was optimistic status not grounded in an observable.
| Defect | Effect | Fix |
|---|---|---|
info()/warn() filtered by printk | hook diagnostics never reached serial | 93e87e1 β write to /dev/console |
ELAPSED counted sleeps, not wall-clock | timeouts ~1.5Γ nominal; misreported minutes | bc504a1 β wall-clock deadlines |
snapshot_serial failed silently | harness read a frozen serial file for 2h | aa00b24 β fail loudly, drop stale copies |
| Phase-2 boot checks were advisory | ALL TESTS PASSED for a boot that never happened | d92c279 β proof-of-life gate |
Hypothesesβ
Branch A β kanpur/himachal: reaches Phase 2, no root deviceβ
Ordered by my estimated likelihood. Each is stated so the next run's serial output discriminates it, because the instrumented hook now announces entry and names a reason at every exit.
A1. Hook present but exits at the karg check. The BLS entry or grub.cfg
did not carry loop= / wootc.host_uuid=. deploy.sh:616 edits loader entries
with sed; if the entry format differs or the edit silently no-ops, the hook
runs and immediately returns.
β Discriminator: EXIT: missing kernel args (loop='' wootc.host_uuid='').
A2. Host NTFS will not mount. Demoted. Confirmed the target image has no
ntfs-3g binary, so the FUSE fallback cannot work β the kernel ntfs3 driver
is the only candidate.
β οΈ Caveat, do not repeat the earlier mistake. Probing /proc/filesystems
inside podman run reports the host's running kernel, not the image's.
So the ntfs3 line observed that way is not evidence that the image's own
kernel has NTFS support. This is the same class of error that produced the wrong
"EL10 lacks NTFS β inject ntfs-3g" root cause. The only trustworthy evidence is
the hook's own runtime probe, which is now printed on the EXIT: line.
β Discriminator: EXIT: cannot mount host NTFS rw ... with the ntfs3= and
ntfs-3g= counts, measured inside the actual Phase-2 initramfs.
A3. qemu-nbd attach fails. Promoted to most likely on 2026-07-18.
The target image ghcr.io/tuna-os/yellowfin:gnome does not contain
qemu-nbd (verified: command -v qemu-nbd β not found). The Phase-2 hook
calls it unconditionally, so the binary must come from the initramfs.
It is staged by a cross-image copy: deploy.sh:618 does
install -m755 "$(command -v qemu-nbd)" .../99wootc-boot/qemu-nbd, taking the
binary from the deployer's environment, and module-setup.sh:28 then
insts it into an initramfs built from the target image. That is a
dynamically-linked binary crossing image boundaries: its glibc and library
dependencies come from the deployer, but the initramfs is assembled from the
target's libraries. A version skew there yields a binary that is present but
cannot execute β which fails exactly like a missing one, silently, at the last
step before the root device would appear.
Two sub-cases worth separating:
- A3a binary absent from the initramfs (staging or
instfailed); - A3b binary present but unable to run (missing/mismatched shared libs).
β Discriminator: EXIT: qemu-nbd failed to attach ..., which now also prints
whether the binary resolves and whether the nbd module is loaded. A3b
specifically needs ldd-style evidence β see step 2b of the plan.
A4. Attach succeeds but the root UUID never appears to udev. Partition scan
did not happen, or the UUID in the BLS entry does not match what is inside the
VHDX. This is the one that would look most like "hook worked fine" β which is
exactly why the hook now prints post-attach partitions and by-uuid contents.
β Discriminator: attach success followed by a post-attach by-uuid list that
lacks the expected root UUID.
A5. Race: the UUID appears after sysroot.mount has already given up.
rd.timeout=120 is set, so this is unlikely, but an initqueue/settled hook that
only fires late could still lose.
β Discriminator: attach success and a correct by-uuid list, yet still
emergency β i.e. all hook output looks healthy.
A6. Hook absent from the Phase-2 initramfs. deploy.sh:698 has a guard that
aborts the deploy if lsinitrd cannot find the hook, and kanpur's deploy did
not abort β but the guard is conditional on INITRD_CHROOT_PATH being set and
lsinitrd existing, and otherwise only logs [WARN]. Deploy logging now reaches
serial, so the guard's own verdict will be visible.
β Discriminator: no attach-loop hook entered line at all, plus the deploy's
guard: lsinitrd listed N entries line.
Branch B β dilli: BCD one-shot did not takeβ
B1. One-shot consumed by an earlier boot. bootsequence is one-shot by
design; anything that boots between staging and the intended Phase-2 boot eats
it, and the next boot goes to Windows.
B2. Secure Boot rejected the chain. shim or GRUB failed validation and firmware fell through to the next entry silently. The Fedora grub prefix and embedded-module work is the relevant history here.
B3. Firmware ignored bootsequence. Some firmware honours BootNext but
not the BCD-level one-shot; dockur's OVMF may differ from the laptops'.
Branch B has the weakest evidence β one frozen serial log, n=1. It should not be theorised about further until a run reproduces it with live serial.
The planβ
Do exactly one Phase-2 run and read it. No code changes to the boot chain first. The point of the last four commits is that a single run should now produce an attributable answer; spending that run is cheaper than any further reasoning from the current evidence.
-
One run on kanpur (Phase 1 is proven there; it isolates Branch A). Read: does
attach-loop hook enteredappear? If yes, whichEXIT:line? If no exit line, what dopost-attach partitions/by-uuidshow? -
Attribute to a single hypothesis above. Do not fix anything until the serial names which one.
2b. Cheap check that can be done BEFORE spending a run (and should be, since A3 is now the leading hypothesis): unpack a deployed Phase-2 initramfs and confirm (i)
/usr/bin/qemu-nbdis present, and (ii) every shared library it needs is also in the image.lsinitrd <img> | grep qemu-nbdanswers A3a; comparing itsNEEDEDentries against the initramfs contents answers A3b. This costs minutes rather than a ~1 hour VM run, and if it comes back negative it explains kanpur and himachal outright. -
Then, and only then, fix. Each branch has a different fix and they are not compatible β A1 is a
sed/BLS problem, A2 is an image-content problem, A3/A4 are a VHDX/udev problem. -
Separately, one run on dilli to see whether B reproduces with a live serial feed. If it does not reproduce, close it as a one-off.
-
Establish a pass rate before believing anything is fixed (#34). N runs at one commit, not one green run. A single pass is what produced the false confidence in the first place.
Explicitly deferred: the raw-vs-VHDX question (#39). If the hook reports a successful attach, VHDX corruption is not our problem and the whole question is moot. Deciding it now would be reasoning ahead of evidence.
Standing rule this came fromβ
Every failure this session was invisible before it was wrong. The recurring bug class is status derived from a timer or an assumption rather than from an observable. When adding any check, ask: what does this assert, and what would it print if the thing it asserts never happened?
Reflection: why the failure rate went up (2026-07-19)
First, a reframe: Phase 2 is not failing more β we stopped reaching itβ
Worth stating plainly because it changes what to investigate. Phase 2 was reached exactly twice, both early on 2026-07-18 (kanpur, himachal), and both dropped to an emergency shell. Since then, not one run has reached Phase 2 at all. Every failure since has been Phase 1 or infrastructure:
| Failure | Cause | Mine? |
|---|---|---|
| 3 runs killed mid-flight | podman system prune -af on live hosts | yes |
deploys stalled at phase: verification | triple /dev/console write saturating serial | yes |
| runs died ~10min after launch | no systemd linger; ssh session teardown | partly |
| deploys timed out at 45m | wall-clock fix made the budget honest, exposing it as too small | yes (consequence) |
| preflight disk failures | no artifact retention; ~60 GiB resident per run | pre-existing |
| kanpur build failures | host podman/unit config drift | no |
So the increase is overwhelmingly my churn, not a product regression. The two genuine product findings (qemu-nbd library mismatch, BitLocker VHDX placement) still stand and are unaffected.
The snapshot hypothesis is well-foundedβ
snapshot_before_deployer() does this:
qga_call freezeβ fsfreeze the Windows filesystems (via VSS on Windows);- copy
data.qcow2(18β28 GiB) to.snapwhile the guest stays frozen β the code comment says "the guest stays frozen until the copy is complete"; qga_call thaw.
Two things make that dangerous, and both are now confirmed by observation:
cp --reflink=always FAILS on these runners. We see
cp: failed to clone ... on every host, so it falls through to
--reflink=never β a full byte copy of 18β28 GiB. That is 10β20+ minutes, not
the instant CoW clone the design assumed. The freeze window was sized for a
reflink.
Windows VSS cannot hold a freeze that long. VSS imposes hard limits (writers ~10s, overall ~60s). A 20-minute freeze is not something the guest will honour; it will auto-thaw or fail the snapshot mid-copy. So:
- the resulting
.snapis not crash-consistent, contrary to the comment; - more importantly, the guest experiences a long freeze and an abrupt timeout-driven thaw under heavy host I/O contention.
The link to Phase 2: an NTFS volume that was frozen and abruptly thawed can be left with the dirty bit set. A dirty NTFS cannot be mounted read-write by ntfs3 β which is exactly the Phase-2 failure we saw, and exactly what the attach hook's own warning says:
wootc: cannot mount host NTFS rw (no ntfs3 and no ntfs-3g). Dirty volume? Boot Windows once and full-shutdown; ...
That is a coherent mechanism from snapshot β dirty NTFS β root.disk never attached β emergency shell. It is a hypothesis, not a proven cause, but it is
the best-supported explanation of the two Phase-2 failures we have.
And the snapshot is never usedβ
data.qcow2.snap is written and never read: the only reference in
run-e2e.sh is inside snapshot_before_deployer() itself. There is no restore
path. So today it costs:
- a 10β20 minute freeze of the guest at a delicate moment,
- ~28 GiB of disk (the direct cause of the preflight failures),
- a large slice of the deploy budget,
in exchange for an artifact nothing consumes.
Recommendation: disable it by default. Not "optimise" β remove from the
default path, behind WOOTC_E2E_SNAPSHOT=1 for when a restore path exists. This
is the single highest-value change available: it removes the most plausible
cause of the Phase-2 NTFS failure, frees ~28 GiB, and shortens every run.
The process failure, honestlyβ
The deeper problem is not any single bug β it is that there is no fast feedback loop for deployer/initramfs changes. Every change costs a 60β90 minute VM run, so I batched several changes per run cycle. When a run then failed, attribution was guesswork, and three times I "fixed" something that was not broken while introducing something that was.
Rules to work by from here:
- One deployer change per run. If two need testing, run twice.
- Prefer changes testable without a VM. The qemu-nbd closure was diagnosed and verified in a container in minutes β that is the model.
- Never touch a host with a live run (prune, kill, disk cleanup).
- A fix for an observability gap is not free. The
/dev/consolechange was meant only to make failures visible and it stalled every deploy. - Verify the observable, not the exit code.
pgrepmatching my own ssh command reported runs as alive that did not exist.
Outstanding risk I introduced and have not testedβ
The Phase-2 attach hook still writes to /dev/console. That is the same call
that stalled deploy.sh β kept here because volume is far lower (a handful of
lines at boot vs hundreds during an install). But the failure mode was a
blocking write, and volume only changes its likelihood, not its possibility.
If Phase 2 hangs with the hook's first line printed and nothing after, this is
the first suspect.