Skip to content

review: 8 fixes, cuts, docs against the code - #304

Merged
CMGS merged 25 commits into
masterfrom
review/whole-repo-2026-11-04d
Nov 4, 2026
Merged

CMGS merged 25 commits into
masterfrom
review/whole-repo-2026-11-04d

Conversation

@CMGS

@CMGS CMGS commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Whole-repo audit at 3b885b8. Every tracked file was read in full (389 Go files, docs, scripts, CI config), except go.sum, LICENSE and the first 40 lines only of 9 testdata files. Seven cross-file flows were then traced window by window. Most of the serious bugs came from the flow pass, not the per-file pass.

Fixes

Data loss

  • convert refuses a running daemon (679883b). meta convert under cocoon daemon --gc-interval left the daemon reading retired json as empty, or the renamed database. Its next sweep treated VMs as orphans, killed the VMM and deleted the COW disk. Reproduced in both directions with the real convert.Run and GC module.
  • a TarDirStream failure between entries reaches the reader (c94d958). PersistSnapshotDir's EXDEV fallback could finalize a snapshot with files missing and delete the source. Present since 71fefed.
  • the json backend refuses a root whose meta.db holds the records (04b65c3). Setting meta_backend=json before converting hid every VM from GC.

Silent VM damage

  • a capture waits for the guest to eject a swapped NIC (7f78a58). A snapshot taken right after a CH clone saved a half-ejected NIC, and every later generation inherited it. In 4 of 5 rounds the snapshot carried it, and one clone came up with no NIC in the guest while vm clone reported success. Afterwards 8 of 8 rounds are clean. On timeout the capture warns and goes on, so descendants of an already poisoned snapshot stay capturable.
  • migrate receive --id refuses a busy id (ba3f005). It could adopt a tombstoned placeholder, and roll-forward then destroyed the received VM.

Host safety

Smaller

  • b77c198f: convert checks the target filesystem before the manifest is durable, so a json root on NFS no longer locks itself out.
  • 0d0e5013: a VM that cannot converge no longer wakes its own daemon about 4 times a second.
  • d7146d58, 7e20c1e4: cocoon-check --subnet no longer generates the ipMasq setting that known-issues says leaks a rule per VM. Each run now checks the subnet's masquerade rule, and --fix adds it, so a reboot or a failed insert is reported and a rerun converges.
  • 0e116223: a failed migrate round resumes before it ends the series.
  • 7d026caa, 1be2f69e, c576e5d7, 60896a44: FC PTY master close, vm debug max vCPU, netns thread restore, script annotation.

Each Go fix has a regression test that fails without the fix, except the vm debug one-liner, ba3f0057 and c576e5d7. ba3f0057 has a killed-receive retry leg on hardware, which covers the retry but not the busy branch. c576e5d7 was probed in a privileged container only.

Cuts, tests, review

  • 97864be4 inlines single-caller HandleStopResult and removes cloudimg's rename of a qcow2 v3 source.
  • 81e59cdf makes tests prove what they name and removes duplicates.
  • 522bac52 is the comment budget, naming and modern-idiom pass.

Docs and tooling

  • 33125b67, b8571e3b: docs match the code; the release pin moves to v0.7.6; duplicated sections become links.
  • eaec44ed: goreleaser ids; fmt-check local prefix; .gitignore no longer hides cmd/storebench; cocoon-check no longer requires ch-remote; vm-inspect.sh reads the netns from the record.

Latency

The clean A/B on an idle NUMA-pinned host caught a regression from this branch: the first form of 3ff73bb placed before recovering the network, which cost 6-7ms per restore. bf83dbe4 restores the order. The root cause of that cost was not found. The trade-off is a 1-2ms gap for a stopped-origin restore between un-quiesce and the placement commit, with no flag.

restore, n=60, permutation-ordered v0.7.6 3b885b8 head
p50 ms 129.3 127.9 126.7
mean ms 125.9 124.4 126.4

One unexplained detail: split by position in the iteration, head is 128.4 at position 0 against about 117 for the other two, and faster at positions 1 and 2. Each cell has n=20. Hibernate shows no regression. The clone, stop, cold start and rm numbers come from the earlier build and stayed within noise with labels swapped.

Hardware verification

Run on an idle bare-metal test host, NUMA-pinned, at bf83dbe (the later 7e20c1e only changes doctor/check.sh), with CH dev 35b098c65260 and FC dev 71b5754994de.

Leg Result
Functional regression 113 pass, 1 fail: a contrast check written for v0.7.5, which v0.7.6 already refuses
Kill at VMM exec, deterministic head 12/0. v0.7.6 reproduces 9/9: quiesce_pending=False, eth0 still up after vm stop
Zero-delay snapshot after clone, plus a chain poisoned by v0.7.6 81/0
Live migration, including a killed capture and a killed receive 36/0
meta convert under a live daemon 13/0
In-place upgrade from v0.7.6 49/0

Not verified on hardware:

  • The daemon legs saw eth0 down at the first poll, so they rely on the nine no-daemon runs for proof that eth0 was up after the kill.
  • Random-timing kill loops hit the window 0 times on both binaries and are not counted as evidence.
  • The check.sh change ran in a privileged container's own netns rather than on a test host, because shared hosts' cni0 is off limits: fresh --fix, an idempotent rerun, a flushed POSTROUTING reported as FAIL, and --fix restoring the rule. A shim that fails every iptables -A gives WARN and no FIXED for that rule, and a healthy rerun adds it. The real xtables lock was not exercised, because Debian's iptables-nft does not take it.

Gates

  • make lint: 0 issues on GOOS=linux and darwin. make fmt-check is clean. asl reports zero findings on both GOOS.
  • go test -race ./...: all 38 packages with tests pass in a privileged linux/arm64 container at bf83dbe.
  • On darwin, an earlier tree (before d7146d5 and bf83dbe) passed except the known fsnotify kqueue race in meta/json, which is in third-party code (backend_kqueue.go:443).
  • Codex review reports no blocking findings.

Known and not fixed

For a maintainer to decide

  • Snapshot chains already poisoned on running hosts do not heal; this fix only stops new ones. Production hosts were not inspected.
  • A round after --final plus a resume can silently lose punched blocks. docs/snapshots.md forbids that sequence; closing it adds a FICLONE per disk to final-round downtime.
  • os-image fixes are held back because any os-image/** change rebuilds and publishes images on merge:
    • no-op ! assertions in verify-vm-inner.sh, including an internal network prefix;
    • tautological assertions in validate-image.sh;
    • stale labels and an old CH version in start.sh;
    • an unpinned picoclaw download.
  • Reported, not applied, because they change logic: merging tombstone Acquire and MarkDeleting, and one RTM_SETLINK for CNI TAP setup.

Bounded, no code

  • snapshot save racing vm rm --force.
  • A killed launch's placement row.
  • A CNI plugin that ignores the requested IP on recovery.
  • Without the daemon, nothing quiesces automatically until the next verb on the VM.

CMGS added 13 commits November 4, 2026 19:20
TarDirStream closed the tar writer after a TarDir error, so a failure
between two entries wrote the end-of-archive trailer and ExtractTar saw
a valid, shorter archive. PersistSnapshotDir's stream fallback then
finalized a snapshot with files missing and deleted the source dir,
because the error only surfaced on the deferred Close it discards.
…cords

Setting meta_backend=json before running meta convert opened an empty
json store over live sqlite data: every process in that window saw no
VMs, so the cgroup, CNI, TAP and vmlock GC modules reclaimed live VMs'
resources and new records landed in the wrong engine. The sqlite side
already refuses to bootstrap over legacy json; this is the mirror.
Start and restore un-quiesce a VM's plumbing before the VMM runs. A
SIGKILL in that window (vk-cocoon's exec.CommandContext sends one on
cancel) left a stopped record with no QuiescePending, so nothing ever
brought the veth back down and the TC redirect pointed at a TAP no VMM
held, the #130 softirq condition. The placement write now schedules the
quiesce, restore places before it recovers the network, and the
transitions to running clear it as before. Clone placeholders are
excluded: a dead clone is reclaimed as a stale create.

Start also checks the record invariants before placing it, so a record
it rejects no longer keeps its cpus counted in the host placement.
…ries

A canceled capture returns while CH is still writing the dump, and CH
serves its API one request at a time. Ending the series first sent that
request on the 30s client timeout behind the dump; CH drops a request
whose client has gone, so the series stayed open with dirty logging on
and the directory was deleted, leaving abort nothing to act on. Resume
raises the timeout and waits out the dump, then the series ends.
The no-lease failure branch kept the master open in the CLI process.
Nobody reads it, so a full PTY buffer can block the guest's console
writes until the CLI exits; the relay child holds its own copy on the
success path, so the master closes once on every path.
--max-cpu defaults to 8 and --cpu has no upper bound, so --cpu 16
printed boot=16,max=8, which Cloud Hypervisor rejects.
netns.NewNamed enters the new netns before it creates and mounts the
name, so a failure there returned the locked thread to the scheduler
inside an empty netns, where a later exec in the same process could run
host-side plugin work.
HandleStopResult had one caller and no user outside this repo. A
qcow2 v3 source was renamed to a fixed shared temp name before commit
renamed it again into the blob store; every caller already passes a
temp file it owns in the same temp dir, so commit takes it directly.
The GC removal-failure test chmods the snapshot data dir, so it reaches
the removal and checks the tombstone it keeps; the TC redirect test
re-runs setup on partial plumbing; the final migrate round checks the
reference file a reflink would create; teardown tests assert the
operation happened. Remove tests that duplicated others or could not
fail, add the missing vm migrate and no-watchdog cases, and move a
logstream test next to its code.
Compress multi-fact comments to one fact, delete comments that restate
the code or an interface's contract, fix comments the code outgrew
(convergeOrphanedPause after live migration, Network's omitzero, the
missingDir cause, ReverseLayers' parameter), name func types spelled
more than once, rename parameters that shadow imported packages, and
use slices.Concat, min and a direct return where they are shorter.
Pin the quick start to v0.7.6; correct the Windows clone NIC, Android
SELinux, data-disk size and Debian SSH statements; document -o json on
mutating vm verbs, hibernate's --name, device --id and the pull_conns
cap; replace duplicated sections with links to the canonical copy and
drop sections no supported setup can hit.
goreleaser archives use ids; fmt-check applies the local import prefix
like fmt and CI; make test uses the CI timeout; .gitignore no longer
hides cmd/storebench; cocoon-check stops requiring ch-remote, which
cocoon never runs; vm-inspect reads the netns from the record so
scoped installs work; meta-upgrade.py exits with main's status and is
ruff-formatted.
@CMGS CMGS changed the title review: whole-repo audit, 8 fixes, cuts, docs against the code review: 8 fixes, cuts, docs against the code Nov 4, 2026
CMGS added 12 commits November 4, 2026 20:18
The daemon keeps its store open across the cutover, and checkQuiesced
only probes the namespace locks for an instant, which an idle daemon
does not hold. After json to sqlite the json engine reads the retired
files as empty, so the next --gc-interval sweep treats every VM as an
orphan: it kills the VMM and deletes the COW disk. After sqlite to json
it keeps reading the renamed database, so VMs created later die the
same way. Convert now holds the daemon's single-instance lock for the
whole run, resume included. Hot-path cost is zero.
A json root on NFS, CIFS or FUSE cannot host sqlite, yet the CLI suggests
meta convert on every json start. The filesystem refusal came after the
manifest was saved, so every later verb refused with 'a conversion is in
flight' and the rerun hit the same refusal; only deleting the manifest
by hand got out.
3ff73bb set the flag at placement, but a restore of a running VM has
already committed Stopped by then. A SIGKILL during apply left Stopped
with no flag and the plumbing up: the daemon skips that record and vm
stop finds nothing to do, so only another restore or vm rm cleared it.
The commit that erases the Running signal now sets the flag. The test
also pins placement before network recovery for a stopped origin.
RecoverTombstone commits a fresh lease before its teardown fails. That
commit fires the meta event, so a persistent failure such as a removed
CNI conflist ran a full pass about four times a second instead of every
five seconds: a /proc walk, several durable commits per stuck VM and
plugin execs, all contending with CLI writers. After a degraded pass
the loop leaves the next pass to the ticker.
The reclaim's busy outcome was discarded, so the receive blocked on the
lock and then adopted the placeholder: over a deleting tombstone the
next roll-forward destroyed the received VM, and over a concurrent
receive it removed the directories under that receive's VMM.
Also correct the QuiescePending comment and the daemon event note after
the flag started covering launches, and name the way out in meta-upgrade's
in-flight refusal.
…d NIC

A clone hot-swaps its NICs while paused, and the old NIC stays in the
device tree until the guest acks the eject. A snapshot taken before that
ack saves the node without the pending-eject bit, so every VM cloned
from it keeps a half-ejected NIC forever and passes it down the chain.
On hardware, 4 of 5 zero-delay snapshots carried it, and one clone came
up with no NIC in the guest while vm clone reported success.

Pause now waits up to ejectWaitTimeout. On timeout it warns and goes on
instead of refusing, because descendants of an already poisoned snapshot
hold a node that never ejects and must stay capturable. A VM with nothing
pending pays nothing: vm.info is already read here.
…er VM

cocoon-check --subnet wrote ipMasq true, the setting known-issues says
leaks a POSTROUTING rule and a CNI chain for every VM, because the bridge
plugin reads the addresses to remove off an interface cocoon has flushed.
Generate ipMasq false and masquerade the subnet on the host once.
… again

3ff73bb moved placement first so the quiesce flag preceded the
un-quiesce. Measured across commits on an idle node-pinned host, that
order alone adds 6-7ms to every restore (p50 130 to 136, n=60, flag kept
and order reverted measures 129.7), and hibernate wake is a latency
path. A running origin already has the flag from the kill's commit, so
this only reopens a 1-2ms gap for a stopped origin between un-quiesce
and the placement commit. The window up to the VMM exec stays covered.
d7146d5 added the rule once at generation and printed FIXED even when
iptables failed. A later run skipped generation, and a reboot dropped the
rule, so guests lost outbound NAT with no report. It is now checked like
the FORWARD rules: reported on every run and added by --fix, so a rerun
converges.
@CMGS
CMGS merged commit 36aa287 into master Nov 4, 2026
4 checks passed
@CMGS
CMGS deleted the review/whole-repo-2026-11-04d branch November 4, 2026 14:13
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