carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "log"
10 "math/rand"
11 "os"
12 "path/filepath"
13 "regexp"
14 "sort"
15 "strconv"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "testing"
20 "time"
21
22 "github.com/carlosframework/platform/internal/cgroup"
23 "github.com/carlosframework/platform/internal/registry"
24)
25
26// TestSwapInvariants drives random schedules of requests, overlap and hold
27// swaps, hibernation, lease loss, restarts, maintenance fences, root
28// repoints, supersession, crashes, re-stop cadences and a final Shutdown
29// against the real Service and a simulated backend. It checks I1, I2 and I3
30// (plan 3 task 13; spec §12).
31//
32// The simulated processes do not all stop when asked. Some drain slowly,
33// some answer ErrStillRunning or a failed kill and exit on their own later,
34// some generations fork a process that outlives its leader, KillGen can
35// fail before it heals, and a start can fail with its kill unproven
36// (errGenUnproven), with or without a process handle. So the
37// retain-until-proven paths (pending stops, the member and wake reapers)
38// run under the sweep too. Each seed also picks whether generations are on
39// (the dormant switch) and whether generation containment is.
40//
41// Seeds: SWAP_SIM_SEEDS (default 100). SWAP_SIM_SEED=<n> replays one seed.
42// Goroutine interleaving is not deterministic, so a replay is best effort.
43// The event log printed with a failure is the evidence.
44//
45// The mutation checks (testdata/swapsim/mutate.sh) run this sweep and the
46// TestSwapWitness* tests against each `// swapsim:Mn` mutant.
47func TestSwapInvariants(t *testing.T) {
48 first, last := 0, 100
49 if v := os.Getenv("SWAP_SIM_SEEDS"); v != "" {
50 n, err := strconv.Atoi(v)
51 if err != nil {
52 t.Fatal(err)
53 }
54 last = n
55 }
56 if v := os.Getenv("SWAP_SIM_SEED"); v != "" {
57 n, err := strconv.Atoi(v)
58 if err != nil {
59 t.Fatal(err)
60 }
61 first, last = n, n+1
62 }
63 oldSettle, oldJoin := swapSettle, opJoinBound
64 swapSettle, opJoinBound = 2*time.Millisecond, 3*time.Second
65 t.Cleanup(func() { swapSettle, opJoinBound = oldSettle, oldJoin })
66 // The Service's own log lines go into the seed's event log, so a failure
67 // prints them in order with the model's events, and a passing sweep
68 // prints nothing.
69 sink := &simLogSink{}
70 oldOut, oldFlags := log.Writer(), log.Flags()
71 log.SetOutput(sink)
72 log.SetFlags(0)
73 t.Cleanup(func() { log.SetOutput(oldOut); log.SetFlags(oldFlags) })
74 staged := stageSimBuilds(t)
75 sockDir := t.TempDir()
76 failed := 0
77 tally := map[string]int{}
78 t.Cleanup(func() {
79 keys := make([]string, 0, len(tally))
80 for k := range tally {
81 keys = append(keys, k)
82 }
83 sort.Strings(keys)
84 lines := make([]string, len(keys))
85 for i, k := range keys {
86 lines[i] = fmt.Sprintf("%6d %s", tally[k], k)
87 }
88 t.Logf("seeds %d..%d: seed settings and Swap results (mode/outcome: reason, normalized):\n%s", first, last-1, strings.Join(lines, "\n"))
89 })
90 for seed := first; seed < last; seed++ {
91 violations, events, outcomes := simulateSwapOnce(seed, staged, sockDir, sink)
92 for k, n := range outcomes {
93 tally[k] += n
94 }
95 if len(violations) == 0 {
96 continue
97 }
98 failed++
99 if len(events) > 200 {
100 events = events[len(events)-200:]
101 }
102 // A seed whose only failure is liveness (a Swap that never returned)
103 // says so: it is not an invariant violation.
104 kind := "liveness failed"
105 for _, v := range violations {
106 if !strings.HasPrefix(v, "liveness:") {
107 kind = "invariant violated"
108 }
109 }
110 t.Errorf("%s at seed %d (replay: SWAP_SIM_SEED=%d): %s\n--- last events ---\n%s", kind, seed, seed, strings.Join(violations, "; "), strings.Join(events, "\n"))
111 if failed >= 3 {
112 t.FailNow()
113 }
114 }
115}
116
117type simArtifact struct{ path, digest string }
118
119func stageSimBuilds(t *testing.T) map[string]simArtifact {
120 t.Helper()
121 out := map[string]simArtifact{}
122 for _, b := range []string{"v3", "v4"} {
123 dir := filepath.Join(t.TempDir(), "versions", b)
124 if err := os.MkdirAll(dir, 0o755); err != nil {
125 t.Fatal(err)
126 }
127 p := filepath.Join(dir, "app")
128 body := []byte("build " + b)
129 if err := os.WriteFile(p, body, 0o755); err != nil {
130 t.Fatal(err)
131 }
132 sum := sha256.Sum256(body)
133 out[b] = simArtifact{p, hex.EncodeToString(sum[:])}
134 }
135 return out
136}
137
138// simLogSink routes the log package into the current seed's world. A
139// goroutine a previous seed left behind may log into the next seed's
140// events; the lines say what they are.
141type simLogSink struct{ w atomic.Pointer[simWorld] }
142
143func (s *simLogSink) Write(p []byte) (int, error) {
144 if w := s.w.Load(); w != nil {
145 w.logf("log: %s", strings.TrimRight(string(p), "\n"))
146 }
147 return len(p), nil
148}
149
150// simWorld is the model: which simulated processes run on each host, the
151// live link, the staged builds, and the violations seen. Lock order: a
152// caller may hold a.mu when it takes w.mu (the backend is called under the
153// entry lock), never the reverse; a process's own p.mu nests inside w.mu
154// (die), never the reverse.
155type simWorld struct {
156 mu sync.Mutex
157 rng *rand.Rand
158 events []string
159 running map[string]map[*simProc]bool
160 live map[string]string
161 staged map[string]string
162 violations []string
163 seen map[string]bool
164 nextID int
165 hosts []string
166 // gensOn is this seed's dormant switch (Service.gens). contained is
167 // generation containment: with it, each generation has its own child
168 // that KillGen empties, and a start can fail with its kill unproven;
169 // without it, KillGen answers errNoGenContainment, the parent cannot be
170 // raised, and a generation's leader is all there is of it.
171 gensOn, contained bool
172 // killFails is how many more KillGen calls fail for host#n before the
173 // kill heals.
174 killFails map[string]int
175 // shutdown is set once Service.Shutdown has returned: nothing may spawn
176 // after it (I2).
177 shutdown bool
178 // outcomes counts the seed's settings and its Swap results by mode,
179 // outcome and reason, so a sweep shows what it reached.
180 outcomes map[string]int
181 // dying, when set, runs once a death is published, with no model lock
182 // held: the moment a reaper that saw Exited() could act. The model's
183 // own interleaving test sets it; the sweep never does.
184 dying func(p *simProc)
185}
186
187func (w *simWorld) logf(format string, args ...any) {
188 w.mu.Lock()
189 w.events = append(w.events, fmt.Sprintf(format, args...))
190 w.mu.Unlock()
191}
192
193func (w *simWorld) violatef(format string, args ...any) {
194 msg := fmt.Sprintf(format, args...)
195 w.mu.Lock()
196 defer w.mu.Unlock()
197 if w.seen[msg] {
198 return
199 }
200 w.seen[msg] = true
201 w.violations = append(w.violations, msg)
202 w.events = append(w.events, "VIOLATION "+msg)
203}
204
205func (w *simWorld) intn(n int) int {
206 w.mu.Lock()
207 defer w.mu.Unlock()
208 return w.rng.Intn(n)
209}
210
211func (w *simWorld) jitter() {
212 if d := w.intn(4); d > 0 {
213 time.Sleep(time.Duration(d) * 250 * time.Microsecond)
214 }
215}
216
217// spawn starts one simulated process of generation n. A leader gets a
218// random stop behaviour; a descendant (a process the app forked) stops
219// only when killed.
220func (w *simWorld) spawn(host, build string, n uint64, leader bool) *simProc {
221 w.mu.Lock()
222 w.nextID++
223 p := &simProc{w: w, id: w.nextID, host: host, build: build, n: n, leader: leader}
224 if leader {
225 switch r := w.rng.Intn(20); {
226 case r < 2:
227 p.mode = procStubborn
228 case r < 3:
229 // A failed kill that is not ErrStillRunning is a claims-on
230 // process's answer (KillAndEmpty), and the generations-on stop
231 // handles it through releasable. With generations off the exec
232 // backends' Stop (childProc, unitProc) answers ErrStillRunning
233 // for a process that may remain, so that is what it gets.
234 p.mode = procStubborn
235 if w.gensOn {
236 p.mode = procKillFails
237 }
238 case r < 7:
239 p.mode = procSlow
240 }
241 }
242 if w.running[host] == nil {
243 w.running[host] = map[*simProc]bool{}
244 }
245 w.running[host][p] = true
246 what := "spawn"
247 if !leader {
248 what = "fork"
249 }
250 w.events = append(w.events, fmt.Sprintf("%s p%d %s build=%s gen=%d mode=%s", what, p.id, host, build, n, p.mode))
251 after := w.shutdown && w.gensOn
252 w.mu.Unlock()
253 if after {
254 w.violatef("I2: p%d (%s build %s) spawned after Shutdown returned", p.id, host, build)
255 }
256 return p
257}
258
259// spawnGen starts generation n's leader. With generations and containment
260// on, one generation in four also forks a process that outlives its
261// leader, which only KillGen (or a crash) ends.
262func (w *simWorld) spawnGen(host, build string, n uint64) *simProc {
263 p := w.spawn(host, build, n, true)
264 if w.gensOn && w.contained && n != 0 && w.intn(4) == 0 {
265 w.spawn(host, build, n, false)
266 }
267 return p
268}
269
270func (w *simWorld) runningOn(host string) int {
271 w.mu.Lock()
272 defer w.mu.Unlock()
273 return len(w.running[host])
274}
275
276func (w *simWorld) runningCount() int {
277 w.mu.Lock()
278 defer w.mu.Unlock()
279 n := 0
280 for _, ps := range w.running {
281 n += len(ps)
282 }
283 return n
284}
285
286func (w *simWorld) leaseFreed(host, by string) {
287 if n := w.runningOn(host); n > 0 {
288 w.violatef("I2: lease of %s released by %s while %d generation process(es) run", host, by, n)
289 }
290 w.logf("lease %s released by %s", host, by)
291}
292
293func (w *simWorld) setLive(host, build string) {
294 w.mu.Lock()
295 w.live[host] = build
296 w.events = append(w.events, "repoint "+host+" -> "+build)
297 w.mu.Unlock()
298}
299
300func (w *simWorld) crashOne(host string) {
301 w.mu.Lock()
302 var victims []*simProc
303 for p := range w.running[host] {
304 victims = append(victims, p)
305 }
306 // Map order is random; sort so the seed picks the victim.
307 sort.Slice(victims, func(i, j int) bool { return victims[i].id < victims[j].id })
308 var p *simProc
309 if len(victims) > 0 {
310 p = victims[w.rng.Intn(len(victims))]
311 }
312 w.mu.Unlock()
313 if p != nil {
314 p.die("crash")
315 }
316}
317
318var simDigits = regexp.MustCompile(`[0-9]+`)
319
320// tally counts one Swap result. The reason is normalized (host names and
321// numbers out, length capped) so that like results group.
322func (w *simWorld) tally(res SwapResult) {
323 key := string(res.Mode) + "/" + string(res.Outcome)
324 if res.Outcome != SwapDone && res.Reason != "" {
325 r := res.Reason
326 for _, h := range w.hosts {
327 r = strings.ReplaceAll(r, h, "<host>")
328 }
329 r = simDigits.ReplaceAllString(r, "N")
330 if len(r) > 110 {
331 r = r[:110] + "..."
332 }
333 key += ": " + r
334 }
335 w.mu.Lock()
336 w.outcomes[key]++
337 w.mu.Unlock()
338}
339
340func (w *simWorld) snapshot() ([]string, []string, map[string]int) {
341 w.mu.Lock()
342 defer w.mu.Unlock()
343 outcomes := make(map[string]int, len(w.outcomes))
344 for k, n := range w.outcomes {
345 outcomes[k] = n
346 }
347 return append([]string(nil), w.violations...), append([]string(nil), w.events...), outcomes
348}
349
350// simProcMode is how a simulated leader answers Stop.
351type simProcMode int
352
353const (
354 // procPrompt ends at once.
355 procPrompt simProcMode = iota
356 // procSlow drains for a few milliseconds, then ends.
357 procSlow
358 // procStubborn answers ErrStillRunning: SIGKILL did not prove it gone.
359 // It exits on its own a few milliseconds after it was first asked.
360 procStubborn
361 // procKillFails answers a kill error that is not ErrStillRunning (a
362 // claims-on KillAndEmpty failure), and exits on its own later, as
363 // procStubborn does. Generations-on seeds only (see spawn).
364 procKillFails
365)
366
367func (m simProcMode) String() string {
368 return [...]string{"prompt", "slow", "stubborn", "killfails"}[m]
369}
370
371var errSimKillFailed = errors.New("sim: kill failed")
372
373// simProc is one simulated process of a generation: its leader, or a
374// process the leader forked (leader false), which nothing but KillGen or a
375// crash ends.
376type simProc struct {
377 w *simWorld
378 id int
379 host string
380 build string
381 n uint64
382 leader bool
383 mode simProcMode
384 mu sync.Mutex
385 dead bool
386 later bool
387}
388
389func (p *simProc) Stop(context.Context) error {
390 p.w.jitter()
391 if p.Exited() {
392 return nil
393 }
394 switch p.mode {
395 case procSlow:
396 time.Sleep(time.Duration(1+p.w.intn(6)) * time.Millisecond)
397 case procStubborn:
398 p.exitLater()
399 return ErrStillRunning
400 case procKillFails:
401 p.exitLater()
402 return errSimKillFailed
403 }
404 p.die("stop")
405 return nil
406}
407
408// exitLater arms the one delayed exit of a process that did not stop when
409// asked.
410func (p *simProc) exitLater() {
411 p.mu.Lock()
412 armed := p.later
413 p.later = true
414 p.mu.Unlock()
415 if armed {
416 return
417 }
418 d := time.Duration(1+p.w.intn(15)) * time.Millisecond
419 time.AfterFunc(d, func() { p.die("late exit") })
420}
421
422func (p *simProc) Exited() bool {
423 p.mu.Lock()
424 defer p.mu.Unlock()
425 return p.dead
426}
427
428// die is one transition of the model: the process is marked dead and
429// leaves running in the same critical section (w.mu, then p.mu, the
430// model's lock order), so no observer can see it Exited() and still
431// running. A concurrent die waits on w.mu and finds it already dead.
432func (p *simProc) die(why string) {
433 p.w.mu.Lock()
434 p.mu.Lock()
435 was := p.dead
436 p.dead = true
437 p.mu.Unlock()
438 if !was {
439 delete(p.w.running[p.host], p)
440 p.w.events = append(p.w.events, fmt.Sprintf("exit p%d (%s)", p.id, why))
441 }
442 p.w.mu.Unlock()
443 if !was && p.w.dying != nil {
444 p.w.dying(p)
445 }
446}
447
448type simRep struct {
449 w *simWorld
450 host string
451 mu sync.Mutex
452 err error
453}
454
455func (r *simRep) Err() error {
456 r.mu.Lock()
457 defer r.mu.Unlock()
458 return r.err
459}
460
461func (r *simRep) Close(context.Context) error {
462 r.w.jitter()
463 r.w.leaseFreed(r.host, "rep.Close")
464 return nil
465}
466
467type simLease struct {
468 b *simBackend
469 host string
470}
471
472func (l *simLease) Replicate(context.Context, string, time.Duration) (Rep, error) {
473 r := &simRep{w: l.b.w, host: l.host}
474 l.b.mu.Lock()
475 l.b.reps[l.host] = r
476 l.b.mu.Unlock()
477 return r, nil
478}
479
480func (l *simLease) Release(context.Context) error {
481 l.b.w.leaseFreed(l.host, "lease.Release")
482 return nil
483}
484
485// simBackend is Backend, GenBackend, SwapBackend and genKiller, as the
486// exec backend is. It has no hostKiller or sidecarKiller (no workers, no
487// sidecars). The live-link wake goes through StartGen, so every generation
488// has a number KillGen can name.
489type simBackend struct {
490 w *simWorld
491 dir string
492 mu sync.Mutex
493 reps map[string]*simRep
494}
495
496var (
497 _ Backend = (*simBackend)(nil)
498 _ GenBackend = (*simBackend)(nil)
499 _ SwapBackend = (*simBackend)(nil)
500 _ genKiller = (*simBackend)(nil)
501 _ DueStore = simDue{}
502)
503
504func (b *simBackend) loseLease(host string) {
505 b.mu.Lock()
506 r := b.reps[host]
507 b.mu.Unlock()
508 if r != nil {
509 r.mu.Lock()
510 r.err = errors.New("sim: lease lost")
511 r.mu.Unlock()
512 }
513}
514
515func (b *simBackend) Acquire(_ context.Context, rt registry.Route) (Lease, error) {
516 b.w.jitter()
517 if n := b.w.runningOn(rt.Host); n > 0 {
518 b.w.violatef("I2: lease of %s acquired while %d process(es) of the previous holder's generations run", rt.Host, n)
519 }
520 return &simLease{b: b, host: rt.Host}, nil
521}
522func (b *simBackend) Restore(context.Context, registry.Route) (bool, error) {
523 b.w.jitter()
524 return false, nil
525}
526func (b *simBackend) Seed(context.Context, registry.Route) error { return nil }
527
528// Start is the pre-generations spawn. The Service never calls it on a
529// GenBackend; it is here for the Backend interface.
530func (b *simBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) {
531 p, g, err := b.StartGen(ctx, rt, 0, false)
532 return p, g.Build, err
533}
534
535// StartGen is the wake's spawn of the live link (which only ever names v1
536// or v2).
537func (b *simBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) {
538 b.w.jitter()
539 b.w.mu.Lock()
540 build := b.w.live[rt.Host]
541 b.w.mu.Unlock()
542 g := canonicalGen(rt, n, build)
543 if perGen {
544 g.Socket = filepath.Join(b.dir, fmt.Sprintf("%s.%d.sock", rt.Host, n))
545 g.Control = g.Socket + ".control"
546 }
547 if err := ctx.Err(); err != nil {
548 return nil, g, err
549 }
550 p, err := b.startOutcome(rt.Host, build, n)
551 return p, g, err
552}
553
554// startOutcome is a spawn's result. Most run. Some fail before the exec,
555// with nothing running. With containment, some fail after it with their
556// kill unproven (errGenUnproven, as plan 2's startUnitAt answers): the
557// generation runs on, with its handle returned or not.
558func (b *simBackend) startOutcome(host, build string, n uint64) (Proc, error) {
559 w := b.w
560 r := w.intn(30)
561 switch {
562 case r == 0:
563 return nil, errors.New("sim: exec failed")
564 case r <= 2 && w.contained:
565 p := w.spawnGen(host, build, n)
566 err := fmt.Errorf("sim: health wait failed; %w: kill unproven", errGenUnproven)
567 w.logf("start of %s gen %d failed with its kill unproven (handle returned: %v)", host, n, r == 1)
568 if r == 1 {
569 return p, err
570 }
571 return nil, err
572 }
573 return w.spawnGen(host, build, n), nil
574}
575
576func (b *simBackend) NextDueVia(context.Context, registry.Route, string) (int64, error) {
577 return 0, nil
578}
579func (b *simBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil }
580func (b *simBackend) HasSidecar(registry.Route) bool { return false }
581func (b *simBackend) StartSidecar(context.Context, registry.Route) (Proc, error) {
582 return nil, errors.New("sim: no sidecars")
583}
584func (b *simBackend) StartWorker(context.Context, registry.Route, map[string]string) (Proc, string, error) {
585 return nil, "", errors.New("sim: no workers")
586}
587
588// StartStaged checks I3 at the exec: the staged path a generation runs is
589// the one its build was staged at. Its sockets live in the run's own
590// directory, because a hold's pinned wake links the canonical path to the
591// generation's socket for real (swapCanonicalLink).
592func (b *simBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) {
593 b.w.mu.Lock()
594 want := b.w.staged[g.Build]
595 b.w.mu.Unlock()
596 if g.StagedPath != want {
597 b.w.violatef("I3: generation %d of %s execs %q for build %s (staged at %q)", g.N, rt.Host, g.StagedPath, g.Build, want)
598 }
599 b.w.jitter()
600 if err := ctx.Err(); err != nil {
601 return g, nil, err
602 }
603 // The caller's exec gate at the launch point, after the preparation
604 // the jitter stands for (final review F1).
605 if err := runExecGate(ctx); err != nil {
606 return g, nil, err
607 }
608 g.Socket = filepath.Join(b.dir, fmt.Sprintf("%s.%d.sock", rt.Host, g.N))
609 g.Control = g.Socket + ".control"
610 p, err := b.startOutcome(rt.Host, g.Build, g.N)
611 return g, p, err
612}
613
614// KillGen is plan 2's generation kill: it empties generation n's child
615// and returns once the child is empty. One generation in six fails its
616// first one or two kills, then heals. A process that does not die when
617// killed (procStubborn, procKillFails) keeps the child from emptying until
618// it exits on its own. Without containment it answers errNoGenContainment,
619// as ExecBackend.KillGen does.
620func (b *simBackend) KillGen(_ context.Context, rt registry.Route, n uint64) error {
621 w := b.w
622 if !w.contained {
623 return fmt.Errorf("sim: %w", errNoGenContainment)
624 }
625 w.jitter()
626 key := fmt.Sprintf("%s#%d", rt.Host, n)
627 w.mu.Lock()
628 fails, seen := w.killFails[key]
629 if !seen && w.rng.Intn(6) == 0 {
630 fails = 1 + w.rng.Intn(2)
631 }
632 var procs []*simProc
633 for p := range w.running[rt.Host] {
634 if p.n == n {
635 procs = append(procs, p)
636 }
637 }
638 if fails > 0 {
639 w.killFails[key] = fails - 1
640 w.events = append(w.events, fmt.Sprintf("KillGen %s gen %d fails (%d more)", rt.Host, n, fails-1))
641 w.mu.Unlock()
642 return errors.New("sim: cgroup.kill: the child is not empty")
643 }
644 w.killFails[key] = 0
645 w.mu.Unlock()
646 stuck := 0
647 for _, p := range procs {
648 if p.mode == procStubborn || p.mode == procKillFails {
649 p.exitLater()
650 stuck++
651 continue
652 }
653 p.die("cgroup.kill")
654 }
655 if stuck > 0 {
656 return fmt.Errorf("sim: g%d still holds %d process(es)", n, stuck)
657 }
658 return nil
659}
660
661func (b *simBackend) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault }
662func (b *simBackend) GenReady(registry.Route, Gen, Proc) {}
663
664// RaiseGenParent refuses without containment, as plan 2's ParentLimit
665// does, so such a seed never overlaps.
666func (b *simBackend) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error {
667 if !b.w.contained {
668 return fmt.Errorf("sim: %w", errNoGenContainment)
669 }
670 return nil
671}
672func (b *simBackend) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error {
673 return nil
674}
675func (b *simBackend) GenSwitched(rt registry.Route, g Gen, _ Proc) error {
676 b.w.logf("switch %s -> gen %d (%s)", rt.Host, g.N, g.Build)
677 return nil
678}
679
680type simDue struct{}
681
682func (simDue) SetNextDue(string, int64) error { return nil }
683func (simDue) ClearNextDueIf(string, int64) error { return nil }
684func (simDue) DueInstances(int64) ([]registry.Route, error) { return nil, nil }
685func (simDue) AlwaysOnWorkers() ([]registry.Route, error) { return nil, nil }
686func (simDue) SetVersions(string, string, string) error { return nil }
687func (simDue) EarliestTimers() (map[string]int64, error) { return nil, nil }
688
689// checkSwap is I3 on the result: a SwapDone reports the attempt's build and
690// staged path. The live link only ever names v1 or v2 and attempts only
691// target v3 or v4, so a swap that ran the live link is a visible mismatch.
692func (w *simWorld) checkSwap(at Attempt, res SwapResult) {
693 w.logf("swap %s %s -> %s: %s %s", at.Host, at.Mode, at.Build, res.Outcome, res.Reason)
694 w.tally(res)
695 if res.Outcome != SwapDone {
696 return
697 }
698 if res.Gen.Build != at.Build || res.Gen.StagedPath != at.StagedPath {
699 w.violatef("I3: %s swap of %s to %s reported generation %d running %s from %q", at.Mode, at.Host, at.Build, res.Gen.N, res.Gen.Build, res.Gen.StagedPath)
700 }
701}
702
703// checkGenerations is I1, polled against the entry's state under a.mu
704// until stop closes.
705func (w *simWorld) checkGenerations(a *Service, hosts []string, stop <-chan struct{}) {
706 retired := map[string]map[uint64]bool{}
707 for {
708 for _, h := range hosts {
709 cur, members := a.genStateForTest(h)
710 if retired[h] == nil {
711 retired[h] = map[uint64]bool{}
712 }
713 attempts := 0
714 for n, role := range members {
715 if role == roleRetiring {
716 retired[h][n] = true
717 } else {
718 attempts++
719 }
720 if cur.N != 0 && n == cur.N {
721 w.violatef("I1: generation %d of %s is current and also %v", n, h, role)
722 }
723 }
724 if attempts > 1 {
725 w.violatef("I1: %d attempts in flight for %s", attempts, h)
726 }
727 if cur.N != 0 && retired[h][cur.N] {
728 w.violatef("I1: retired generation %d of %s is current again", cur.N, h)
729 }
730 }
731 select {
732 case <-stop:
733 return
734 case <-time.After(200 * time.Microsecond):
735 }
736 }
737}
738
739func onOff(b bool) string {
740 if b {
741 return "on"
742 }
743 return "off"
744}
745
746func simulateSwapOnce(seed int, staged map[string]simArtifact, sockDir string, sink *simLogSink) ([]string, []string, map[string]int) {
747 hosts := []string{"one.sim.example.com", "two.sim.example.com"}
748 w := &simWorld{rng: rand.New(rand.NewSource(int64(seed))), running: map[string]map[*simProc]bool{},
749 live: map[string]string{}, staged: map[string]string{}, seen: map[string]bool{}, outcomes: map[string]int{},
750 killFails: map[string]int{}, hosts: hosts}
751 // One seed in five runs with the dormant switch off (today's paths,
752 // where stop()'s own ErrStillRunning check is the only retention), and
753 // one in five without generation containment.
754 w.gensOn, w.contained = w.rng.Intn(5) != 0, w.rng.Intn(5) != 0
755 w.outcomes[fmt.Sprintf("seeds with generations %s, containment %s", onOff(w.gensOn), onOff(w.contained))]++
756 w.events = append(w.events, fmt.Sprintf("seed %d: generations %s, containment %s", seed, onOff(w.gensOn), onOff(w.contained)))
757 sink.w.Store(w)
758 defer sink.w.Store(nil)
759 for b, art := range staged {
760 w.staged[b] = art.path
761 }
762 for _, h := range hosts {
763 w.live[h] = "v1"
764 }
765 sb := &simBackend{w: w, dir: sockDir, reps: map[string]*simRep{}}
766 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
767 a := New(Config{Bucket: "sim", Idle: time.Hour}, sb, simDue{})
768 a.gens = w.gensOn
769 a.now = clk.now
770 a.genHealth = func(ctx context.Context, _ Gen) error {
771 w.jitter()
772 if err := ctx.Err(); err != nil {
773 return err
774 }
775 if w.intn(6) == 0 {
776 return errors.New("sim: unhealthy")
777 }
778 return nil
779 }
780 revs := map[string]*atomic.Uint64{}
781 for _, h := range hosts {
782 revs[h] = new(atomic.Uint64)
783 }
784 newest := func(h string) uint64 { return revs[h].Load() }
785
786 stopCheck, checked := make(chan struct{}), make(chan struct{})
787 go func() { defer close(checked); w.checkGenerations(a, hosts, stopCheck) }()
788
789 var reqs, swaps sync.WaitGroup
790 var fenceGen atomic.Uint64
791 // The sweeper. The real Service ticks it on its own goroutine, so a
792 // pending stop or an unproven wake is retried without an event to
793 // trigger it. Here it ticks every 5ms of wall time (the fake clock does
794 // not move, so nothing goes idle by it) and is serialized with the
795 // events' own sweeps: there is one sweeper. It stops before Shutdown, as
796 // the real one does.
797 var sweepMu sync.Mutex
798 sweep := func() {
799 sweepMu.Lock()
800 a.sweepOnce()
801 sweepMu.Unlock()
802 }
803 stopSweeper, sweeperDone := make(chan struct{}), make(chan struct{})
804 go func() {
805 defer close(sweeperDone)
806 tick := time.NewTicker(5 * time.Millisecond)
807 defer tick.Stop()
808 for {
809 select {
810 case <-stopSweeper:
811 return
812 case <-tick.C:
813 sweep()
814 }
815 }
816 }()
817 for i := 0; i < 25; i++ {
818 host := hosts[w.intn(len(hosts))]
819 rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(sockDir, host+".sock"),
820 DBPath: filepath.Join(sockDir, host+".db"), Hibernate: true, Status: "ready"}
821 switch w.intn(12) {
822 case 0, 1:
823 reqs.Add(1)
824 go func() {
825 defer reqs.Done()
826 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
827 defer cancel()
828 if release, err := a.beginRequest(ctx, rt); err == nil {
829 w.jitter()
830 release()
831 }
832 }()
833 case 2, 3, 4:
834 mode := ModeOverlap
835 if w.intn(3) == 0 {
836 mode = ModeHold
837 }
838 build := []string{"v3", "v4"}[w.intn(2)]
839 at := Attempt{Host: host, Cause: CauseRelease, Build: build, StagedPath: staged[build].path, Digest: staged[build].digest,
840 Mode: mode, SchedRev: revs[host].Add(1), Canary: w.intn(2) == 0}
841 // A scheduler swaps awake instances. Three attempts in four wake
842 // their host first, so most reach a spawn, a switch or a hold's
843 // pinned wake; the rest meet whatever state the host is in.
844 wakeFirst := w.intn(4) != 0
845 swaps.Add(1)
846 go func() {
847 defer swaps.Done()
848 if wakeFirst {
849 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
850 if release, err := a.beginRequest(ctx, rt); err == nil {
851 release()
852 }
853 cancel()
854 }
855 w.checkSwap(at, a.Swap(context.Background(), at, newest))
856 }()
857 case 5:
858 clk.advance(2 * time.Hour)
859 sweep()
860 case 6:
861 sb.loseLease(host)
862 sweep()
863 case 7:
864 a.Bounce(host)
865 case 8:
866 reqs.Add(1)
867 go func() {
868 defer reqs.Done()
869 g := fenceGen.Add(1)
870 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
871 defer cancel()
872 _, _ = a.FenceAndStop(ctx, host, g)
873 w.jitter()
874 a.LiftFence(host, g)
875 }()
876 case 9:
877 w.setLive(host, []string{"v1", "v2"}[w.intn(2)])
878 revs[host].Add(1) // the scheduler's newer desired build supersedes in-flight attempts
879 case 10:
880 w.crashOne(host)
881 sweep()
882 case 11:
883 // A stop-retry interval passes: the sweep's re-stop cadences
884 // (pending stops, unproven members) come due, and nothing goes
885 // idle.
886 clk.advance(actStopTimeout + time.Second)
887 sweep()
888 }
889 w.jitter()
890 }
891 reqs.Wait()
892 close(stopSweeper)
893 <-sweeperDone
894 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
895 a.Shutdown(ctx)
896 cancel()
897 w.mu.Lock()
898 w.shutdown = true
899 w.mu.Unlock()
900 w.logf("Shutdown returned")
901 // "Nothing runs after Shutdown returns" is the generations-on Shutdown
902 // (spec §7.4: it cancels and joins waking entries, mutant M6's line).
903 // With the switch off, today's rule holds by design (global constraints:
904 // Shutdown and wake() byte for byte as today): a wake already in flight
905 // resolves on its own and the lease TTL bounds it. Flag-off seeds check
906 // every lease release and acquire, not this clause.
907 if n := w.runningCount(); n > 0 && w.gensOn {
908 w.violatef("I2: %d generation process(es) still run after Shutdown returned", n)
909 }
910 done := make(chan struct{})
911 go func() { swaps.Wait(); close(done) }()
912 select {
913 case <-done:
914 case <-time.After(10 * time.Second):
915 w.violatef("liveness: a Swap did not return after Shutdown")
916 }
917 if n := w.runningCount(); n > 0 && w.gensOn {
918 w.violatef("I2: %d generation process(es) run once every Swap has returned after Shutdown", n)
919 }
920 close(stopCheck)
921 <-checked
922 return w.snapshot()
923}
924
925// The model's death is one transition (final review F2): a reaper that
926// sees a process Exited() and releases its lease never finds the same
927// process still listed as running, so the sweep cannot report a false I2.
928// The dying hook runs at the one point a reaper could act during a death;
929// a second, concurrent die of the same process returns there too.
930func TestSimDeathIsOneTransition(t *testing.T) {
931 for _, concurrent := range []bool{false, true} {
932 w := &simWorld{rng: rand.New(rand.NewSource(1)), running: map[string]map[*simProc]bool{},
933 live: map[string]string{}, staged: map[string]string{}, seen: map[string]bool{}, outcomes: map[string]int{},
934 killFails: map[string]int{}}
935 p := w.spawn("h.example.com", "v1", 1, true)
936 fired := false
937 w.dying = func(q *simProc) {
938 if fired {
939 return
940 }
941 fired = true
942 if concurrent {
943 q.die("concurrent")
944 }
945 // A reaper: the process reads as exited, so it gives the lease back.
946 if q.Exited() {
947 w.leaseFreed(q.host, "reaper")
948 }
949 }
950 p.die("first")
951 if !fired {
952 t.Fatalf("concurrent %v: the dying hook never ran", concurrent)
953 }
954 if v, ev, _ := w.snapshot(); len(v) != 0 {
955 t.Fatalf("concurrent %v: the model reported %v\n%s", concurrent, v, strings.Join(ev, "\n"))
956 }
957 if n := w.runningOn("h.example.com"); n != 0 {
958 t.Fatalf("concurrent %v: %d process(es) still listed after the death", concurrent, n)
959 }
960 }
961}
962