Implement Fake.FireEvent(domainName string, event libvirt.DomainEvent... - #151
Merged
colonelpanik merged 1 commit intoAug 9, 2026
Conversation
colonelpanik
marked this pull request as ready for review
August 9, 2026 19:14
colonelpanik
force-pushed
the
overseer/implement-fake-fireevent-domainname-string-event-libvirt-dom
branch
from
August 9, 2026 19:15
ab47680 to
2d9d8ad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Implement Fake.FireEvent(domainName string, event libvirt.DomainEventType, detail int) in internal/libvirtfake/libvirtfake.go so the callback stored by RegisterDomainEventCallback can be invoked from a test, then add a tests/fleet/ scenario that fires DomainEventCrashed for a VM and asserts the daemon's handler transitions it to state=error (and that a VM with StateDetail=="operator-stop" is left alone).
Plan
Plan —
Fake.FireEvent+ a fleet scenario for the domain-event crash handlerGoal
Fake.FireEvent(domainName string, event libvirt.DomainEventType, detail int)ininternal/libvirtfake/libvirtfake.go, so the callback stored byRegisterDomainEventCallbackcan be driven from a test.tests/fleet/scenario that firesDomainEventCrashedfor a VM and asserts thedaemon's handler moves it to
state=error, while a VM carryingstate_detail="operator-stop"is left untouched.What exists today (facts a reviewer should check first)
internal/libvirt/client.go:23-34DomainEventCallback func(domainName string, event DomainEventType, detail int);Started=0 Stopped=1 Crashed=2 Shutdown=3internal/libvirt/client.go:139-146c.eventCallbackunderc.muinternal/grpcapi/libvirt_iface.go:141RegisterDomainEventCallbackis onLibvirtBackendpurely so the same backend can be passed todaemon.Runinternal/daemon/daemon.go:596-614d.virtinternal/libvirtfake/libvirtfake.go:1244-1247Fake.FireEventdirectly (TODO if needed)"The handler's exact logic (daemon.go:598-613), which the fleet test must pin:
Nothing in production ever calls
c.eventCallback. Verified:internal/libvirtnever subscribes to go-libvirt's lifecycle-event stream (noLifecycleEvents/SubscribeEventscall anywhere), so the "immediate VM death detection(#44)" path stores a callback that is never invoked. The only existing invocation in the
tree is
internal/libvirt/client_test.go:46, which pokes the unexported field directly.Real VM-death detection today comes from the polling
health.VMChecker/health.Reconciler.Consequences, stated up front so nobody over-reads the new test:
does not prove that a crashed qemu on a real host reaches the handler — that wiring
does not exist.
golibvirtlifecycle events intoc.eventCallbackis a real gap but is out ofscope here; it is called out in "Follow-ups" and should be its own change with its own
e2e coverage.
inside
Daemon.Run, a ~700-line function nothing can invoke from a test.Design decisions
D1 —
FireEventshape: match the existing comment exactlyNo return value, no
*testing.T, no error. It is the fake's analogue ofSetState/SetStateReason— a scenario-driver, not an assertion helper.D2 — store the callback under
f.mu, invoke it outside the lockThe callback field joins the other per-domain state on the
Fakestruct and is written /read under
f.mu. ButFireEventmust copy the callback out, releasef.mu, and onlythen invoke it.
sync.Mutexis not reentrant, and the callback is arbitrarytest/daemon code that may call back into the fake (
DomainExists,SetState,DomainState, …). Holding the lock across the call is a self-deadlock waiting to happen —this is the single most important correctness detail in the change, and it gets its own
unit test (T1c).
RegisterDomainEventCallbackstays a safe no-op for the ~40 existing fleet tests: storinga callback has no effect until something fires an event, and nothing fires one unless a
scenario calls
FireEvent. AFireEventwith no callback registered returns silently.D3 — extract the daemon handler so the test drives the real one (recommended:
internal/health)The fleet harness builds
grpcapi.NewServerForTests(...)(tests/fleet/cluster.go:392-...);it does not run
daemon.Run. So the handler has to become a callable unit or the testis testing a copy of it.
There is already a cautionary precedent in-tree:
tests/fleet/opjournal_recovery_test.go:17says "The lookup below mirrorsDaemon.runOperationRecovery" — a hand-copy that can silently drift from the code it claims
to cover. Under CLAUDE.md's mutation-verify rule, re-copying the crash handler into the
test would produce a test that passes with the daemon's handler deleted. Not acceptable.
Recommendation: move the closure body into
internal/health/domain_events.go.Why
internal/healthand notinternal/daemon:internal/healthis already intests/fleet's dep set (6 fleet tests import it). Importinginternal/daemoninsteadwould add 7 packages —
daemon, ui, restapi, watchdog, firewall, failover, cephdeploy,including
internal/ui's 2.2 MB ofgo:embedassets — to every fleet test build.healthalready owns runtime→cluster-state reconciliation.VMCheckeris the polling version of this exact decision, carries the same
operator-stopskip,and already uses the
SetStateWriteFailObserver(func(op, class string))convention thishandler needs for
stateWriteMetrics.Failed.health.NewReconciler(name, dir, db, virt)is likewise constructed directly by fleet tests today
(
tests/fleet/failover_invariants_test.go:127), so this matches the harness idiom.healthalready importscorrosionandlv "internal/libvirt";daemonalready importshealth.Alternative considered: export it from
internal/daemonasdaemon.DomainEventHandler(...)and importinternal/daemonfromtests/fleet. Smallerconceptual move (the code never leaves its package), and
cmd/litevirttests alreadyimport
internal/daemon, so it is not unprecedented. Rejected on the dependency-weightand cohesion grounds above — but it is a legitimate reviewer preference, and switching to
it changes only which package the new file lives in, not the tests or the harness wiring
shape. If the reviewer prefers it, say so and it's a one-file relocation.
The move must be behaviour-identical — same switch, same guard order, same
state="error", samestate_detailformat string, sameslogcalls, same failureobserver.
daemon.Runkeeps its registration and its#44comment:D4 — register the handler in the harness (
cluster.go), not in the test fileWire it once in
buildServer, so every fleet node behaves like a real daemon and ascenario only has to call
n.Virt.FireEvent(...):This is what makes the wiring — not just the function — part of what the test covers:
deleting this line turns the scenario red (mutation M5 below). Blast radius on the other
fleet tests is nil, since nothing else calls
FireEvent.context.Background()is used because the harness has no daemon-lifetime ctx; thehandler's two corrosion calls are synchronous and complete inside
FireEvent. Note it inthe comment.
D5 — assert on corrosion rows only
Every assertion reads
corrosion.GetVM(ctx, node.DB, name)→.State/.StateDetail/.HostName. NoEventLog()counting, no call counters. (FireEventdoes append afire-evententry to the fake's event log for debuggability, consistent with the rest ofthe fake — but no test asserts on it.)
D6 — seed only through registered corrosion writers
tests/fleet/owner_epoch_test.go:100-104records that the receiver's apply guard refusesad-hoc statement shapes, so a test that seeds with inline SQL breaks the moment its
mutations are pumped to a peer. Seeding therefore uses
corrosion.InsertVM(which carriesStateDetaildirectly —internal/corrosion/vms.goInsertVMWithHardware, theINSERT INTO vms (..., state_detail, ...)statement), never a rawUPDATE.Files to change
1.
internal/libvirtfake/libvirtfake.goFakestruct, alongsidereasons/ownerEpochs(~line 57-59):New()change needed (nil zero value is the "nothing registered" state).f.recordassumes the lock is held — see its callers — and usesf.Now, whichNew()sets to
time.Now.)FireEventdeliberately does not mutatef.domains; a scenario that wants thefake's domain state to agree with the event calls
SetStateitself. Event injection andstate are kept orthogonal, matching
SetStateReason.grpcapi.LibvirtBackend:FireEventis a fake-only driver, likeSetState.2.
internal/health/domain_events.go(new, ~55 lines)The extracted handler per D3. Body copied verbatim from
daemon.go:598-613, withd.db→h.db,d.cfg.HostName→h.hostName, and the metric call routed through theexisting
noteStateWriteFail-style helper (h.onStateWriteFail(corrosion.OpVMState, corrosion.ClassifyWriteErr(err)), nil-safe).writechecknote:UpdateVMStateis in its guarded set(
scripts/ci/writecheck/main.go), so theif err := corrosion.UpdateVMState(...); err != nilform must be preserved exactly. There are no path allowlists in the guard, so movingthe call between packages is fine as long as the error is still checked.
3.
internal/daemon/daemon.goReplace lines 596-614 with the three-line wiring in D3. Net: -17 lines.
libvirtandfmtimports may become unused in that file — check and drop only if so.4.
tests/fleet/cluster.goIn
buildServer, right aftern.Virt = libvirtfake.New()and thegrpcapi.NewServerForTestscall, add the registration from D4. New imports:internal/health(already in the fleet dep set);contextis already imported.5.
tests/fleet/domain_event_test.go(new) — the scenario6.
internal/libvirtfake/libvirtfake_test.go(new) — first test file in that packageTests
T1 —
internal/libvirtfake/libvirtfake_test.go(unit)TestFake_FireEvent_NoCallbackIsNoOp—New(), thenFireEvent("vm", libvirt.DomainEventCrashed, 0); must not panic. Pins the constraintthat the fake stays safe for scenarios that never register.
TestFake_FireEvent_DeliversToRegisteredCallback— register a recordingcallback, fire
(name=..., DomainEventCrashed, detail=7), assert the callback sawexactly those three values; re-register a second callback and assert the first no longer
receives.
TestFake_FireEvent_CallbackMayReenterTheFake— register a callback whose bodycalls
f.DomainExists(name)andf.SetState(name, StateShutdown). RunFireEventin agoroutine and
selecton a done channel vstime.After(2*time.Second), failing witht.Fatalon timeout — so the lock bug fails as a red test in 2s instead of hangingthe package until the 10-minute panic timeout. This is the test that would catch someone
"simplifying"
FireEventtodefer f.mu.Unlock().T2 —
tests/fleet/domain_event_test.go(the required scenario)TestFleet_DomainEventCrashed_MarksVMErrorAndSparesOperatorStop,Options{Nodes: 2},nodes
a(the host firing events) andb(peer):Seed — three VMs via
corrosion.InsertVM(ctx, a.DB, ...):vm-crasharunningvm-opstopastoppedoperator-stopvm-remotebrunningThen
pumpMutations(t, c, a, b)(defined inowner_epoch_test.go, same package, reused by5 other fleet files) so both nodes agree before the event, and
a.Virt.SetState("vm-crash", libvirtfake.StateShutdown)so the fake's view matches acrashed domain.
Act — from node
a:Assert — all reads via
corrosion.GetVM(ctx, a.DB, ...):vm-crash→State == "error"andStateDetailcontainsdetail=3(substring match on the format string, not the whole sentence — the OOMadvice text is not a contract).
vm-opstop→ stillState == "stopped",StateDetail == "operator-stop".vm-remote→ stillState == "running",HostName == b.Name.Cheap, same handler, and the guard is one line above the operator-stop one.
vm-idle(hosta,running) fired withDomainEventStartedstaysrunning— pins theswitch, so widening it to a baredefaultcannot pass.tests/fleet/, not a unit test):pumpMutations(t, c, a, b)again, then assert nodeb'svm-crashrow also readserror. This runs the real spine — mutation_log → PushMutations over realgRPC/mTLS →
applyStatementLWW+ the receiver's statement-shape apply guard — and wouldcatch the crash write being emitted in a shape a peer silently discards.
b'svm-opstopandvm-remotemust be unchanged there too.Assertions 3-5 are additions beyond the literal ask; they cost ~15 lines and reuse the same
seed, but they are explicitly flagged here so the reviewer can trim them if they want the
scenario kept to exactly the two required cases.
T3 — mutation verification (CLAUDE.md: "a passing test proves nothing until you have seen it fail")
Each row: break it, run
go test ./tests/fleet/ -run DomainEvent ./internal/libvirtfake/,confirm red, restore. Record the results in the PR description.
RegisterDomainEventCallbackback to a no-op (dropf.eventCB = cb)FireEventreturns before invokingcbif vm.StateDetail == "operator-stop" { return }vm.HostName != h.hostNamefrom the guardRegisterDomainEventCallbackline fromcluster.gobuildServerUpdateVMState(..., "error", ...)→"stopped"switchto act on every event typeFireEventholdsf.muacrosscb(...)(defer f.mu.Unlock())Why the vacuity risk is real here:
FireEventno-ops when no callback is registered, soa negative-only test ("operator-stop VM unchanged") would pass with the entire feature
deleted. M1/M5 are the mutations that prove it doesn't — which is why the positive
assertion and the negative assertion must live in the same test, sharing one
registration.
Verification
Expected guard outcomes, and why:
writecheck— unaffected; theUpdateVMStatecall keeps itsif err := ...form(D3/§2). It has no path allowlist, so the package move is invisible to it.
stmtshapecheck— unaffected; no new or modified SQL builder. The handler reusescorrosion.UpdateVMStateverbatim. (reachable.goonly tracks unexported builders ofreplicated statements.)
check-schema-bump/ ledger drift — no schema change.cmd/litevirt/docs_triangulation_test.go) — no new CLI command,config key, or
litevirt_*identifier, so no doc updates are owed. The existinglitevirt_state_write_failures_totalmetric is reached by the same observer as before.Reviewer notes / risks
trigger. The change is still worth making — it converts a documented TODO into a working
test seam and puts the handler's rules under test — but the PR description must not
claim "crash detection is now tested end-to-end".
identical to
daemon.go:598-613: guard order,state="error", the"domain event: stopped (detail=%d). Check host dmesg for OOM."format, bothsloglines, and
stateWriteMetrics.Failed(corrosion.OpVMState, ClassifyWriteErr(err)). Ifthe reviewer would rather it stay in
internal/daemon(exported), that's a one-filerelocation; see the alternative in D3.
FireEventis the one place a plausible-looking edit(
defer f.mu.Unlock()) introduces a deadlock. T1c exists to catch exactly that and iswritten to fail fast rather than hang.
UpdateVMStateisUPDATE vms SET ... WHERE name = ?— the same shapeowner_epoch_test.godocuments asthe 2026-08-01 rejoin-fight footgun, unlike the epoch-guarded
UpdateVMStateAtEpoch.T2 assertion 5 will therefore show the write landing on the peer. That is today's
behaviour, faithfully pinned; whether the crash handler should be epoch-guarded is a
separate design question and is not changed here. If the team wants it guarded,
assertion 5 is the test that would have to change with it.
cluster.go, shared by ~40 fleet tests.It is inert without a
FireEventcall, but it does mean every node now holds a closureover
n.DB; a scenario that fired an event afterCluster.Stop()would hit a closed DB.No current test does.
internal/libvirtfakegains its first_test.go. Intentional — the fake now hasbehaviour (lock handoff, nil-callback path) that is worth pinning in place, not just
plumbing.
Follow-ups (explicitly out of scope)
Client.eventCallbackso the daemon'shandler has a real trigger, with
tests/e2e/coverage against a real crashed domain.UpdateVMStateAtEpochfor the crash write once the handler is genuinelyreachable (see note 4).
tests/fleet/opjournal_recovery_test.go:17with thesame extract-and-share treatment used here.
Deviations taken during implementation
1. T2 assertion 4 fired at an unseeded VM — would have been vacuous (plan bug)
The plan's Act block fires the non-lifecycle event at
vm-crash2:but assertion 4 describes "a fourth VM
vm-idle(hosta,running)", and the seed tablelists only three VMs — neither
vm-crash2norvm-idleis ever inserted.That combination makes the assertion vacuous. Firing at a name with no
vmsrow meanscorrosion.GetVMreturnsnil, so the handler'svm == nilguard returns early whateverthe
switchdoes. Mutation M7 ("widen the switch to act on every event type"), whichthis assertion exists to catch, would have stayed green:
vm-idlewould have had no row toread and no event fired at it either way.
Implemented instead: seed a fourth VM
vm-idle(hosta, staterunning) alongside theother three, and fire
DomainEventStartedat that name. Verified: M7 now fails on exactlythis assertion —
The seed table is therefore four rows, not three.
2.
daemon.goimports — the plan's conditional resolved to "no change"Plan §3 says the
libvirtandfmtimports "may become unused in that file — check and droponly if so". Checked: both are still used elsewhere in
daemon.go(libvirt.Client,libvirt.NewClient,libvirt.ResolveFirmwarePaths, and 20+fmt.calls). No import changed.3. Mutation-verification results (T3)
All eight ran; each was confirmed to have actually applied (not a silent no-op edit) before
its test run, and each restored cleanly afterwards.
RegisterDomainEventCallbackdropsf.eventCB = cbFireEventreturns before invokingcboperator-stopguardvm.HostName != h.hostNamefrom the guardRegisterDomainEventCallbackline fromcluster.go"stopped"instead of"error"switchtodefault:FireEventholdsf.muacrosscb(...)M3/M4/M6/M7 were re-run after the handler was re-indented (see deviation 4) and still go red.
4.
Callbackkeeps the switch inlineA first pass split the body into a separate exported
Handle(ctx, domName, event, detail)method with
Callbackdelegating to it. Nothing calledHandle, and the plan's stated APIfor the type is
NewDomainEventHandler/SetStateWriteFailObserver/Callback— so theextra exported method was dropped and the switch sits inline in the closure
Callbackreturns, which is also the exact shape it had in
daemon.Run.Verification run
go build ./...,go vet ./...,go test ./...,make test-fleet-race(477s, ok) andmake ci-guardsall pass. As predicted in §Verification,writecheck,stmtshapecheck,schema-bump/ledger-drift and docs triangulation were all unaffected.
Final Codex review
No blocking findings remained.
Opened by overseer. The plan and the code each converged to zero blocking findings from Codex before this pull request was created.