Fault-injection pass over the startup-convergence work from #55 / #56 / #57 (follow-up to #54). Most of it holds up; five paths still leave a VM stranded after a vk restart, and four of them cannot recover without manual intervention.
None of them lose guest state — every recovery below was verified with a marker-file ledger inside the guest (a uniquely-named file created before each hibernate, listed after each wake), and all nine markers survived every wedge. These are availability bugs.
Setup
|
|
| vk-cocoon |
v0.3.5-4-g40d80bb (rev 40d80bbb64e2d70ca9d90cafcee5978b909ac2c1) |
| cocoon |
v0.5.7-2-g0ba410f (rev 0ba410f1b8f553070364901a7a9e994ebfa3df18) — reconcile-stale-create exists only on master, not in any release |
| Guest |
cloud-hypervisor + Windows, drop-NIC class, DHCP |
Note on method, because it decides the outcome: systemctl stop / systemctl restart and kill -KILL <MainPID> leave different damage. Under stop/restart the in-flight cocoon child dies within 1s — not from systemd (KillMode=process only stops systemd sweeping the cgroup) but from vk itself, since signal.NotifyContext(SIGINT, SIGTERM) cancels the ctx and every cocoon call is exec.CommandContext. Under kill -KILL nobody kills the child: the in-process watchdog never runs, and the child lingers ~4s before dying on its next write to the closed stdout/stderr pipe. RestartSec=5s sits inside that lag, which is what makes problem 1 reachable. (systemctl kill defaults to --kill-who=all and kills the child too, hiding the race — signal the main PID directly.)
1. watchBusyCreate never re-invokes the reclaim verb — VM never recovers
Trigger: kill -KILL on vk while cocoon vm clone is in flight (any crash: OOM, node failure). Reproduced twice.
reconcileStaleCreates runs once, at startup. The clone child is still alive at that instant, so cocoon vm reconcile-stale-create answers busy and the record goes to watchBusyCreate. The child then dies. That loop only calls Runtime.Inspect; it never re-invokes the verb. creating satisfies inFlightCreate(), so it polls until defaultDeferredRecheckBudget (30 min) expires, logs did not leave creating within budget; giving up, and abandons the record — which still owns the VM name.
stale_create_reconcile_total{outcome="busy"} 1
Error: reserve VM record: vm name "vk-staging-cocoonset-vm-XXXX-0" already exists (id: NMA2LDSIW5ZIM54D4UMFSDYTR5) # every CreatePod retry
wake_total{result="failed"} → 33 and climbing
CocoonSet=Failed, pod=Pending, no VMID, no IP
#55 did not remove this deadlock, it moved it one race window later.
Fix direction: the poll loop must periodically re-invoke the verb. Only the verb distinguishes "owner still alive" from "owner died leaving the record in creating" — VM state alone cannot.
Escape hatch: cocoon vm reconcile-stale-create <VMID> -o json → {"outcome":"collected"}, after which the next CreatePod retry succeeds.
2. An interrupted snapshot save leaves the guest paused at the hypervisor level
Trigger: systemctl stop (or restart) while cocoon snapshot save is in flight — i.e. the everyday operational path.
snapshot save pauses the guest, dumps memory, resumes. Cut in the middle, cloud-hypervisor stays Paused forever. dispatchOwedWork correctly re-enters hibernate (startup_resume_total{op="hibernate"}=1, WRN resuming interrupted hibernate), but every attempt dies on the first step:
ERR drop NIC pre-hibernate ...: cocoon vm net <ID>: exit status 1
(output: Error: vm is paused (snapshot or hibernate in flight); retry after it completes)
hibernate_total{phase="netresize",result="failed"} # +1 per attempt
hibernate_total{phase="dhcp_release",result="failed"} # guest exec also fails, exit -1
Two things make this hard to diagnose: cocoon vm inspect reports state: running while the hypervisor reports Paused, and cocoon vm has no pause/resume subcommand. snapshots__records / __names / __tombstones were all empty, so this is not a pending-snapshot name lock.
Fix direction: probe the hypervisor state before re-entering hibernate() and unpause first, or make dropNICForHibernate self-heal on vm is paused. Separately, cocoon vm inspect should report Paused honestly.
Escape hatch:
S=/var/lib/cocoon/run/cloudhypervisor/<VMID>/api.sock
sudo curl -s -X PUT --unix-socket $S http://localhost/api/v1/vm.resume # 204
3. A save killed mid-flight leaves a snapshot the retry cannot replace
Trigger: kill -KILL 6s into cocoon snapshot save. The orphaned child survives and finishes the save (snapshot landed 2s after vk died). The resumed hibernate then fails on every attempt:
Error: save snapshot: snapshot name "vk-staging-cocoonset-vm-XXXX-0" already in use by MPT5A6ZS2FNZWQGFN24AZLREWQ
SnapshotSave's recovery branch keys on the substring already exists; cocoon says already in use by, so the rm-and-retry never engages.
But the trigger is not simply "a same-named snapshot exists." Killing vk 28ms after the save child exited cleanly leaves an equally same-named finalized snapshot, and there the resumed save succeeded on the first try and replaced it — no error, no retry. So something about the record a save leaves when its parent dies mid-flight makes it non-replaceable, while a cleanly-exited save's record is replaceable. I did not trace this into cocoon's source, so please don't treat the already exists branch as dead code on the strength of the first case alone.
Fix direction: broaden the match to cover already in use by, and make the recovery rm by ID rather than by name (a name lookup cannot resolve a half-committed record). Worth understanding the record-state difference before settling on the fix.
Escape hatch: cocoon snapshot rm <name> by hand for the finalized case; the retry then succeeds immediately.
4. A hibernate owed on a crashed VM is never dispatched
#56's commit message states "a hibernate owed on a crashed VM boots it first instead of never firing". That did not happen.
Trigger: stop vk during dropNICForHibernate (hibernate owed, no save has run yet), kill -9 the cloud-hypervisor process so the record goes to stopped, then start vk.
startup_resume_total # NO SAMPLES AT ALL — the resume was never dispatched
ERR drop NIC pre-hibernate ...: cocoon vm net <ID>: exit status 1
(output: Error: vm is not running: vm <ID> pid 42405 not cloud-hypervisor: vm not running)
hibernate_total{phase="netresize",result="failed"} 14
hibernate_total{phase="dhcp_release",result="failed"} 14
CocoonSet ended Failed, no ResumeStartFailed event. The VM was never started. Start itself is fine — a manual cocoon vm start <ID> brought it back first try, and after clearing lifecycle-state=failed the hibernate completed normally (netresize / snapshot / push / remove all ok).
Fix direction: the guard exists in dispatchResume but the path was not entered. Worth checking whether handleVMGone, or the adoption of a stopped record, drops the pod from the tracked table before dispatchOwedWork reads it.
5. lifecycle-state=failed parks the pod, and silently blocks unrelated operations
Every failure above converges here: after a few retries vk writes lifecycle-state=failed, and then nothing drives the pod — the resume no longer fires and the operator leaves a non-terminal pod alone. Recovery needs the annotation deleted by hand.
It also blocks operations that have nothing to do with the original failure. A leftover failed from an earlier test made a subsequent spec.nodeName patch a no-op: the CocoonSet sat in Failed, the migration never started, and neither the vk nor the operator log said why. Removing the annotation let the migration begin immediately.
Fix direction: whatever writes failed should also define how the pod leaves that state. Fixing 1–4 without this still leaves an operator-only exit.
What held up
Worth recording so the fixes don't regress it:
- create interrupted (
vm clone in flight) + systemctl restart → record reclaimed (outcome="collected"), VM recreated, exactly one VM, orphan_vm_total=0
- delete interrupted (
vm rm --force in flight, 144ms in) + systemctl restart → VM/pod/CocoonSet all gone, unrelated snapshots untouched
- wake interrupted by
systemctl restart, and by kill -KILL 28ms after the clone committed → both recovered; the latter went through classify_drop_nic correctly
- push interrupted (
kill -KILL 28ms after the save child exited) → resume re-saved, re-pushed, settled Suspended
post-clone-state=running → {op="post_clone"}; =done → {op="ready_wait"}; =failed → correctly parked with zero dispatch; empty lifecycle-state with a marker present → still dispatched
- post-clone marker dies with its VM incarnation (a hand-injected stale
done was gone after the hibernate)
- resume claim mutual exclusion works: a counter-operation inside the claim window is rejected with
ProviderUpdateFailed: resumed operation still in flight for <ns>/<name> and succeeds on retry once released. Note this surfaces only as a pod event, not a log line
- double restart: the in-flight resume logged
post-clone setup canceled after 1.745s (provider shutdown), the new process re-derived the same owed work from the annotations and completed. No double execution, no skipped work
classify_drop_nic budget exhaustion (tested with a local build cut to 25s, AR blocked via /etc/hosts): ResumeClassifyFailed fired at exactly the budget and the error carries (refusing fresh boot) — the fail-closed contract holds
- bounded fan-out:
startup reconcile: 10 pods adopted 28 ms after process start. startupFanOut=8 is not a bottleneck at this scale
- cross-node migrate with the source-side push interrupted: resume re-ran hibernate, old pod deleted, recreated on the target, VM live; pull 38.6s,
vm_boot{mode=clone} 115.7s including a cold base pull
Coverage gap worth flagging
The #54 protection that matters most — never fresh-boot over a hibernate snapshot — has no end-to-end coverage from outside vk-cocoon, and hibernate_evidence_total stayed all-zero through every run. Three mechanisms block it:
- the operator always stamps
restore-from-hibernate when it recreates the pod of a suspended CocoonSet, so CreatePod short-circuits before deriveRestoreFromEvidence;
- deleting the CocoonSet also deletes the registry
:hibernate tag, so "keep the tag, drop the pod" is not reachable that way;
- a hand-built pod is rejected by admission (
pods.mutate.cocoonstack.io: cocoon pods must be managed by a CocoonSet).
gcloud artifacts docker tags add also refuses to copy a tag across image names inside one repository, so the tag cannot be cloned onto a spare VM name. This needs in-repo tests. The same applies to the created-state trap and the not-creating race, which need millisecond-scale timing.
Related: the image identity guard lives only in deriveRestoreFromEvidence, so an operator-set marker skips it. Changing spec.agent.image while a :hibernate snapshot exists restored the old image with no event and no metric.
Observability
Small things that made every diagnosis above harder than it needed to be:
stale_create_reconcile_total, startup_resume_total and hibernate_evidence_total are process-local, so they reset on the very event they describe. Anything that wedges across a restart is invisible unless scraped in the window.
- Both wedges emit log lines only — no Kubernetes event, no dedicated metric. Nothing outside the node can tell a VM is stuck.
wake_total{result="failed"} is inflated by the CreatePod retry storm (33 in one wedge), so wake success rate is not trustworthy after any create failure.
classifyNICRecovery calls hibernateEvidence directly, so the evidence lookup on the resume path is not counted on hibernate_evidence_total at all.
Fault-injection pass over the startup-convergence work from #55 / #56 / #57 (follow-up to #54). Most of it holds up; five paths still leave a VM stranded after a vk restart, and four of them cannot recover without manual intervention.
None of them lose guest state — every recovery below was verified with a marker-file ledger inside the guest (a uniquely-named file created before each hibernate, listed after each wake), and all nine markers survived every wedge. These are availability bugs.
Setup
v0.3.5-4-g40d80bb(rev40d80bbb64e2d70ca9d90cafcee5978b909ac2c1)v0.5.7-2-g0ba410f(rev0ba410f1b8f553070364901a7a9e994ebfa3df18) —reconcile-stale-createexists only on master, not in any releaseNote on method, because it decides the outcome:
systemctl stop/systemctl restartandkill -KILL <MainPID>leave different damage. Under stop/restart the in-flightcocoonchild dies within 1s — not from systemd (KillMode=processonly stops systemd sweeping the cgroup) but from vk itself, sincesignal.NotifyContext(SIGINT, SIGTERM)cancels the ctx and every cocoon call isexec.CommandContext. Underkill -KILLnobody kills the child: the in-process watchdog never runs, and the child lingers ~4s before dying on its next write to the closed stdout/stderr pipe.RestartSec=5ssits inside that lag, which is what makes problem 1 reachable. (systemctl killdefaults to--kill-who=alland kills the child too, hiding the race — signal the main PID directly.)1.
watchBusyCreatenever re-invokes the reclaim verb — VM never recoversTrigger:
kill -KILLon vk whilecocoon vm cloneis in flight (any crash: OOM, node failure). Reproduced twice.reconcileStaleCreatesruns once, at startup. The clone child is still alive at that instant, sococoon vm reconcile-stale-createanswersbusyand the record goes towatchBusyCreate. The child then dies. That loop only callsRuntime.Inspect; it never re-invokes the verb.creatingsatisfiesinFlightCreate(), so it polls untildefaultDeferredRecheckBudget(30 min) expires, logsdid not leave creating within budget; giving up, and abandons the record — which still owns the VM name.#55did not remove this deadlock, it moved it one race window later.Fix direction: the poll loop must periodically re-invoke the verb. Only the verb distinguishes "owner still alive" from "owner died leaving the record in
creating" — VM state alone cannot.Escape hatch:
cocoon vm reconcile-stale-create <VMID> -o json→{"outcome":"collected"}, after which the nextCreatePodretry succeeds.2. An interrupted
snapshot saveleaves the guest paused at the hypervisor levelTrigger:
systemctl stop(orrestart) whilecocoon snapshot saveis in flight — i.e. the everyday operational path.snapshot savepauses the guest, dumps memory, resumes. Cut in the middle, cloud-hypervisor staysPausedforever.dispatchOwedWorkcorrectly re-enters hibernate (startup_resume_total{op="hibernate"}=1,WRN resuming interrupted hibernate), but every attempt dies on the first step:Two things make this hard to diagnose:
cocoon vm inspectreportsstate: runningwhile the hypervisor reportsPaused, andcocoon vmhas no pause/resume subcommand.snapshots__records/__names/__tombstoneswere all empty, so this is not a pending-snapshot name lock.Fix direction: probe the hypervisor state before re-entering
hibernate()and unpause first, or makedropNICForHibernateself-heal onvm is paused. Separately,cocoon vm inspectshould reportPausedhonestly.Escape hatch:
3. A save killed mid-flight leaves a snapshot the retry cannot replace
Trigger:
kill -KILL6s intococoon snapshot save. The orphaned child survives and finishes the save (snapshot landed 2s after vk died). The resumed hibernate then fails on every attempt:SnapshotSave's recovery branch keys on the substringalready exists; cocoon saysalready in use by, so the rm-and-retry never engages.But the trigger is not simply "a same-named snapshot exists." Killing vk 28ms after the save child exited cleanly leaves an equally same-named finalized snapshot, and there the resumed save succeeded on the first try and replaced it — no error, no retry. So something about the record a save leaves when its parent dies mid-flight makes it non-replaceable, while a cleanly-exited save's record is replaceable. I did not trace this into cocoon's source, so please don't treat the
already existsbranch as dead code on the strength of the first case alone.Fix direction: broaden the match to cover
already in use by, and make the recoveryrmby ID rather than by name (a name lookup cannot resolve a half-committed record). Worth understanding the record-state difference before settling on the fix.Escape hatch:
cocoon snapshot rm <name>by hand for the finalized case; the retry then succeeds immediately.4. A hibernate owed on a crashed VM is never dispatched
#56's commit message states "a hibernate owed on a crashed VM boots it first instead of never firing". That did not happen.Trigger: stop vk during
dropNICForHibernate(hibernate owed, no save has run yet),kill -9the cloud-hypervisor process so the record goes tostopped, then start vk.CocoonSet ended
Failed, noResumeStartFailedevent. The VM was never started.Startitself is fine — a manualcocoon vm start <ID>brought it back first try, and after clearinglifecycle-state=failedthe hibernate completed normally (netresize/snapshot/push/removeallok).Fix direction: the guard exists in
dispatchResumebut the path was not entered. Worth checking whetherhandleVMGone, or the adoption of astoppedrecord, drops the pod from the tracked table beforedispatchOwedWorkreads it.5.
lifecycle-state=failedparks the pod, and silently blocks unrelated operationsEvery failure above converges here: after a few retries vk writes
lifecycle-state=failed, and then nothing drives the pod — the resume no longer fires and the operator leaves a non-terminal pod alone. Recovery needs the annotation deleted by hand.It also blocks operations that have nothing to do with the original failure. A leftover
failedfrom an earlier test made a subsequentspec.nodeNamepatch a no-op: the CocoonSet sat inFailed, the migration never started, and neither the vk nor the operator log said why. Removing the annotation let the migration begin immediately.Fix direction: whatever writes
failedshould also define how the pod leaves that state. Fixing 1–4 without this still leaves an operator-only exit.What held up
Worth recording so the fixes don't regress it:
vm clonein flight) +systemctl restart→ record reclaimed (outcome="collected"), VM recreated, exactly one VM,orphan_vm_total=0vm rm --forcein flight, 144ms in) +systemctl restart→ VM/pod/CocoonSet all gone, unrelated snapshots untouchedsystemctl restart, and bykill -KILL28ms after the clone committed → both recovered; the latter went throughclassify_drop_niccorrectlykill -KILL28ms after the save child exited) → resume re-saved, re-pushed, settledSuspendedpost-clone-state=running→{op="post_clone"};=done→{op="ready_wait"};=failed→ correctly parked with zero dispatch; emptylifecycle-statewith a marker present → still dispatcheddonewas gone after the hibernate)ProviderUpdateFailed: resumed operation still in flight for <ns>/<name>and succeeds on retry once released. Note this surfaces only as a pod event, not a log linepost-clone setup canceled after 1.745s (provider shutdown), the new process re-derived the same owed work from the annotations and completed. No double execution, no skipped workclassify_drop_nicbudget exhaustion (tested with a local build cut to 25s, AR blocked via/etc/hosts):ResumeClassifyFailedfired at exactly the budget and the error carries(refusing fresh boot)— the fail-closed contract holdsstartup reconcile: 10 pods adopted28 ms after process start.startupFanOut=8is not a bottleneck at this scalevm_boot{mode=clone}115.7s including a cold base pullCoverage gap worth flagging
The
#54protection that matters most — never fresh-boot over a hibernate snapshot — has no end-to-end coverage from outside vk-cocoon, andhibernate_evidence_totalstayed all-zero through every run. Three mechanisms block it:restore-from-hibernatewhen it recreates the pod of a suspended CocoonSet, soCreatePodshort-circuits beforederiveRestoreFromEvidence;:hibernatetag, so "keep the tag, drop the pod" is not reachable that way;pods.mutate.cocoonstack.io: cocoon pods must be managed by a CocoonSet).gcloud artifacts docker tags addalso refuses to copy a tag across image names inside one repository, so the tag cannot be cloned onto a spare VM name. This needs in-repo tests. The same applies to thecreated-state trap and thenot-creatingrace, which need millisecond-scale timing.Related: the image identity guard lives only in
deriveRestoreFromEvidence, so an operator-set marker skips it. Changingspec.agent.imagewhile a:hibernatesnapshot exists restored the old image with no event and no metric.Observability
Small things that made every diagnosis above harder than it needed to be:
stale_create_reconcile_total,startup_resume_totalandhibernate_evidence_totalare process-local, so they reset on the very event they describe. Anything that wedges across a restart is invisible unless scraped in the window.wake_total{result="failed"}is inflated by theCreatePodretry storm (33 in one wedge), so wake success rate is not trustworthy after any create failure.classifyNICRecoverycallshibernateEvidencedirectly, so the evidence lookup on the resume path is not counted onhibernate_evidence_totalat all.