2596f764
test: the concurrency rules this package argues are now observed
a73x 2026-08-23 11:13
Commit message
internal/agent/reconcile/narrate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -165,3 +165,69 @@ func TestDownloadDetailWords(t *testing.T) { | |||
| 165 | // A server that declared no length gets the half it can vouch for. | 165 | // A server that declared no length gets the half it can vouch for. |
| 166 | assert.Equal(t, "downloading image 1.2 GiB", downloadDetail(1_288_490_188, -1)) | 166 | assert.Equal(t, "downloading image 1.2 GiB", downloadDetail(1_288_490_188, -1)) |
| 167 | } | 167 | } |
| 168 | |||
| 169 | // TestAStragglerFromAnEarlierPassCannotLandOnALaterOne is the generation half | ||
| 170 | // of the publish guard, and the sibling of the test above: that one kills a | ||
| 171 | // straggler after its pass ENDED (busy is false), this one kills a straggler | ||
| 172 | // from pass N while pass N+1 is in flight (busy is true, and only the | ||
| 173 | // generation tells them apart). Without it the report shows a phantom state | ||
| 174 | // from a pass that finished — the earlier pass's download progress on top of | ||
| 175 | // the live pass's own row — and the control plane reads narration as state. | ||
| 176 | // | ||
| 177 | // Nothing races here: pass 2 is parked inside its fetch, so the straggler is | ||
| 178 | // invoked from the test goroutine at a moment that is fully determined. | ||
| 179 | func TestAStragglerFromAnEarlierPassCannotLandOnALaterOne(t *testing.T) { | ||
| 180 | f := setup(t) | ||
| 181 | |||
| 182 | var mu sync.Mutex | ||
| 183 | var straggler func(done, total int64) | ||
| 184 | passes := 0 | ||
| 185 | firstFetched := make(chan struct{}) | ||
| 186 | secondNarrated := make(chan struct{}) | ||
| 187 | release := make(chan struct{}) | ||
| 188 | releaseAll := sync.OnceFunc(func() { close(release) }) | ||
| 189 | t.Cleanup(releaseAll) | ||
| 190 | |||
| 191 | f.eng.Images = func(_ context.Context, _, _ string, progress func(done, total int64)) (string, error) { | ||
| 192 | mu.Lock() | ||
| 193 | passes++ | ||
| 194 | n := passes | ||
| 195 | if n == 1 { | ||
| 196 | straggler = progress // kept past the fetch, the way a shared fetch keeps it | ||
| 197 | } | ||
| 198 | mu.Unlock() | ||
| 199 | if n == 1 { | ||
| 200 | close(firstFetched) | ||
| 201 | return "", errors.New("fetch gave up") | ||
| 202 | } | ||
| 203 | progress(2_147_483_648, 4_294_967_296) // pass 2's own line: 2.0/4.0 GiB | ||
| 204 | close(secondNarrated) | ||
| 205 | <-release | ||
| 206 | return "", errors.New("fetch gave up") | ||
| 207 | } | ||
| 208 | |||
| 209 | stepNoWait(t, f, snap(1, vm("vm1"))) | ||
| 210 | <-firstFetched | ||
| 211 | f.eng.manager().waitIdle() // pass 1 is over; its generation is closed | ||
| 212 | |||
| 213 | stepNoWait(t, f, snap(2, vm("vm1"))) | ||
| 214 | <-secondNarrated | ||
| 215 | require.Equal(t, "downloading image 2.0/4.0 GiB", | ||
| 216 | findVM(f.aggregateNow(2), "vm1").GetStatusDetail(), | ||
| 217 | "precondition: pass 2 is in flight and narrating") | ||
| 218 | |||
| 219 | // Pass 1's fetch goroutine drains on and says one more thing — while pass 2 | ||
| 220 | // is still running, so busy alone cannot tell it apart from pass 2's own. | ||
| 221 | mu.Lock() | ||
| 222 | say := straggler | ||
| 223 | mu.Unlock() | ||
| 224 | require.NotNil(t, say, "precondition: pass 1's progress callback outlived pass 1") | ||
| 225 | say(1_288_490_188, 3_972_844_748) // pass 1's line: 1.2/3.7 GiB | ||
| 226 | |||
| 227 | assert.Equal(t, "downloading image 2.0/4.0 GiB", | ||
| 228 | findVM(f.aggregateNow(2), "vm1").GetStatusDetail(), | ||
| 229 | "a line from pass 1 landed while pass 2 was in flight: the report now carries a phantom state from a finished pass, and the pass actually running is hidden behind it") | ||
| 230 | |||
| 231 | releaseAll() | ||
| 232 | f.eng.manager().waitIdle() | ||
| 233 | } | ||
internal/agent/reconcile/reconcile_test.go
| Old | New | ||
|---|---|---|---|
| @@ -280,6 +280,14 @@ func vm(id string, opts ...func(*pb.VMSpec)) *pb.VMSpec { | |||
| 280 | func tombstoned(v *pb.VMSpec) *pb.VMSpec { v.Tombstoned = true; return v } | 280 | func tombstoned(v *pb.VMSpec) *pb.VMSpec { v.Tombstoned = true; return v } |
| 281 | func stopped(v *pb.VMSpec) { v.PowerState = "stopped" } | 281 | func stopped(v *pb.VMSpec) { v.PowerState = "stopped" } |
| 282 | 282 | ||
| 283 | // ownImage gives a VM an image no other VM uses. The Images seam is told a URL | ||
| 284 | // and a sha and never a vm_id, so this is what makes an observable hung on the | ||
| 285 | // image cache a PER-VM observable — see occupancy. | ||
| 286 | func ownImage(v *pb.VMSpec) { | ||
| 287 | v.ImageUrl = "http://x/" + v.VmId + ".img" | ||
| 288 | v.ImageSha256 = v.VmId | ||
| 289 | } | ||
| 290 | |||
| 283 | func findVM(rep *pb.Report, id string) *pb.VMStatus { | 291 | func findVM(rep *pb.Report, id string) *pb.VMStatus { |
| 284 | for _, v := range rep.Vms { | 292 | for _, v := range rep.Vms { |
| 285 | if v.VmId == id { | 293 | if v.VmId == id { |
internal/agent/reconcile/worker_test.go
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,7 @@ package reconcile | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "sync" | ||
| 5 | "testing" | 6 | "testing" |
| 6 | "time" | 7 | "time" |
| 7 | 8 | ||
| @@ -224,3 +225,143 @@ func TestCreateConcurrencyZeroIsUnlimited(t *testing.T) { | |||
| 224 | close(release) | 225 | close(release) |
| 225 | f.eng.manager().waitIdle() | 226 | f.eng.manager().waitIdle() |
| 226 | } | 227 | } |
| 228 | |||
| 229 | // ---- exclusion observables ---- | ||
| 230 | // | ||
| 231 | // Every structure in this package is individually mutex-guarded, so an | ||
| 232 | // execution that runs two passes for one VM performs no unsynchronized access | ||
| 233 | // and -race stays silent by construction. Exclusion has to be OBSERVED: the | ||
| 234 | // gauge below is read at the moment two passes would be inside the same | ||
| 235 | // operation, which is the only moment they differ from one pass run twice. | ||
| 236 | |||
| 237 | // imagesFunc is the Engine.Images seam, named so the wrapper below reads. | ||
| 238 | type imagesFunc = func(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) | ||
| 239 | |||
| 240 | // occupancy counts how many goroutines are inside a wrapped operation at once, | ||
| 241 | // per key, and remembers the high-water mark. | ||
| 242 | type occupancy struct { | ||
| 243 | mu sync.Mutex | ||
| 244 | in map[string]int | ||
| 245 | high map[string]int | ||
| 246 | } | ||
| 247 | |||
| 248 | func newOccupancy() *occupancy { return &occupancy{in: map[string]int{}, high: map[string]int{}} } | ||
| 249 | |||
| 250 | // watchImages wraps an Images func with the gauge, keyed by image sha — which | ||
| 251 | // is a VM's identity for a VM built with ownImage. | ||
| 252 | func (o *occupancy) watchImages(next imagesFunc) imagesFunc { | ||
| 253 | return func(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) { | ||
| 254 | o.mu.Lock() | ||
| 255 | o.in[sha]++ | ||
| 256 | o.high[sha] = max(o.high[sha], o.in[sha]) | ||
| 257 | o.mu.Unlock() | ||
| 258 | defer func() { | ||
| 259 | o.mu.Lock() | ||
| 260 | o.in[sha]-- | ||
| 261 | o.mu.Unlock() | ||
| 262 | }() | ||
| 263 | return next(ctx, url, sha, progress) | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | func (o *occupancy) highWater(key string) int { | ||
| 268 | o.mu.Lock() | ||
| 269 | defer o.mu.Unlock() | ||
| 270 | return o.high[key] | ||
| 271 | } | ||
| 272 | |||
| 273 | // TestABusyVMNeverRunsTwoPassesAtOnce pins the layer's whole reason for | ||
| 274 | // existing: one goroutine per VM, so the worker IS the lock and a single VM's | ||
| 275 | // operations are serial without a per-VM mutex. A second pass loop for one VM | ||
| 276 | // puts a Destroy/DeleteVM alongside a Boot/SaveVM with nothing between them. | ||
| 277 | // | ||
| 278 | // The second assignment is what makes this observable: it lands while the first | ||
| 279 | // pass is parked, so one loop coalesces it into pending and nothing new enters, | ||
| 280 | // while a second loop consumes it immediately and walks into the provisioner | ||
| 281 | // beside the pass already there. | ||
| 282 | func TestABusyVMNeverRunsTwoPassesAtOnce(t *testing.T) { | ||
| 283 | f := setup(t) | ||
| 284 | occ := newOccupancy() | ||
| 285 | arrived := make(chan struct{}, 2) | ||
| 286 | release := make(chan struct{}) | ||
| 287 | releaseAll := sync.OnceFunc(func() { close(release) }) | ||
| 288 | t.Cleanup(releaseAll) | ||
| 289 | f.eng.Images = occ.watchImages(wedge(arrived, release)) | ||
| 290 | |||
| 291 | stepNoWait(t, f, snap(1, vm("vm1", ownImage))) | ||
| 292 | select { | ||
| 293 | case <-arrived: | ||
| 294 | case <-time.After(2 * time.Second): | ||
| 295 | t.Fatal("worker never started the create") | ||
| 296 | } | ||
| 297 | |||
| 298 | stepNoWait(t, f, snap(2, vm("vm1", ownImage))) | ||
| 299 | select { | ||
| 300 | case <-arrived: | ||
| 301 | t.Fatal("two passes entered the provisioner for one VM at once: the worker is no longer the lock, so a Destroy can run against a Boot and orphan a hypervisor with no record left to find it by") | ||
| 302 | case <-time.After(250 * time.Millisecond): | ||
| 303 | } | ||
| 304 | |||
| 305 | releaseAll() | ||
| 306 | f.eng.manager().waitIdle() | ||
| 307 | assert.Equal(t, 1, occ.highWater("vm1"), | ||
| 308 | "a VM was inside its slow create more than once at a time: one goroutine per VM is what serializes this VM's operations") | ||
| 309 | } | ||
| 310 | |||
| 311 | // TestBusyWorkerIsNotReaped pins that reaping only ever takes an IDLE worker. | ||
| 312 | // A worker mid-pass owns its VM; dropping it from the map lets the next deliver | ||
| 313 | // for the same id spawn a SECOND worker, which is the two-goroutines-for-one-VM | ||
| 314 | // state this layer exists to prevent. | ||
| 315 | // | ||
| 316 | // reapAbsent is called directly with an empty live set because that is the | ||
| 317 | // production shape of the hazard: LoadVMs continues past read errors, so a | ||
| 318 | // transiently unreadable record drops a live VM out of live for one tick while | ||
| 319 | // its pass is still running. Driving it through Step cannot reproduce that — | ||
| 320 | // the record is readable, so the union puts the VM straight back. | ||
| 321 | func TestBusyWorkerIsNotReaped(t *testing.T) { | ||
| 322 | f := setup(t) | ||
| 323 | m := f.eng.manager() | ||
| 324 | arrived := make(chan struct{}, 1) | ||
| 325 | release := make(chan struct{}) | ||
| 326 | releaseAll := sync.OnceFunc(func() { close(release) }) | ||
| 327 | t.Cleanup(releaseAll) | ||
| 328 | f.eng.Images = wedge(arrived, release) | ||
| 329 | |||
| 330 | stepNoWait(t, f, snap(1, vm("vm1"))) | ||
| 331 | select { | ||
| 332 | case <-arrived: | ||
| 333 | case <-time.After(2 * time.Second): | ||
| 334 | t.Fatal("worker never started the create") | ||
| 335 | } | ||
| 336 | |||
| 337 | m.reapAbsent(map[string]assignment{}) // the tick that could not read vm1's record | ||
| 338 | require.Equal(t, 1, m.count(), | ||
| 339 | "a busy worker was reaped: the next deliver for this VM spawns a second goroutine for it, and a concurrent Destroy against a Boot orphans a hypervisor process") | ||
| 340 | |||
| 341 | // The other half, so the pair reads as one rule: once the pass ends, the | ||
| 342 | // same absent VM IS reaped — deferring a busy worker costs only a tick. | ||
| 343 | releaseAll() | ||
| 344 | m.waitIdle() | ||
| 345 | m.reapAbsent(map[string]assignment{}) | ||
| 346 | assert.Equal(t, 0, m.count(), "an idle worker for a VM that is gone must be reaped") | ||
| 347 | } | ||
| 348 | |||
| 349 | // TestEveryReportOwnsItsRows pins the clone in collect. A worker publishes once | ||
| 350 | // and then serves that result until its next pass ends, so without the clone | ||
| 351 | // every report taken in between carries the SAME row proto — and merge writes | ||
| 352 | // into the row it hands out (the level-triggered host key), so that write lands | ||
| 353 | // in reports other goroutines are already holding, and in the worker's own live | ||
| 354 | // result. Two Steps from concurrent sessions (an agent reconnect) is that case. | ||
| 355 | func TestEveryReportOwnsItsRows(t *testing.T) { | ||
| 356 | f := setup(t) | ||
| 357 | f.step(snap(1, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAA")))) | ||
| 358 | |||
| 359 | first := findVM(f.aggregateNow(1), "vm1") | ||
| 360 | second := findVM(f.aggregateNow(1), "vm1") | ||
| 361 | require.NotNil(t, first) | ||
| 362 | require.NotNil(t, second) | ||
| 363 | require.NotEmpty(t, first.GetSshHostPubkey(), "precondition: merge stamps the host key into the row it hands out") | ||
| 364 | |||
| 365 | assert.NotSame(t, first, second, | ||
| 366 | "two reports share one row proto: collect stopped cloning, so merge's write lands in every report already holding that row and in the worker's live result") | ||
| 367 | } | ||
internal/server/syncsvc/tracker_test.go
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "errors" | 4 | "errors" |
| 5 | "sync" | 5 | "sync" |
| 6 | "testing" | 6 | "testing" |
| 7 | "time" | ||
| 7 | 8 | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" |
| 9 | ) | 10 | ) |
| @@ -132,34 +133,84 @@ func TestStatusTrackerDroppedAddressNotCached(t *testing.T) { | |||
| 132 | } | 133 | } |
| 133 | } | 134 | } |
| 134 | 135 | ||
| 135 | // TestStatusTrackerWriteThroughAtomic pins the invariant Fix D restores: under | 136 | // parkedWriter is the shape both write-through atomicity tests need: a durable |
| 136 | // concurrent conflicting reports for one VM, the cached triple always equals the | 137 | // write that announces it has started and then holds still until released, so a |
| 137 | // LAST durable write. writeThrough holds the lock across write+commit, so the | 138 | // test can apply a second write for the same key at the one moment the two |
| 138 | // two can never be reordered across goroutines and leave the cache disagreeing | 139 | // could interleave. The lock either keeps the second writer out of its write — |
| 139 | // with the row (the divergence that would suppress every later report). Runs | 140 | // which is the invariant — or it does not, and the second writer says so |
| 140 | // under -race to exercise the interleavings. | 141 | // immediately. It is never a question of which goroutine wins a race: under a |
| 142 | // broken lock the second writer is runnable and blocked on nothing. | ||
| 143 | type parkedWriter struct { | ||
| 144 | inside chan struct{} // closed once this writer is inside its durable write | ||
| 145 | release chan struct{} // closed by the test to let it finish | ||
| 146 | } | ||
| 147 | |||
| 148 | func newParkedWriter() *parkedWriter { | ||
| 149 | return &parkedWriter{inside: make(chan struct{}), release: make(chan struct{})} | ||
| 150 | } | ||
| 151 | |||
| 152 | // entered reports whether a writer got into its durable write within a window | ||
| 153 | // long enough that only a starved goroutine could miss it. | ||
| 154 | func entered(inside <-chan struct{}) bool { | ||
| 155 | select { | ||
| 156 | case <-inside: | ||
| 157 | return true | ||
| 158 | case <-time.After(250 * time.Millisecond): | ||
| 159 | return false | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | // TestStatusTrackerWriteThroughAtomic pins that decide→write→commit runs under | ||
| 164 | // one lock per VM. Concurrent reports for the same host — an agent reconnect, | ||
| 165 | // where the old and new sessions both deliver a report — must not interleave | ||
| 166 | // the durable write and the cache update, because a cache left disagreeing with | ||
| 167 | // the row treats every later identical report as "unchanged" and suppresses it | ||
| 168 | // forever. | ||
| 169 | // | ||
| 170 | // The exclusion is asserted directly rather than sampled: -race cannot see this | ||
| 171 | // (the tracker's own mutex covers every shared access, so an unlocked write is | ||
| 172 | // still race-clean), and an end-state check over many random pairs is both a | ||
| 173 | // coin flip per run and self-healing — a divergence is papered over by the next | ||
| 174 | // write of the other status, so only one among the final few survives to be | ||
| 175 | // seen. | ||
| 141 | func TestStatusTrackerWriteThroughAtomic(t *testing.T) { | 176 | func TestStatusTrackerWriteThroughAtomic(t *testing.T) { |
| 142 | tr := newStatusTracker() | 177 | tr := newStatusTracker() |
| 178 | first := newParkedWriter() | ||
| 179 | secondInside := make(chan struct{}) | ||
| 143 | var mu sync.Mutex | 180 | var mu sync.Mutex |
| 144 | lastWritten := "" // status of the most recent successful durable write | 181 | lastWritten := "" // status of the most recent successful durable write |
| 145 | 182 | ||
| 146 | writer := func(status string) { | 183 | go func() { |
| 147 | _ = tr.writeThrough("vm1", status, "", "10.0.0.1", func() (string, error) { | 184 | _ = tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() (string, error) { |
| 185 | close(first.inside) | ||
| 186 | <-first.release | ||
| 148 | mu.Lock() | 187 | mu.Lock() |
| 149 | lastWritten = status // recorded inside writeThrough's critical section | 188 | lastWritten = "ready" |
| 150 | mu.Unlock() | 189 | mu.Unlock() |
| 151 | return "10.0.0.1", nil | 190 | return "10.0.0.1", nil |
| 152 | }) | 191 | }) |
| 153 | } | 192 | }() |
| 193 | <-first.inside | ||
| 154 | 194 | ||
| 155 | var wg sync.WaitGroup | 195 | done := make(chan struct{}) |
| 156 | for range 300 { | 196 | go func() { |
| 157 | wg.Add(2) | 197 | defer close(done) |
| 158 | go func() { defer wg.Done(); writer("ready") }() | 198 | _ = tr.writeThrough("vm1", "failed", "", "10.0.0.1", func() (string, error) { |
| 159 | go func() { defer wg.Done(); writer("failed") }() | 199 | close(secondInside) |
| 200 | mu.Lock() | ||
| 201 | lastWritten = "failed" | ||
| 202 | mu.Unlock() | ||
| 203 | return "10.0.0.1", nil | ||
| 204 | }) | ||
| 205 | }() | ||
| 206 | |||
| 207 | if entered(secondInside) { | ||
| 208 | close(first.release) | ||
| 209 | t.Fatal("a second report for the same VM entered its durable write while the first was mid-write: decide→write→commit is no longer atomic, so the cache can commit a status the row does not hold and suppress every later report of the true one") | ||
| 160 | } | 210 | } |
| 161 | wg.Wait() | ||
| 162 | 211 | ||
| 212 | close(first.release) | ||
| 213 | <-done | ||
| 163 | tr.mu.Lock() | 214 | tr.mu.Lock() |
| 164 | cached := tr.last["vm1"].status | 215 | cached := tr.last["vm1"].status |
| 165 | tr.mu.Unlock() | 216 | tr.mu.Unlock() |
| @@ -169,6 +220,58 @@ func TestStatusTrackerWriteThroughAtomic(t *testing.T) { | |||
| 169 | require.Equal(t, dbLast, cached, "cache must equal the last durable write") | 220 | require.Equal(t, dbLast, cached, "cache must equal the last durable write") |
| 170 | } | 221 | } |
| 171 | 222 | ||
| 223 | // TestNetTrackerWriteThroughAtomic is the same invariant for the per-host facts | ||
| 224 | // (subnet, host address). Two connections for one host — the reconnect again — | ||
| 225 | // must not interleave: a cache holding a value the row never took makes every | ||
| 226 | // later report of the true value read as "unchanged", and the fleet keeps | ||
| 227 | // serving the stale subnet for as long as the process lives. | ||
| 228 | func TestNetTrackerWriteThroughAtomic(t *testing.T) { | ||
| 229 | tr := newNetTracker() | ||
| 230 | first := newParkedWriter() | ||
| 231 | secondInside := make(chan struct{}) | ||
| 232 | var mu sync.Mutex | ||
| 233 | lastWritten := "" | ||
| 234 | |||
| 235 | go func() { | ||
| 236 | _ = tr.writeThrough("h1", "10.77.1.0/24", func() error { | ||
| 237 | close(first.inside) | ||
| 238 | <-first.release | ||
| 239 | mu.Lock() | ||
| 240 | lastWritten = "10.77.1.0/24" | ||
| 241 | mu.Unlock() | ||
| 242 | return nil | ||
| 243 | }) | ||
| 244 | }() | ||
| 245 | <-first.inside | ||
| 246 | |||
| 247 | done := make(chan struct{}) | ||
| 248 | go func() { | ||
| 249 | defer close(done) | ||
| 250 | _ = tr.writeThrough("h1", "192.168.64.0/24", func() error { | ||
| 251 | close(secondInside) | ||
| 252 | mu.Lock() | ||
| 253 | lastWritten = "192.168.64.0/24" | ||
| 254 | mu.Unlock() | ||
| 255 | return nil | ||
| 256 | }) | ||
| 257 | }() | ||
| 258 | |||
| 259 | if entered(secondInside) { | ||
| 260 | close(first.release) | ||
| 261 | t.Fatal("a second write for the same host entered while the first was mid-write: decide→write→commit is no longer atomic per host, so the cache can hold a subnet the row never took and suppress every later report of the real one") | ||
| 262 | } | ||
| 263 | |||
| 264 | close(first.release) | ||
| 265 | <-done | ||
| 266 | tr.mu.Lock() | ||
| 267 | cached := tr.last["h1"] | ||
| 268 | tr.mu.Unlock() | ||
| 269 | mu.Lock() | ||
| 270 | dbLast := lastWritten | ||
| 271 | mu.Unlock() | ||
| 272 | require.Equal(t, dbLast, cached, "cache must equal the last durable write") | ||
| 273 | } | ||
| 274 | |||
| 172 | func TestStatusTrackerForget(t *testing.T) { | 275 | func TestStatusTrackerForget(t *testing.T) { |
| 173 | tr := newStatusTracker() | 276 | tr := newStatusTracker() |
| 174 | if !tr.write("vm1", "ready", "", "10.0.0.1") { | 277 | if !tr.write("vm1", "ready", "", "10.0.0.1") { |