reconcile: the concurrency invariants are argued in prose and observed by nothing #97
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
[claude 2026-08-23] From the same mutation audit (branch
comment-audit).internal/agent/reconcilehas the repo's highest comment ratio and the most-argued concurrency invariants — and none of them are observable. Every mutation below stayed green under-race, most at-count=3or higher.The auditing agent's verdict: "this suite pins behaviour, not concurrency." The comments survived the audit not because they are irreducible rationale but because nothing can tell whether they still hold.
1. "Only IDLE workers are reaped" —
worker.go:76Mutation: deleted the idle check, so a busy worker is dropped from the map.
-race -count=3green.The comment names its own consequence: two goroutines reconciling one VM — a concurrent Destroy/DeleteVM against a Boot/SaveVM, orphaning a hypervisor process.
Test shape: deliver → wedge the worker → reap → deliver again → assert one worker.
2. The GENERATION RULE —
worker.go:242-25520 lines, the package's most-argued comment. Two mutations, both green under
-race:if !w.busy || gen != w.gen→if !w.busy, and deletingw.gen++.Only the
!w.busyhalf is pinned —TestNarrationCannotOutliveThePassThatSaidItkills a straggler after the pass ended, never one from pass N landing while pass N+1 runs.3.
collectclones the rows"The rows are CLONED … two reports marshalled concurrently would race on one message." Mutation:
own := res.clone()→own := res.-race -count=5green.Test shape: two concurrent
aggregates, bothproto.Marshaled.4. "One goroutine per VM — the worker IS the lock"
Mutation:
go w.run()twice per worker.-race -count=3green.5.
netTracker.writeThroughatomicity —syncsvc/tracker.go:100Unlocking across the write is green at
-race -count=3. The siblingstatusTrackerhasTestStatusTrackerWriteThroughAtomic;netTrackerhas no equivalent.Related: one existing alarm is unreliable
TestStatusTrackerWriteThroughAtomiccaught its mutation at-count=5but passed a single-racerun. As a one-shot CI alarm it may not fire on a real regression. Worth pinning the iteration count or restructuring the test.Also found while mutating
quotaCheckLocked's earlyif e.MaxVCPUs == 0 && …return is dead code — everycasebelow already guards onMax* > 0.SeedLedgerhas no callers anywhere in the package.Migrated from git-collab issue
457540744aac164aa3465e04dad6564deb02ef9b.Original author: a73x dev@a73x.sh.
Close reason: [claude 2026-08-23] Merged to main in
d0c032f(pushed to both remotes). Every test proven by the mutant it kills; Fable's checkpoint review passed it merge-as-is. make ci green, boot gate PASS (full smoke incl. UDP, cold_start=20s).[claude 2026-08-23] Design for observing the reconcile concurrency invariants. Verified the headline claims by re-running two of the mutations myself (tree restored,
git diff --quietclean). One invariant's stated mechanism is obsolete and one incidental finding is wrong — both below.1. Diagnosis: why this suite pins behaviour and not concurrency
Two structural reasons, and neither is "no way to wedge a worker" — the harness already has one (
wedge()in worker_test.go, plusstepNoWait).(a) Every assertion reads quiesced end-state. The canonical driver
f.stepis Step →waitIdle→aggregateNow. Quiesced end-state is interleaving-independent by construction, so no assertion in the suite can distinguish correct locking from broken locking. The wedge-based tests in worker_test.go are the exception, but they aim the wedge at liveness properties (report promptness, cross-VM concurrency, coalescing, the create cap) — none pairs a wedged worker with the structural stimulus that matters: a reap, a redeliver, a stale-generation publish, a concurrent collect.(b) These are exclusion/ownership invariants, and
-raceis categorically blind to them here. Every shared structure in the package is individually mutex-guarded (manager.mu,worker.mu,Engine.mu, the fake provisioner's own lock). So the violating executions are race-clean: two goroutines reconciling one VM perform zero unsynchronized memory accesses in the test environment — each pass writes its ownvmResult, and everything they share is locked.-racedetects unsynchronized access, not violated ownership. Its silence is by design, not by luck, which is why-count=3and higher bought nothing.Corollary: the fix is not five unrelated tests. Invariants 1, 2 and 4 are the same invariant — at most one pass in flight per VM — violated by three different mutations, and it needs one missing observable plus a handful of forced interleavings. Invariant 3's hazard needs restating first (see below). Invariant 5 and the unreliable alarm share one deterministic pattern.
2. Strategy: deterministic wedge + an occupancy observable, extending the existing harness
Chosen: (a) one small addition to the reconcile test harness — an occupancy-tracking
Imageswrapper (per-vmID entry/exit gauge with a mutex-guarded high-water mark), composed with the existingwedge(); (b) for the trackers, a "park a writer insidewrite()" pattern that asserts mutual exclusion directly. All tests are channel-sequenced: the dangerous window is held open by a parked goroutine, the stimulus is applied, the observable is read. No repetition, no-count, deterministic under themake testflags CI actually uses (go test -race -shuffle=on ./...— count=1, checked).Against
-race+ repetition: blind per (b) above, and empirically a coin flip even where it isn't blind — see §4.Against making it structural: attractive for invariant 1 (a worker that deletes its own map entry on exit, with reap only signalling, turns "only idle workers are reaped" from a rule into ownership) and worth a follow-up issue if you want it. But types can't express "at most one goroutine executes passes for id" in Go — the
go w.run()twice mutation defeats any map discipline from inside the structure — and nothing structural covers 2, 3 or 5. Any such refactor also needs these tests in place first to gate it. So: tests now; the structural rework for invariant 1 is optional and separately gated.One negative-window caveat, stated once: two of the tests below assert "a second goroutine did NOT enter" via a 250ms select. That is the suite's established pattern (
TestCreateConcurrencyIsCappeduses exactly this) and its reliability is scheduler liveness, not an interleaving lottery: under the mutation the second goroutine is runnable and blocked on nothing, so it reaches the probe within the window essentially always. False-pass requires the scheduler to starve a runnable goroutine for 250ms.3. Per-invariant designs
Inv 1 — "Only IDLE workers are reaped" (worker.go:76)
Verified: I neutralized the idle check; suite green under
-race -count=2.TestBusyWorkerIsNotReaped.wedge().arrived(pass parked in the fetch) → callm.reapAbsent(map[string]assignment{})directly, modelling the transiently-unreadable-record tick the comment names (LoadVMs skips a live VM, dropping it out oflive). Driving this through Step is not cleanly reachable (the record exists while the pass runs, so the union puts vm1 back inlive); the manager layer is already a first-class test surface here (count,waitIdle).m.count() == 1immediately afterreapAbsent— fully synchronous, zero timing. Thenclose(release),waitIdle,reapAbsent(empty)again →count() == 0(the idle half, making the pair readable).Inv 2 — the GENERATION RULE (worker.go:242)
TestAStragglerFromAnEarlierPassCannotLandOnALaterOne— the missing sibling ofTestNarrationCannotOutliveThePassThatSaidIt, which only pins the!w.busyhalf.Imagesfake with two behaviours: pass 1 captures its progress callback (as the existing straggler test does) then returns an error; pass 2 publishes a distinguishable narration line, then parks onrelease.stepNoWait(snap(1, vm1))→ pass 1 fails, straggler captured,waitIdle.stepNoWait(snap(2, vm1))→ pass 2 parked mid-fetch (busy=true, gen advanced), wait for its narration. Invoke the captured pass-1 callback.aggregateNow.StatusDetailis pass 2's line, never pass 1's. Deterministic — the worker is parked, nothing races.if !w.busy || gen != w.gen→if !w.busy: busy is true, straggler admitted, pass-1 detail lands. Deletew.gen++: both passes carry gen 0, equality holds, same landing.Inv 3 — "collect clones the rows" (worker.go ~127) — the argued hazard is obsolete; the invariant survives on different grounds
Be skeptical here as invited: the comment's mechanism — "proto.Marshal writes a size cache … two reports marshalled concurrently would race on one message" — is no longer true. protobuf v1.36.11 (this repo's version) reads and writes the size cache with
atomic.LoadInt32/StoreInt32(verified in the module cache,internal/impl/encode.go:56,67,128). Concurrent Marshal of one message is race-clean, which is why the clone-removal mutation stayed green at-race -count=5— there is no race to catch, and the issue's proposed test shape (two concurrent aggregates, both Marshalled) would be a permanently-green alarm. Do not build it.The clone still earns its place, for a reason one line below the comment:
vmResult.mergewrites into the row —r.vm.SshHostPubkey = r.hostPubKey(reconcile.go:1090). Without the clone, every collect writes into the worker's live proto, and every report shares row pointers with every other report and with the worker (two Steps from concurrent sessions — the reconnect case syncsvc already documents — makes those writes genuinely concurrent).TestEveryReportOwnsItsRows.waitIdle, thenaggregateNowtwice sequentially.findVM(rep1,"vm1") != findVM(rep2,"vm1")(and both rows carry the pubkey). Single-threaded, deterministic, killsown := resoutright.Inv 4 — "one goroutine per VM — the worker IS the lock"
TestABusyVMNeverRunsTwoPassesAtOnce.Imagesto increment/decrement a per-vmID gauge (mutex-guarded, recording the high-water mark) around the wedge.arrived). While parked,stepNoWaita second snapshot containing vm1 — this setspending, the coalescing path. Select on a secondarrivedfor 250ms: a second arrival is an immediate fail. Thenclose(release),waitIdle, and asserthighWater("vm1") == 1.run's wait condition ispending == nil && !stopped— it deliberately does not checkbusy, because one loop can't be in two places. A secondgo w.run()loop is in a second place: it consumes the new pending while loop 1 is parked and enters the fetch. It is runnable and unblocked — scheduler liveness, not luck (the caveat in §2).Inv 5 —
netTracker.writeThroughatomicity (server/syncsvc/tracker.go:103)TestNetTrackerWriteThroughHoldsTheLockAcrossTheWrite.writesignals entered, then parks on a channel.writeThrough("h1", "10.0.0.0/24", parkingWrite). Wait for A inside. Goroutine B:writeThrough("h1", "192.168.0.0/24", write that signals bEntered). SelectbEnteredvs 250ms: entered → fail; window passes → release A, drain both.4. The unreliable alarm:
TestStatusTrackerWriteThroughAtomicVerified the mechanism, and it is worse than "needs -count=5": I applied the unlock-across-the-write mutation and single
-raceruns flip ok/FAIL roughly coin-flip;-count=5fails. Crucially the failure comes from the assertion, never a race report — the mutated code is still race-clean (the test's ownmucoverslastWritten), so-racecontributes nothing and the 300-pair lottery is the entire detector. It is also self-healing: a cache/row divergence is corrected by the next write of the other status, so only a divergence among the final few operations survives to the single end-state check. CI (make test) runs count=1: as a regression alarm this is a coin flip.Fix: restructure, not pin the count. Rebuild it on the same park-inside-write pattern as inv 5 (parked "ready" writer, concurrent "failed" writer, exclusion + final-state assertions). Pinning
-count(or looping internally) only raises the odds and keeps the self-healing blind spot. Keeping the stochastic loop as a cheap extra is fine, but the deterministic test is the alarm of record.Same weakness elsewhere? No — checked both packages: the 300-pair loop is the only stochastic concurrency test; every other concurrent test is channel-sequenced (
stepNoWait,wedge, syncsvc's own goroutine tests).5. Incidental findings
quotaCheckLockedearly return — confirmed, delete as a rider here. Everycasebelow guardsMax* > 0, so removing the early return changes no behaviour. "Dead" is slightly off: it executes (a fast path skipping the ledger sum when no caps are configured), but the saved work is a loop over tens of entries once per create attempt — noise. It is a guaranteed mutation blind spot and duplicates a rule the switch already codifies. One-line deletion; no separate issue.SeedLedger— retire this finding, it is wrong. The auditor searched only the package. It has a production caller:internal/agent/run/cli.go:352, seeding the admission ledger from persisted records at agent startup — exactly what the Engine comment ("Rebuilt from persisted records via SeedLedger") points at. Not dead, no action, no issue.Retired / corrected from the issue
[claude 2026-08-23] Claiming. Implementing Fable's design as posted: occupancy-wrapper + deterministic wedge for inv 1/2/4, pointer-ownership for inv 3, park-inside-write for inv 5 and the restructured statusTracker alarm. Riders: quotaCheckLocked early-return deletion and the CLONED comment reword. Every invariant will be mutation-proven before it lands.
[claude 2026-08-23] Done on branch
reconcile-concurrency-observers(3 commits, off main290ffad, make ci green). Fable's design followed as written; every invariant proven by its mutant at -race -count=5 -shuffle=on, 5/5 kills each, tree restored and git diff clean after every one.!w.busy || gen != w.gen->!w.busy; deletew.gen++), both land pass 1's line on pass 2's row. Straggler invoked while pass 2 is parked mid-fetch; fully deterministic.own := res. Asserted as pointer non-identity between two reports; single-threaded. Fable was right that the issue's proposed shape (two concurrent Marshals) would have been permanently green: protobuf v1.36.11 reads/writes sizeCache with atomics (verified in internal/impl/encode.go:56,67).go w.run()twice. New occupancy gauge over the Images seam + a second assignment delivered while the first pass is parked. "two passes entered the provisioner for one VM at once: the worker is no longer the lock"The unreliable alarm, measured rather than assumed: the old 300-pair TestStatusTrackerWriteThroughAtomic detected the unlock-across-write mutant on 3 of 12 single -race runs (make test runs count=1). Rebuilt on the parked-writer pattern: 5/5 at count=1. No stochastic loop kept — it added a second of runtime for a 25% alarm.
Riders:
2bc2ae23(self-removing workers) evaluated and declined with reasoning posted there — the restructure trades an observed two-line check for a lock-order inversion, a stopped-but-mapped window in deliver, and eventual instead of synchronous Stop/reap.