Skip to content

fix(harness): drain sidecar stderr from spawn so a failed boot names its cause - #74

Merged
stuffbucket merged 2 commits into
mainfrom
fix/harness-drain-stderr-from-spawn
Aug 6, 2026
Merged

fix(harness): drain sidecar stderr from spawn so a failed boot names its cause#74
stuffbucket merged 2 commits into
mainfrom
fix/harness-drain-stderr-from-spawn

Conversation

@stuffbucket

@stuffbucket stuffbucket commented Aug 6, 2026

Copy link
Copy Markdown
Owner

A sidecar that dies during boot now says why. SidecarExitedError and the ready-line timeout carry a stream-tagged transcript of everything the child wrote — stderr included — so a red harness leg is diagnosable from the log alone instead of only from a re-run. Previously the log held Sidecar stdout closed before it emitted a ready-line and nothing else, because startSidecar attached its stderr drain only after the ready-line arrived and discarded the startup window unread.

Both pipes are now drained from the moment of spawn. bootLines/logLines still split pre- and post-ready, now covering both pipes; the sink flips synchronously on the ready-line, line drains strip a trailing CR, and a failed boot reaps its child rather than leaking an engine holding a port. superviseSidecar splits the supervise half of startSidecar from the spawn half so the regression test drives the real drain path with a child it launches portably.

tests/sidecar-harness.test.ts pins the diagnostic on both platforms: a child that writes a sentinel to stderr and exits before ready must produce an error containing it.

Measured, not assumed — the undrained pipe was not also a hang here. Under the Bun parent every harness runs as, 4 MB of unread pre-ready stderr passes without the child blocking once (a Node parent wedges between 128 KB and 160 KB), and the engine writes 95 bytes to stderr before ready. The blindness was the whole of it.

@stuffbucket stuffbucket added this to the v0.4.4 milestone Aug 6, 2026
…its cause

`startSidecar` attached its stderr drain only after the ready-line arrived, so
for the whole startup window stderr — where a dying engine says why — went
unread and was then discarded. Both pipes are now drained from the moment of
spawn and split at the ready-line into `bootLines`/`logLines`, and
`SidecarExitedError`/`SidecarReadyTimeoutError` carry the stream-tagged
transcript in their message. A boot that fails also reaps its child.
…on Windows

The regression test pointed `MAXIMAL_E2E_BINARY` at an extensionless shebang
script, which Windows cannot execute — `spawn` threw ENOENT before any of the
diagnostic ran. `superviseSidecar` splits the supervise half of `startSidecar`
out from the spawn half, so the test drives the real drain path with a child it
launches portably, by naming the running interpreter.

Also from the same platform gap: line drains strip a trailing CR, and the
transcript barrier now waits on both pipes running dry as well as the child's
close, rather than assuming an ordering between them.
@stuffbucket
stuffbucket force-pushed the fix/harness-drain-stderr-from-spawn branch from 5e543bd to fe55838 Compare August 6, 2026 04:19
@stuffbucket
stuffbucket merged commit 2820a9e into main Aug 6, 2026
6 checks passed
stuffbucket added a commit that referenced this pull request Aug 11, 2026
* feat(dev): add a local Windows 11 ARM64 test VM on QEMU

`ci.yml`'s `windows` job is the only Windows this repo has, and it is a batch
loop: push, wait, read a log. Every Windows defect here was found that way --
#38, #55, #71, #74, and a `RegExp` built from an interpolated `os.tmpdir()`
that is harmless on POSIX and a hard SyntaxError on Windows. This is the same
platform with an edit-run-observe loop.

`scripts/dev/win11/` is self-contained and project-agnostic: it reads no
manifest and no version file, so the directory can be copied to another repo,
or to no repo. Project-specific inputs are passed in -- `--payload` for what to
stage in the guest, `--expect-file` for what `smoke` should assert, which is
why this repo's smoke names `.bun-version` rather than the tool reading it.

One base image, many thin instances. A Windows install is ~26 GB, so each
instance is a qcow2 overlay backed by a sealed read-only base and holds only
its own deltas -- measured at 192 MB after a boot. Reset is `rm overlay &&
qemu-img create`, measured at 1.3 s, and also discards firmware variables and
TPM state, which live outside the disk image and would otherwise survive.
Instances get their own sockets and VNC display, so several run concurrently
without colliding; verified by writing a file in one and confirming its
absence in the other.

Device choices follow UTM's source, which is the best record of what works for
this guest on this hardware: NVMe for the boot disk (in-box on Windows ARM64,
so no driver injection and no WinPE drive-letter bet), usb-storage CDs, and an
external swtpm. UTM's guest-tools ISO is the practical source of WHQL-signed
ARM64 virtio drivers plus qemu-ga, which is the command channel -- `guest-exec`
over virtio-serial, so no SSH and no listening port. `utmctl` is deliberately
not used: it drives UTM.app over Apple Events and needs a logged-in Aqua
session plus an interactive TCC grant.

Two answer-file details are load-bearing and cost an install cycle each:
`SkipMachineOOBE`/`SkipUserOOBE` silently suppress FirstLogonCommands on 25H2,
and UAC must be off or the guest-tools installer raises a consent dialog that
nothing can answer.

The guest is ARM64 and CI is x64, so this cannot settle timing-sensitive
questions and `bun:ffi` is unavailable there. The doc says so prominently.

* fix(dev): make the Windows base-image build actually run unattended

Three defects, all found by running `winvm build` end to end rather than
assuming the path worked. Each one stalled the install at firmware with no
error, which is the worst shape for a 20-minute operation.

1. Boot order. The empty NVMe disk carried `bootindex=0` during a build, so
   EDK2 tried it, failed, and dropped to a UEFI shell before USB enumeration
   had produced any filesystem -- the mapping table listed the NVMe block
   device and nothing else, so `startup.nsh` was never even found. The install
   media now takes index 0 and the disk index 1, but only while installing.

2. QMP socket race. The key-sender connected the instant QEMU was spawned, but
   QEMU creates its socket a moment later, so the connect failed with ENOENT
   and the boot prompt went unanswered. It now retries for 30s.

3. The boot selector had lost its only working loader. `cdboot_noprompt.efi`
   is valid only as an El Torito boot image; launched from the shell it returns
   `Invalid Parameter`. `BOOTAA64.EFI` does boot, but prints "Press any key to
   boot from CD or DVD" and returns EFI_TIMEOUT unanswered -- which is what the
   key-sender is for. It is restored as the final fallback.

The shell path is not a fallback in the usual sense: on this firmware the
install CD is never registered as a boot option at all, so `startup.nsh` is
the ONLY route to the installer. The comment there says so, because the
noprompt loop looks tidier and is the obvious thing to "simplify" toward.

`sendkey` is an HMP command; issued as plain QMP it returns CommandNotFound.
An earlier revision ignored that response, so hundreds of keystrokes were sent
and none arrived, and the symptom was indistinguishable from a storage fault.
qmp.ts now checks, and records why.

* fix(dev): make the Windows base-image build reproducible from scratch

Found by rebuilding from an empty state repeatedly. Each defect stalled the
install with no error, which is the worst shape for a 25-minute operation.

Setup ignores autounattend.xml on the hdiutil-built ISO9660 seed on 25H2. It
came up interactively at "Select language settings" with the file present at
the ISO root -- indistinguishable from supplying no answer file at all. The
answer file now also rides the FAT removable volume, which Setup has always
honoured.

Microsoft-Windows-International-Core was missing from the oobeSystem pass. The
same component under windowsPE only covers Setup itself, so Windows installed
perfectly and then parked forever on "Is this the right country or region?".

Provisioning moved from FirstLogonCommands to SetupComplete.cmd, which Windows
runs as SYSTEM at the end of Setup before any logon. FirstLogonCommands needs
a user session, runs unelevated (so the guest-tools installer raises a UAC
dialog nothing can answer -- and that dialog focuses No, so blind Enter denies
it), and on 25H2 is silently suppressed by the deprecated Skip*OOBE settings.

provision.ps1 located the payload relative to "the volume containing
provision.ps1", but copies of that script exist on the seed, on the result
volume, and in C:\Windows\Setup\Scripts -- so it matched the wrong one and
staged nothing while reporting success. It now finds the payload directory,
which only ever exists on the seed.

The boot-prompt key-sender now stops as soon as the installer is writing.
Holding Enter down longer put keystrokes into Setup's GUI, where Enter lands
on Cancel and opens "Are you sure you want to quit?" -- wedging a build at 35%.
The prompt only needs answering on the first boot; later reboots find Windows
Boot Manager on the ESP and never reach it.

`smoke` reported the guest as AMD64 on an ARM64 machine. qemu-ga is the x86-64
build, so every process it spawns inherits an emulated x64 environment and
PROCESSOR_ARCHITECTURE lies. Architecture now comes from Win32_Processor, and
the emulated environment is reported alongside it because anything run through
`winvm exec` inherits it.

Verified from an empty state: build completes unattended, base seals read-only
at 13G, an instance boots to a live guest agent, smoke matches .bun-version,
bun self-reports arm64, npm is reachable, and reset restores the base.

* fix(dev): make the Windows VM build and boot deterministically

Every failure this harness had looked identical from outside: a QEMU process
alive, a valid pidfile, and a monitor answering "VM status: running". Each fix
below is a cause found in an artifact, not a timing guess.

The guest now boots to AUDIT MODE rather than OOBE
(Microsoft-Windows-Deployment | Reseal | Mode = Audit). OOBE was not merely slow,
it was undetectable: it runs a temporary account called defaultuser0, and
throughout it qemu-ga answers guest-ping, guest-exec works, and Windows reports
OOBEInProgress=0, SystemSetupInProgress=0 and a running explorer.exe while the
OOBE screen is still displayed. There is no probe that returns "not yet".
Snapshotting in that window livelocked the guest -- all vCPUs at 100%, no disk
I/O, recoverable only by killing it -- and the post-OOBE reboot hung at the
firmware splash indefinitely. Audit mode deletes the phase: every boot lands on
a desktop logged in as the built-in Administrator in about 35 seconds.

The oobeSystem pass now contains ONLY Reseal. The documentation says oobeSystem
settings "do not appear in audit mode", which reads as "they are harmless". They
are not. Left in place, UserAccounts and AutoLogon still created an account and
signed IT in, so the audit logon that audit.exe had arranged to be re-called on
(audit.exe /user) never happened, the auditUser pass never ran, provisioning
never ran, and the guest sat on a finished desktop with no agent and nothing
anywhere explaining why.

The install media's boot prompt is answered on a handshake instead of by
holding Enter down. startup.nsh prints winvm-keypress-needed immediately before
launching the one image that waits for a key; the host waits for that line and
sends five keystrokes. It used to press Enter every 400ms until the disk grew
past 300MB -- roughly 250 keystrokes for a prompt that consumes one. Surplus
keystrokes are not discarded: they queue and are handed to Windows Setup's GUI,
where Enter activates Cancel and opens "Are you sure you want to quit?",
freezing the install with no error at whatever percentage it reached.

A running instance now attaches no USB storage at all, and the CD device lines
follow Linaro's published reference for this guest (qemu-xhci, media=cdrom, no
removable= flag). EDK2 hung enumerating USB with the console stopped dead on
"UsbBootExecCmd: Success to Exec 0x0 Cmd (Result = 1)" and a core pegged; it did
so on an instance carrying only one USB disk, so it is not about how many are
attached. nec-usb-xhci is replaced by qemu-xhci, which QEMU's documentation
recommends and which has no open "broken" bug against it.

Live snapshots are available: winvm snapshot / rewind / snapshots. Three device
changes were required, each announced by savevm refusing and naming the device:
the NVMe boot disk is non-migratable (the disk now runs on virtio-blk, with a
scratch virtio disk attached during the build so viostor installs boot-start);
raw pflash firmware variables cannot hold a snapshot (now qcow2); and the
writable raw result volume could not either (now attached only while building).
Snapshot is deliberately explicit -- start does not take one, because
snapshotting a guest that has not finished starting wedges it.

Builds are verified rather than assumed. "QEMU exited" used to mean success, so
a crash, a kill, or a launch that never happened all sealed whatever the disk
contained into a read-only base image; a silent launch failure sealed an empty
one in 15 seconds. The guest now states its own verdict and the host shuts it
down through the guest agent, because Microsoft warns that terminating the
machine from a Setup script "will put the system in a bad state".

launch() confirms the guest started. QEMU reports a bad command line or an
unreachable socket on stderr and then exits 0, which made a launch that never
happened indistinguishable from a slow boot.

Also: builds refuse to run over a previous build's live guest, reap it on Ctrl-C
and on timeout, and start from a clean scratch directory; serial.log rotates
rather than being erased by the retry that needs it; and provisioning tolerates
the guest agent dropping, which it does when it installs vioserial underneath
itself.

Measured, repeatedly: 3/3 builds clean at ~8 min; boots at 31/31/30/33/34.5s;
rewind at 10.4/10.2/10.3s with the post-snapshot change discarded every time.

winvm diagnose is added in this commit but still misreports some cases; it is
corrected next.

* fix(dev): correct winvm diagnose and hold it to recorded traces

Two defects in the checks, both of which would have taught people to ignore the
tool.

It reported "provisioning left no transcript" against perfectly healthy
instances. Provisioning runs once, in the scratch instance a build creates;
ordinary instances inherit the result in the base image and no longer attach the
result volume at all, so there is nothing to find. Scoped to build scratch
instances.

It called a running build hung. "Firmware stopped at USB enumeration" and "the
boot prompt went unanswered" are claims about time, and both descriptions also
fit a healthy guest ten seconds into an install. Confirmed the hard way: a
successful build's console sits at exactly the same 4180 bytes, ending on the
same UsbBootExecCmd line, as the two genuine hangs -- a live guest passes
through that state and continues to 4883. Those checks now require progress to
have actually stopped: a guest that is not running qualifies by definition, a
running one has to have gone quiet on both its console and its disk.

Adds the check that would have found the worst bug here in seconds instead of
hours: an answer file requesting audit mode while leaving AutoLogon or
UserAccounts in the oobeSystem pass. Windows honours both halves, so audit.exe
enables the built-in Administrator and arranges to be re-called on the audit
logon, and AutoLogon then signs in the answer file's account instead. The audit
logon never happens, the auditUser pass never runs, provisioning never runs, and
the guest reaches a working desktop with no agent and nothing anywhere saying
why. It took reading audit.exe's own log inside the guest disk to find; it is a
static check on a file.

The checks are now held to recorded artifacts in
scripts/dev/win11/tests/traces.zip, run by `bun run test:winvm`. Ten traces,
each a complete state directory: serial logs, QEMU's stderr, image and instance
metadata. Most are captured from real runs; one is reconstructed and says so.
Half are of guests that were FINE, because several signatures here are states a
healthy guest passes straight through and a check that cannot tell the
difference is worse than no check.

The archive is a zip, not a folder, and that is deliberate. These are frozen
pictures of BROKEN configurations, and loose in the tree they sit next to the
working code as a trap for anyone -- or anything -- reading it to learn how this
is supposed to work. Zipped they stay retrievable while diagnosing and invisible
otherwise. It also carries index.md and the long-form notes, which move out of
the tree for the same reason. Standard zip, no macOS extended attributes, so it
opens on Windows and Linux too.

The test logic is deliberately thin: every expectation lives with its trace in
that trace's expected.json, so this never grows a second copy of the knowledge
in diagnose.ts. Adding a failure mode means adding a directory to the archive.

Also drops snapshot.hasLive, dead since start stopped taking an automatic
snapshot, and routes `winvm snapshots` through the same row parser whether the
guest is up or down so its output does not change shape.

* fix(dev): locate QEMU's firmware instead of assuming a Homebrew prefix

The binaries were already found through PATH, and then their data files were
read from a hard-coded `/opt/homebrew/share/qemu`. That is the one place the
tool assumed where QEMU lives, and it is wrong on an Intel Mac (`/usr/local`),
under MacPorts or Nix, or against a QEMU built from source — cases where
`qemu-system-aarch64` itself resolves perfectly well and only the firmware
lookup fails.

It was also wrong here, and silently. `doctor` now reports

  /opt/homebrew/Cellar/qemu-spice/11.0.3/share/qemu/edk2-aarch64-code.fd

because this machine runs qemu-spice; the old path only worked by symlink.

`qemu-system-aarch64 -L help` prints the directories QEMU searches, which is the
authoritative answer and costs one process. Falling back to the binary's own
location covers a QEMU too old to support it, and WINVM_QEMU_DATA overrides both.
Debian's and Linaro's names for the same images are checked alongside QEMU's,
which is free and harmless when absent.

When nothing is found the error names what was looked for and where, instead of
reaching QEMU as the string "null" and returning `Could not open 'null'`.

Verified: doctor reports the discovered path; an explicit override is honoured;
a bad override produces an actionable message; and the guest boots (33s), smokes,
snapshots and rewinds on the firmware found this way.

* feat(dev): vendor the UEFI firmware, pin the guest tools, and manage disk

Three gaps, all of them friction rather than defects.

VENDOR THE FIRMWARE. `firmware/` now carries the EDK2 AArch64 images, so the
harness no longer depends on a system QEMU having laid its data files out any
particular way. They are BSD-2-Clause-Patent, which permits binary
redistribution with the notice reproduced; `THIRD-PARTY-LICENSE.md` lives beside
them rather than at the repository root, because this tool is meant to be copied
out on its own and its obligations should travel with it.

Stored gzipped: the images are ~99% zero padding, so 128 MiB becomes 1.6 MB, and
they are expanded into the state directory on first use. Verified byte-identical
to QEMU's own copies, and a guest boots on them (34s, smoke, snapshot, rewind).
WINVM_QEMU_DATA still overrides, and discovery via `-L help` remains the fallback.

PIN THE GUEST TOOLS RATHER THAN VENDOR THEM. The UTM ISO is ~121 MB, does not
compress (98 MB gzipped — its payload is already compressed), and its installer
bundles qemu-ga under GPL-2.0. Shipping it would oblige this project to keep
corresponding source available for as long as it did, which is a standing duty
for a developer test fixture. It is downloaded and verified against
TOOLS_SHA256 instead. The URL says "latest", so upstream will eventually publish
a new build and the digest will stop matching: the error names both digests and
says to review and update the pin, because the pin's job is to DETECT drift, not
to pretend it cannot happen.

MANAGE IMAGES AND DISK. Instances had a full lifecycle and images had none —
bases are sealed 444, so removing one meant `rm -rf` by hand, which is what
building this branch actually required four times.

  winvm rmi <image>    undoes the seal and removes it; refuses while an
                       instance still names it as its backing file, since
                       deleting it leaves an overlay that cannot be opened
  winvm prune          drops leftover build scratch instances; --media also
                       clears the cached tools ISO and seed, both of which are
                       re-fetched or rebuilt
  winvm ls             now accounts for media and prints a total

`winvm setup` installs missing prerequisites and fetches the pinned media, and
is deliberately separate from `doctor`, which stays a pure check — as this
repository's own preflight puts it, "A check that silently mutates the
environment is its own defect".

Fixes a bug the new listing exposed: `du()` took the first line of `du -h`,
which for a directory is a subdirectory rather than the total, so a state
directory holding a 14 GB image reported 128 MB. It only ever measured single
files before.

* feat(dev): find the Windows ISO instead of demanding it every time

`build` accepted `--iso` and otherwise failed. The path was already remembered
after the first run — it is symlinked into the state directory, never copied and
never committed — but there was no way to declare it, nothing looked for it, and
nothing asked.

It now resolves in order: `--iso`, `WINVM_ISO`, whatever a previous build
recorded, then `~/Downloads`, `~/Desktop` and `~/vm` for a file whose name says
arm64, and only then a prompt. The prompt is gated on a TTY: a scripted build
should fail saying what it needed, not block forever on input nobody can give.
Several candidates are listed rather than guessed between.

The ISO stays the user's file wherever they keep it. At ~8 GB it is symlinked,
so configuring this never puts a path into the repository — `WINVM_ISO` in a
shell profile, or answer once and forget.

Also checks the media before starting: Windows ARM64 install media carries
\efi\boot\bootaa64.efi and an x64 ISO does not, so pointing at the wrong
download now fails in about a second instead of twelve minutes into an install.
A mount that fails proves nothing and passes with a warning.

Directory listing during discovery cannot throw. `~/Downloads` and `~/Desktop`
are TCC-protected on macOS: scanning them from a terminal without Full Disk
Access raises EPERM rather than returning nothing, which aborted the search with
a stack trace — in exactly the folder people keep the ISO in, on a first run.
Found by running it.

* fix(dev): stop searching TCC-protected folders for the Windows ISO

ISO discovery looked in ~/Downloads and ~/Desktop, which are TCC-protected on
macOS. Merely attempting to read them raises a consent dialog against whichever
terminal is running the build — a background build asking for permission to your
Downloads folder is not acceptable behaviour from a dev tool, and catching the
EPERM afterwards does not prevent the prompt, only the crash.

It now searches ~/vm, ~/isos and the state directory, none of which are
protected. Anywhere else is named with --iso or WINVM_ISO, which was always the
supported way to keep the ISO somewhere of your own choosing.

The non-throwing directory listing stays: a missing root, a dangling symlink or
an odd permission bit should skip that folder rather than abort someone's first
run with a stack trace.

* fix(dev): resolve the CodeQL findings on the winvm harness

Four alerts, all real, all in code this branch added.

Dead code in `build`. Rewriting the completion path left the previous
hour-timeout tail sitting after a `return`, so several lines including a `reap()`
were unreachable. Harmless only by luck — `guestFailed` already reaps before it
reports — but it read as though the timeout path did its own cleanup, which is
exactly the confusion that lets a guest be left running.

Two time-of-check/time-of-use races. `existsSync` followed by `readFileSync` or
`statSync` is a window, and these particular files are written by a QEMU process
running concurrently: the firmware console, QEMU's stderr, and the expanded
firmware images. Attempting the operation and handling failure has no window and
is shorter.

Incomplete comment stripping in the answer-file check. Removing `<!-- ... -->`
in a single pass can splice the surrounding text into a fresh `<!--`, leaving a
comment behind that the check then reads as live configuration. That matters
here specifically: the check exists to match ELEMENTS rather than mentions, and
this answer file discusses AutoLogon and UserAccounts by name at length in its
comments, so a surviving comment produces a false report of the exact conflict
it is looking for. Demonstrated:

  input           <!<!-- x -->-- <AutoLogon> -->
  single pass     <!-- <AutoLogon> -->        (check would fire)
  repeated        (empty)                     (correct)

Stripping now repeats until nothing changes, which terminates because every pass
strictly shortens the string.
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