Skip to content

review: 5 fixes, docs from hardware, and tests that prove what they name - #302

Merged
CMGS merged 15 commits into
masterfrom
review/test-and-flow-audit-2026-11-04
Nov 4, 2026
Merged

CMGS merged 15 commits into
masterfrom
review/test-and-flow-audit-2026-11-04

Conversation

@CMGS

@CMGS CMGS commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #301: the 133 test files that round did not read, two cross-file flows traced end to end, and a hardware run of both. It found five small production bugs and tests that could not catch what they name. No new mechanism.

Production fixes

Commit Bug
fix(metering) A verb canceled by Ctrl-C or a timeout lost its metalog event: Emit used the caller's context after the state change had committed. It now runs on context.WithoutCancel.
fix(cloudhypervisor) A vm net that timed out on the guest's eject, or died between adding a NIC and recording it, leaves the VMM and the record out of step. A hibernate from there succeeded and could never wake: restore refuses the NIC count on every retry, and vm net needs a running VM. The capture now refuses before pausing.
fix(firecracker) A vm net that commits while a restore waits on the ops lock passed the prefix check and left a record with more NICs than the guest. The count now has to match, as on Cloud Hypervisor.
fix(snapshot) The same race on a zero-NIC snapshot still slipped through: omitempty dropped the empty nic_macs, so it read back as "no MACs" and skipped the check. omitzero keeps the empty list. Sidecars from before v0.7.6 have no key and still skip the check rather than being refused.
fix(metadata) On cloud images, a mounted data disk got fstab pass number 2. When the host mke2fs sets a feature the guest e2fsck does not know (Debian 13 host, Ubuntu 22.04 guest: orphan_file), the second boot failed the fsck and stopped in the emergency shell with no network or sshd, despite nofail. Pass number 0 now. v0.7.5 does the same; the recovery for existing VMs is in docs/known-issues.md.

Docs: when a clone-added disk mounts, Windows shutdown during background servicing, metering entries lost to a SIGKILL, and the fsck recovery, each from a hardware run. Also: ensureVmlinux moves beside its helpers, Cloud Hypervisor drops a NIC count check ValidateRestoreMACs now makes with the same message, and uuidV5 and start.sh stop claiming to match each other (Go uses the URL namespace, the script DNS; nothing depends on them matching).

Hot-path cost: nothing on start or clone. WithoutCancel is one small allocation per Emit. The NIC check is an integer compare before the balloon wait.

Tests

  • Tests that passed for the wrong reason now prove what they name: GC scope removal, CPU count parsing, metering pairing, delete, convert resume, the custom ledger path, log debounce, daemon cache and reconcile, the flock holders, cgroup rename, the detached transaction, kill-storm ACKs, torn generations, and the FAT image, which is now parsed.
  • The fake VMM was sh -c "sleep 60; :", which forks a child with the same cmdline. Under load the process scan flaked: 22 of 60 runs failed with 48 CPU spinners, 0 of 60 with the new exec -a fixture and a wait for the exec.
  • startFakeVMM, writeTwoGenerations and reapChild replace copies. TestUUIDV5Vector pins a vector.

Evidence

Gates at 19e258a8, and again on the tree this branch has after its rebase onto ba2020c4 (#303); that tree was first computed with git merge-tree and is identical to the rebased head: make lint 0 issues on both GOOS, make fmt-check clean, asl clean on both GOOS, go test -race -count=1 ./... in a linux/arm64 golang:1.27 container: all 38 packages ok.

Hardware, bare metal, Cloud Hypervisor and Firecracker dev builds:

Run Result
Functional regression on 09485b7d, before #301 110 pass, 4 fail: all four are the cloud image clone with an added disk, the bug #301 fixed
Same regression on 19e258a8 114 pass, 0 fail
Upgrade: VMs and snapshots made by v0.7.5, driven by 19e258a8 49 pass, 0 fail

Second round, on a binary built from the rebased tree minus the last docs commit: regression 114 pass 0 fail, interrupted-delete metering 3 of 3 paired, upgrade from v0.7.5 49 pass 0 fail.

Check Result
Zero-NIC FC --pci snapshot, vm net --nics 1 racing vm restore before: record 1 NIC, guest 0, restore exit 0 on the first try. After: 40 of 40 refused. vm net --nics 0 then --nics 1, or a stop and start, heal the old state
Cloud image VM with a mounted data disk, second boot before (v0.7.5 and 19e258a8): emergency shell. After: second and third boot mount and keep data
Clone of a clone with a clone-added disk mounts after the printed post-clone commands, and after a cold start
Live NIC missing from the record, vm hibernate and vm migrate capture both refused with rerun vm net first; guest keeps running; vm net --nics 1 heals
vm migrate capture, delta, final, receive on one host guest process and data carried over
CPU count with process affinity 4 on a 384-core host max_vcpus 384. With /sys/devices/system/cpu/online bind-mounted to 0-15: max_vcpus 16 and --cpu 17 refused
Windows 11: cold, restored, clone after 35s and 120s, graceful stop 9.1, 17.3, 9.9, 9.4 s, no escalation. v0.7.5 gives 8.4, 11.5, 10.0, 9.5 s

Interrupted delete, metering pairs after the delete is finished:

Finished by 09485b7d 19e258a8
gc vm.storage unpaired paired
retried vm rm paired paired
next verb vm.storage unpaired paired

Live NIC missing from the record, added through the VMM API:

Backend Binary Result
Cloud Hypervisor 35f884ac hibernate exits 0; wake fails on every retry with snapshot has 2 NICs, vm has 1; vm start boots cold and loses the memory image
Cloud Hypervisor 19e258a8 hibernate fails with has 2 NICs but its record has 1; rerun vm net first; the VM keeps running; vm net --nics 1, hibernate and wake succeed
Firecracker 19e258a8 hibernate, wake and clone all succeed, so Firecracker gets no capture check; vm net --nics 1 also removes the NIC

A/B, p50 ms, n=20 (hibernate and restore n=10), arms swapped between runs:

Op Run 1: 35f884ac / branch Run 2: branch / 35f884ac
clone 61.7 / 60.8 61.2 / 61.5
hibernate 273.8 / 264.0 270.9 / 251.9
restore 128.6 / 129.5 131.2 / 136.8
rm 69.3 / 68.5 70.3 / 70.4
start cold 91.4 / 91.3 91.8 / 91.4
start running 67.7 / 67.8 69.4 / 68.9
stop 110.0 / 113.4 109.7 / 108.2

No regression. Hibernate's second arm reads faster whichever binary it runs, so its gap is run order, not code.

Size

Production Go +51 −48 (net +3), plus meta/contracttest scaffolding +17 −8. Tests +462 −351 (net +111). Comment lines in production +2 −3: one moved with its function, one corrected, one removed.

Not covered

  • The metering fix has unit tests only. Killing the process just before each emit, across 13 verb positions, shows a SIGKILL there loses the entry and a retry does not write it again; a retried restore can also repeat a stop. An outbox would close it and was rejected as too much mechanism; docs/daemon.md now says downstream must tolerate it. 600 random kills of an unmodified vm stop and vm start left no unpaired compute entry.
  • Existing v0.7.6 zero-NIC FC sidecars have no nic_macs key and keep the race window.
  • A Windows VM can still pass the 30 s stop timeout during background servicing: after 15 minutes of uptime a cold-booted VM took 20.6 s with TiWorker running, and one clone took 73 s (its processes were not sampled). The power-button event is delivered; docs/known-issues.md says to raise stop_timeout_seconds.
  • Guest networking on the Firecracker clone with the extra NIC was not checked; the clone answered over vsock.

@CMGS CMGS changed the title review: 3 fixes and tests that prove what they name, from the test and flow audit review: 5 fixes, docs from hardware, and tests that prove what they name Nov 4, 2026
CMGS added 15 commits November 4, 2026 18:57
With the meta backend, a SIGTERM during a commit canceled the context that Emit
then used, so the committed delete's stop events were never written and nothing
could write them later. The file backend ignores the context.
…gree

A vm net that timed out on the guest's eject, or died between adding a NIC and
recording it, leaves them out of step. A hibernate from there could not wake:
restore refused the NIC count on every retry and vm net needs a running VM. The
capture now refuses before pausing, while rerunning vm net still converges.
A vm net that commits while a restore waits on the ops lock passed the prefix
check, leaving a record with more NICs than the guest and no error. The count
now has to match, as on Cloud Hypervisor. A sidecar without MACs still skips it.
… their regressions

Drive the GC refusal rows through a seam on the real errno, read the CPU count from a path a test owns, run the start-all row through StartSequence with a live VMM, and stop calling t.Fatal off the test goroutine.
The sh fixture forks and its child shares the cmdline, so under load the scan saw two pids or none: 22 of 60 runs failed with the old fixture under 48 CPU spinners, 0 of 60 with this one.
Vary the fields an unchanged pass must ignore, assert the converge timestamp, take one flock handle per holder so the mutual exclusion is real, and remove three sparse-copy duplicates.
…parse the FAT image

DetachedValues never committed on the json model, a worker that acked nothing passed the kill storm, TestCorruptBothGenerations tore only one generation, and the cidata tests searched raw bytes.
…ors in snapshot fixtures

uuidV5 uses the URL namespace and start.sh the DNS bytes, so neither matches the other or the comment that said so; nothing depends on them matching.
…without MACs

omitempty dropped an empty nic_macs, so a zero-NIC snapshot read back as nil and
skipped the count check: a vm net add that committed while the restore waited on
the ops lock left a record with one NIC and a guest with none, and exited 0.
omitzero writes the empty list. Sidecars from before v0.7.6 have no key and still
skip it.
A data disk formatted by a newer host mke2fs carries features an older guest
e2fsck refuses. With pass number 2 the guest's next boot failed the fsck and
systemd-fsck started emergency.target, so the VM came up with no network and no
sshd despite nofail. Seen with a Debian 13 host and an Ubuntu 22.04 cloud image on
every second boot of a VM with a mounted data disk, the default on cloud images.
Pass number 0 leaves the disk to ext4 journal replay at mount.
…g entries and the fsck recovery

Each statement comes from a hardware run: when a clone-added disk mounts, how long
Windows maintenance holds a shutdown, what a SIGKILL before an emit leaves in the
ledger, and how an existing VM recovers from the data disk fsck.
@CMGS
CMGS force-pushed the review/test-and-flow-audit-2026-11-04 branch from dcd7451 to e936738 Compare November 4, 2026 10:57
@CMGS
CMGS merged commit 3b885b8 into master Nov 4, 2026
4 checks passed
@CMGS
CMGS deleted the review/test-and-flow-audit-2026-11-04 branch November 4, 2026 11:04
CMGS added a commit that referenced this pull request Nov 5, 2026
#312)

* test(meta): exercise the watch-error branch on a synthetic watcher

The json engine test sent on fsnotify's own Errors channel, which kqueue
closes behind a pipe the race detector cannot see, so go test -race failed
on darwin. The branch belongs to Broadcaster; test it there and drop the
json notifier's test-only watcher field.

* fix(cloudhypervisor): a detach or NIC removal retry re-requests a pending eject

vm.remove-device drops the device from the VM config at once but keeps it in
device_tree until the guest ejects. A vm net retry after an eject timeout saw
no live NIC, deleted the TAP and truncated the record without asking the
guest again; fs and device detach retries failed with "not attached".
LiveNICs now lists pending NIC ejects unplaced so reconcile re-requests and
waits, and fs/device detach find the id in device_tree as disk detach does.

* fix(firecracker): capture refuses a live NIC count off its record

A vm net add killed between the device PUT and the record append left FC
with an unrecorded NIC that a snapshot then captured bound to the source TAP
name, so every clone of it failed or reused the source TAP. Cloud Hypervisor
refuses this since #302; the FC --pci pause check now does too.

* fix(cloudhypervisor): fsync cidata before the create commits its record

FinalizeCreate commits the record durably while cidata.img sat in the page
cache. A host reboot in that window left an empty or zeroed cidata the next
start still attached, so cloud-init silently got no user or network config
and nothing regenerates it. Cost: one fsync of a 1 MiB file per cloudimg
create or clone.

* fix(vm): logs -f no longer reprints a short log head as it grows

The truncation check compared the first 64 bytes for equality, so a log
shorter than that at open looked truncated on its next append and was
copied again from offset 0. A head that still starts with the old one is
growth, not truncation.

* fix(daemon): shutdown ends open events streams at once

Request contexts did not derive from the daemon context, so Shutdown waited
the full 2 s timeout whenever a /v1/events subscriber (sandboxd always is)
was connected, and the stream handler outlived it.

* fix(vm): console exits on signal with a direct-boot PTY

f.Fd() switched the CH PTY to blocking mode, so closing it on SIGINT or
SIGTERM no longer woke the pending read: the command hung until the guest
printed and left the terminal raw. Take the fd through SyscallConn instead.

* fix(daemon): api-socket-mode help shows the default in octal

The help printed (default 432), the decimal form of 0660, so a user copying
the style typed 660 and got mode 0o1224. The flag now defaults to 0, which
the daemon already maps to 0660, and the help names the octal form.

* fix(snapshot): import stops reading its archive on cancel

Every other streaming verb closes its source when the context is canceled;
import did not, so SIGINT or SIGTERM waited out the whole extraction.

* fix(images): rm reports what an earlier backend deleted before a later one fails

With refs owned by both backends, a failure in the second returned before
the log loop, so refs the first had already removed were never reported.

* fix(vm): status --event aligns its header with the rows

The header went straight to stdout, outside the tabwriter that pads the
rows, so its columns never lined up.

* fix(cni): a failed re-plumb no longer frees recorded NICs' IPs

The rollback ran CNI DEL on every NIC it tried, including recorded NICs a
start or restore had just rebuilt with their saved IP. That released the IP
in IPAM while the records kept it, so a create elsewhere could take it and
every later start failed with "requested IP not available". A recorded NIC
now only loses its TAP; the next start re-plumbs it.

* fix(firecracker): keep the VMM's stderr in its process log

Firecracker's stderr went to /dev/null, so an error it prints before its
logger starts (an argument it rejects, a bad config) left only the API
socket timeout and an empty vm logs. Cloud Hypervisor already writes
stderr to the process log.

* cut(cmd): drop the hand-written completion command for cobra's default

Cobra adds the same completion command when none is defined, with bash
completion V2 and --no-descriptions; cocoon completion <shell> keeps
working as docs/install.md shows.

* review: comments and messages that contradict the code

- restore marker: start clears a stale marker since #311, so the
  FinalizeRestore warning, the restoreDirtyName comment and the apply
  quarantine comments described a refusal that no longer happens
- create, gc, netresize, localfile Delete, types.VM runtime and placement
  comments rewritten to what the code does
- disk name error names the reserved cocoon- prefix it actually rejects
- drop design-doc section refs (§N) that point outside the repo, and
  comments that restate the code

* cut(images): drop nil-entry guards no writer can produce

Index entries come only from materialize (&e, never nil, a JSON null
decodes to a zero entry) and the two backends' commits, and removal uses
delete(); the nil checks date from the whole-file index.

* cut(types): drop nil-receiver guards on the VM network resolvers

Every caller passes a non-nil VM: the embedded record VM, or a FindVM
result that is non-nil on success.

* cut: drop the nil-config guards in the backend constructors

Every caller passes a loaded config: cmd/core here and cocoon-macos's cni.New (built from netConf, never nil).

* cut(hypervisor): ToVM stops cloning a map nothing else holds

Both callers hand ToVM a record meta decoded fresh for this call; the clone
dates from the in-memory index.

* cut(cgroup): placeScope stops reading a scope it just created

Since #306 Prepare always places into a freshly made scope, whose
cpuset.cpus is empty, so the already-placed shortcut never fired and cost a
sysfs read on every pinned launch.

* cut(tombstone): drop the payload kind nothing reads

Recovery reads only Payload.Mode and Payload.Cleanup; Kind was written on
every lease and never consulted. Old rows that carry it still decode.

* cut(sqlite): the events notifier drops its private cancel context

PRAGMA data_version waits at most busy_timeout and Conn.Close already waits
for an in-flight check, so the cancel plumbing guarded nothing and repeated
cancel() in four cleanup tails.

* cut(meta): encode the log cursor without a json.Marshal that cannot fail

A uint64 marshals to its decimal digits; strconv.AppendUint writes the same
bytes with no error branch.

* docs: align docs and help text with the code

- vm.md: a failed start stays error until the next successful start; a
  restore that fails before it stops the VM leaves no marker
- networking.md: start and restore finish an interrupted vm rm instead of
  rebuilding NICs; FC guest hints print after every resize
- daemon.md: a leased delete tombstone rolls back; SSE events are named
  change with the kind in type
- cli.md: restore admits error VMs, --dns takes semicolons, --config,
  reseed skips, placement cleared only on stop, one FC console client
- devices.md: fs tag and device id patterns; FC hints print on every call
- gc.md, known-issues.md, snapshots.md, index.md: cni orphan records, a
  killed snapshot save, eject re-send on every guest, hibernate snapshot
  lifetime, SIGWINCH only on the CH PTY console
- install.md: v0.7.7 release URLs
- vm run --queue-size and vm debug --balloon help name CH-only and auto

* build: least-privilege tokens for build, lint and test; drop dead CI branches

- build, lint and test workflows declare contents: read like the release
  and os-image workflows already do
- build-os-images: no os-image/windows dir and no .skip-generic-build
  marker exist any more
- meta-upgrade.py reads and writes the json as UTF-8, matching Go
- vm-inspect.sh: grep -c already prints 0, so || echo 0 printed a second
  line

* docs: FC clone MAC conflict, daemon convergence scope, local root_dir

- known-issues: an FC clone sends from the source's guest MAC through the
  unchanged TC redirect, so the bridge table flaps; cloud-hypervisor#7356
  closed unfixed
- daemon.md: dead-VMM convergence applies to running records or an open
  interval; the 0750 run dir is the daemon's only when it creates it
- install.md: the sqlite engine refuses NFS, CIFS/SMB and FUSE roots

* fix(scripts): meta-upgrade refuses an in-flight convert on json roots too

Only the sqlite branch checked for the conversion manifest, so a json root
whose json-to-sqlite convert had saved its manifest but not yet created
meta.db got its namespace files rewritten under the manifest's pinned
digests, and the resumed convert then failed as a changed source.

* fix(images): gc logs a blob as collected only when every remover succeeded

collectBlob printed "collected" before returning remover errors, so an
EACCES or EBUSY left both a success line and a failure for the same blob.

* docs: migrate capture rounds and the paused guest after a killed capture

A capture killed after its last write leaves a gap in round numbers whose
files the next round does not list; migrate capture pauses like save and
hibernate.

* fix: stop returning a variable alongside the call that assigns it

`return id, b.view(ctx, fn)` where fn sets id relies on id being read after
the call; the spec leaves the order between a plain operand and a call in
the same return unspecified. gc happens to read it after, so nothing fails
today. Take the error first in the seven read paths that did this.

* review: name the PID and record hook func types spelled out more than once

func(pid int) error appeared in three signatures and func(*VMRecord) error
in three; /code rule 8 gives a repeated func type a name.

* review: FC clone vocabulary types precede the method-bearing plan type

Layout rule: method-less vocabulary types cluster ahead of the first
method-bearing type in the file.

* cut(vm): reseed stops re-inspecting a VM whose result already says no vsock

Clone and restore results are built by SetRunningSockets after resume; the
extra Inspect repeated the same vsock socket existence check and cost a meta
read and a process check per clone or restore of a VM without vsock.

* review: shellUnsafe also guards the sh -c runcmd lines

The non-root runcmd interpolates the password raw into sh -c; the comment
named only the YAML scalar.

* review: two comments the code moved past

refuseUnrecordedDevices now also refuses an unrecorded NIC, and the store's
data dirs come from utils.EnsureDirs, not MkdirAll.

* fix(cloudhypervisor): narrow the eject re-request to vm net, without a wait

Review of the previous commit found two regressions:
- device detach accepted any id present in device_tree, so --id _disk0 or a
  NIC id hot-removed that device instead of failing as not attached; VFIO
  detach goes back to Config.Devices
- listing pending NIC ejects as orphans made every vm net on a Windows clone
  with a stranded hot-swap eject wait 30 s and fail
vm net now re-sends vm.remove-device once for each pending NIC eject and
goes on, so a retry after an eject timeout still asks the guest again.
docs/daemon.md: a delete tombstone rolls back only while leased.

* test(hypervisor): refusal tests assert the refusal they name

The quarantine, interrupted-restore and creating tests passed on any error,
and the delete-close test counted entries without checking their kinds.
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