carlos / platform Public

zdd-readiness: simulation invariants and mutants for markers, retries and intents

The simulation gains I7 (a retry is never undone by an outcome that began before it, the record and the cohort halt both) and I8 (a failure outlives an agent restart when no save lands: one seed in four keeps its book on disk with windows of failing saves), an I1 check at the plan, and late delivery of an ordinary wake's healthy event, with I5 checked at the good write, so a write decided before a hold build ran is seen to clear its marker (Astra R3 N6). mutate.sh gains M7 to M12, matches I1 to I8, and lets a mutant the simulation cannot reach name its own unit test (M12, rule 2), with a baseline of that test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 44800ab41d057508df42d078bfa98985e9b0a9e6 parent 23d42f0
6 files changed, +375 −61
  • internal/rollout/failures.go +3 −3
  • internal/rollout/queue.go +1 −1
  • internal/rollout/scheduler.go +1 −1
  • internal/rollout/sim_test.go +264 −19
  • internal/rollout/testdata/rolloutsim/mutate.sh +105 −36
  • internal/rollout/wakeplan.go +1 −1
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index d60f3d6cb..970363636 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -526,7 +526,7 @@ func clearKey(acct, app, build, host string) [4]string {
// cleared its build since, for its host or app-wide (ruling 191): its
// snapshot is not newer than the clear. The caller holds b.mu.
func (b *FailureBook) preRetryLocked(r FailureRecord) bool {
- if r.Seq == 0 {
+ if r.Seq == 0 { // rolloutsim:M9
return false
}
return r.Seq <= b.cleared[clearKey(r.Account, r.App, r.Build, r.Host)] ||
@@ -663,7 +663,7 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
if ok && cur.At.After(h.At) {
h.At = cur.At
}
- h.Seq = b.bumpLocked()
+ h.Seq = b.bumpLocked() // rolloutsim:M12
b.held[host] = h
return true
}
@@ -724,7 +724,7 @@ func (b *FailureBook) ClearHoldRanBefore(host string, snap uint64) (bool, error)
b.mu.Lock()
defer b.mu.Unlock()
cur, ok := b.held[host]
- if !ok || snap == 0 || cur.Seq >= snap {
+ if !ok || snap == 0 || cur.Seq >= snap { // rolloutsim:M8
return false, b.retryLocked()
}
delete(b.held, host)
diff --git a/internal/rollout/queue.go b/internal/rollout/queue.go
index 6c0c7e1aa..5233c6c03 100644
--- a/internal/rollout/queue.go
+++ b/internal/rollout/queue.go
@@ -519,7 +519,7 @@ type recorder interface {
// halt lives in memory beside it (an agent restart loses it).
func failCohort(book recorder, q *appQueue, rec FailureRecord) (res RecordResult, superseded []string, err error) {
res, err = book.Record(rec) // rolloutsim:M3
- if res == PreRetry {
+ if res == PreRetry { // rolloutsim:M11
return res, nil, err
}
superseded = q.halt(rec.Build, rec.Reason)
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go
index 746ccbfa3..21ed9dccb 100644
--- a/internal/rollout/scheduler.go
+++ b/internal/rollout/scheduler.go
@@ -195,7 +195,7 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
mode = m.d.Act.EffectiveMode(rt.Host, a.Mode)
}
in := Intent{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, Mode: string(mode), ID: d.ExecID, Start: d.Started}
- if err := m.d.Book.Begin(d.ID.Host, in); err != nil {
+ if err := m.d.Book.Begin(d.ID.Host, in); err != nil { // rolloutsim:M10
m.d.Logf("rollout: %s: write the intent of its %s attempt onto %s: %v; not running it", d.ID.Host, d.ID.Cause, d.ID.Build, err)
reason := "failure records could not be written"
if errors.Is(err, ErrIntentStands) {
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index ec1727d29..038f265ea 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -32,7 +32,10 @@ import (
// runs the real scheduler.execute and scheduler.done on a goroutine of its
// own, handed off so that exactly one goroutine runs at any moment: a seed is
// one deterministic interleaving, and every invariant check sees a state no
-// transition is halfway through.
+// transition is halfway through. An ordinary wake's healthy event may reach
+// the agent some steps after its generation serves (sim.late), and one seed
+// in four keeps its failure book on disk, where windows of failing saves
+// open and close (sim.fault).
//
// The invariants, checked against the model's own ground truth (what it
// promoted, what failed, what it retried), never against the book:
@@ -74,10 +77,23 @@ import (
// cleared without it is an I5 violation. (The book is read here only to
// see what the Pass cleared; the evidence is the model's.) When the
// marked build is itself the installed good build, it boots under the
-// marked-build rule, and I1 requires its wake to boot.
+// marked-build rule, and I1 requires its wake to boot. A good write
+// that clears a marker the model's restriction still stands for (one
+// set after the write's execution began) is an I5 violation at the
+// write (goodWrite).
// - I6: every execution's intent (Task 5) ends with its outcome: once the
// run has drained, an intent stands only for a first wake still in
// flight, and it is that wake's.
+// - I7: a retry is never undone by an outcome that began before it
+// (ruling 191, plan review finding 3): a host's `carlos instances
+// retry` stands in the record until that host fails the build again in
+// a later execution, and a record a re-signed pointer cleared is
+// neither written again nor its adoption cohort halted again but by a
+// failure of an execution that began after the clear.
+// - I8: a failure outlives an agent restart even when no save of the book
+// lands (Task 5, F5): at every failure, before the code records it, an
+// agent opening the book from disk holds the build for the instance,
+// and holds it down when the failure does.
//
// Seeds: ROLLOUT_SIM_SEEDS (default 200; the per-revision sweep is 3000).
// ROLLOUT_SIM_SEED=<n> replays one seed. The production code iterates some
@@ -100,11 +116,11 @@ func TestSimInvariants(t *testing.T) {
}
first, last = n, n+1
}
- opt := t.TempDir()
+ opt, books := t.TempDir(), t.TempDir()
staged := map[string]bool{}
failed := 0
for seed := first; seed < last; seed++ {
- v := runSimSeed(seed, opt, staged, simSteps)
+ v := runSimSeed(seed, opt, books, staged, simSteps)
if v == nil {
continue
}
@@ -143,9 +159,15 @@ type simFailure struct {
trace []string
}
-func runSimSeed(seed int64, opt string, staged map[string]bool, steps int) (out *simFailure) {
+func runSimSeed(seed int64, opt, books string, staged map[string]bool, steps int) (out *simFailure) {
var s *sim
defer func() {
+ if s != nil && s.dir != "" {
+ // Writable again, so the seed's book can go (and t.TempDir's
+ // own cleanup never meets a read-only directory).
+ os.Chmod(s.dir, 0o700)
+ os.RemoveAll(s.dir)
+ }
r := recover()
if r == nil {
return
@@ -163,7 +185,7 @@ func runSimSeed(seed int64, opt string, staged map[string]bool, steps int) (out
out = &simFailure{line: fmt.Sprintf("crash at seed %d step %d: %v\n%s", seed, step, r, debug.Stack()), trace: trace}
}
}()
- s = newSim(seed, opt, staged)
+ s = newSim(seed, opt, books, staged)
s.run(steps)
return nil
}
@@ -232,6 +254,14 @@ type simSwap struct {
before string
}
+// simLate is a healthy event on its way (sim.late): the event, and the
+// start of the wake's execution (its plan), which the model's good write
+// is ordered by.
+type simLate struct {
+ ev activator.GenEvent
+ start time.Time
+}
+
type simEvent struct {
host string
swap *simSwap // the attempt reached Swap and is parked
@@ -295,13 +325,34 @@ type sim struct {
// retry cleared and holds nothing down.
retryAt map[string]map[string]time.Time
clearedAt map[string]time.Time
+ // failedAt is when each build last failed in an execution that began
+ // after every retry of it (a failure the retry ordering lets stand): I7
+ // reads it against clearedAt.
+ failedAt map[string]time.Time
+
+ // The book on disk (I8): one seed in four keeps the failure book in a
+ // directory of its own, and fault makes that directory read-only for
+ // a while, so every save fails as a full or broken disk would. fr draws
+ // the fault windows, apart from r, so the seeds that keep their book in
+ // memory run exactly the schedules they ran before I8 existed.
+ dir string
+ fault bool
+ fr *rand.Rand
+
+ // late holds an ordinary wake's healthy event (GenHealthy, not a first
+ // wake) that the activator has not yet delivered, per host: its
+ // generation serves, and its good write waits for the event (Astra R3
+ // N6). Such a wake has no intent, so an adoption may run on the host
+ // meanwhile; a hold one that fails leaves a marker the late write must
+ // not clear (I5).
+ late map[string]simLate
swaps map[string]*simSwap
events chan simEvent
draining bool
}
-func newSim(seed int64, opt string, staged map[string]bool) *sim {
+func newSim(seed int64, opt, books string, staged map[string]bool) *sim {
s := &sim{r: rand.New(rand.NewSource(seed)), seed: seed, now: t0, opt: opt, staged: staged,
book: &FailureBook{}, rows: &fakeRoutes{rows: map[string]registry.Route{}},
byName: map[string]*simHost{}, channels: [2]string{"", "beta"},
@@ -309,7 +360,20 @@ func newSim(seed int64, opt string, staged map[string]bool) *sim {
lastStamp: map[string]time.Time{}, lastSigned: map[string]time.Time{}, failedEver: map[string]bool{},
exempt: map[string]map[string]bool{}, heldDown: map[string]map[string]bool{}, holdRan: map[string]string{},
holdRanAt: map[string]time.Time{}, holdRanSet: map[string]time.Time{}, retryAt: map[string]map[string]time.Time{},
- clearedAt: map[string]time.Time{}, swaps: map[string]*simSwap{}, events: make(chan simEvent)}
+ clearedAt: map[string]time.Time{}, failedAt: map[string]time.Time{}, late: map[string]simLate{}, swaps: map[string]*simSwap{},
+ events: make(chan simEvent)}
+ if seed%4 == 3 && os.Geteuid() != 0 {
+ // Root writes into a read-only directory, so it gets no faults.
+ dir, err := os.MkdirTemp(books, "book")
+ if err != nil {
+ panic(simCrash{msg: "make the book's directory: " + err.Error()})
+ }
+ s.dir = dir
+ if s.book, err = OpenFailureBook(dir); err != nil {
+ panic(simCrash{msg: "open the book: " + err.Error()})
+ }
+ s.fr = rand.New(rand.NewSource(seed*7919 + 1))
+ }
s.book.now = func() time.Time { return s.now }
s.hold = s.r.Intn(4) == 0
s.concurrency = 1 + s.r.Intn(3)
@@ -450,10 +514,12 @@ func (s *sim) failure(build, host string, stamp time.Time, heldDown bool, start
}
return
}
+ s.checkI8(build, host, heldDown)
if stamp.IsZero() {
stamp = s.now
}
s.failedEver[build] = true
+ s.failedAt[build] = s.now
if stamp.After(s.lastStamp[build]) {
s.lastStamp[build] = stamp
}
@@ -467,6 +533,87 @@ func (s *sim) failure(build, host string, stamp time.Time, heldDown bool, start
s.logf("ground truth: %s failed on %s (stamp %s, held down %v)", build, host, stamp.Sub(t0), heldDown)
}
+// diskFault opens or closes a window in which every save of the book fails
+// (I8), for a seed whose book is on disk: its directory is made read-only,
+// so WriteFileAtomic cannot create its temp file there.
+func (s *sim) diskFault() {
+ if s.dir == "" {
+ return
+ }
+ switch {
+ case !s.fault && s.fr.Intn(10) == 0:
+ s.fault = true
+ case s.fault && s.fr.Intn(4) == 0:
+ s.fault = false
+ default:
+ return
+ }
+ mode := os.FileMode(0o700)
+ if s.fault {
+ mode = 0o500
+ }
+ if err := os.Chmod(s.dir, mode); err != nil {
+ panic(simCrash{msg: "the book's directory: " + err.Error()})
+ }
+ s.logf("disk: saves of the failure book now fail: %v", s.fault)
+}
+
+// checkI8 is I8 at a failure of build on host, called before the code has
+// written it: an agent restarting now must still find it. The restart is
+// simulated by a second book opened from the disk as the agent opens it at
+// startup (OpenFailureBook, which turns intents into records). It must hold
+// build for host, and hold host down when the failure does. Only the
+// execution's intent, saved before the build ran (Task 5, F5), can carry
+// that across a window in which every save fails.
+func (s *sim) checkI8(build, host string, heldDown bool) {
+ if s.dir == "" {
+ return
+ }
+ b, err := OpenFailureBook(s.dir)
+ if err != nil {
+ panic(simCrash{msg: "reopen the book: " + err.Error()})
+ }
+ if !b.Blocks("bab", "blog", build, host, false) || (heldDown && !b.HeldDown("bab", "blog", build, host)) {
+ violate("I8", "%s failed on %s (held down %v), and an agent restarting now would not hold it there (saves failing: %v)", build, host, heldDown, s.fault)
+ }
+}
+
+// checkI7 is I7 after every step, against the model's retries: a failure of
+// an execution that began before a retry never takes the retry back.
+// - A `carlos instances retry` of host for build b (exempt) stands until
+// host fails b again in an execution that began after it (failure
+// deletes exempt) or b's record goes: the record still lists host as
+// retried.
+// - A record a re-signed pointer cleared (clearedAt) is written again, and
+// b's adoption cohort is halted again, only by a failure of an execution
+// that began after the clear (failedAt). A stale one's halt is caught
+// here before the next Pass's liftClearedHalts could hide it.
+func (s *sim) checkI7() {
+ for _, b := range sortedKeys(s.clearedAt) {
+ if !s.failedAt[b].Before(s.clearedAt[b]) {
+ continue
+ }
+ if r, ok := s.book.Lookup("bab", "blog", b); ok {
+ violate("I7", "%s's record, cleared by a re-signed pointer at %s, stands again with no failure begun after the clear: %+v",
+ b, s.clearedAt[b].Sub(t0), r)
+ }
+ s.sched.mu.Lock()
+ c := s.sched.q.cohorts[cohortKey(b, classAdopt)]
+ halted := c != nil && c.halted
+ s.sched.mu.Unlock()
+ if halted {
+ violate("I7", "%s's cohort, reopened by a re-signed pointer at %s, is halted again with no failure begun after the clear", b, s.clearedAt[b].Sub(t0))
+ }
+ }
+ for _, host := range sortedKeys(s.exempt) {
+ for _, b := range sortedKeys(s.exempt[host]) {
+ if r, ok := s.book.Lookup("bab", "blog", b); ok && !slices.Contains(r.Retried, host) {
+ violate("I7", "%s's retry of %s was taken back with no failure there begun after it: %+v", host, b, r)
+ }
+ }
+ }
+}
+
func (s *sim) stands(build string) bool {
return s.failedEver[build] && !s.lastSigned[build].After(s.lastStamp[build])
}
@@ -638,6 +785,10 @@ func (s *sim) launch(d dispatch) {
func (s *sim) run(steps int) {
for s.step = 0; s.step < steps; s.step++ {
s.now = s.now.Add(time.Duration(1+s.r.Intn(3)) * time.Second)
+ s.diskFault()
+ if hosts := sortedKeys(s.late); len(hosts) > 0 && s.r.Intn(12) == 0 {
+ s.deliverLate(hosts[s.r.Intn(len(hosts))])
+ }
switch k := s.r.Intn(26); {
case k < 2:
s.promote()
@@ -669,6 +820,7 @@ func (s *sim) run(steps int) {
s.restage()
}
s.checkI2()
+ s.checkI7()
}
s.draining = true
for _, host := range sortedKeys(s.swaps) {
@@ -677,6 +829,9 @@ func (s *sim) run(steps int) {
close(sw.release)
s.await()
}
+ for _, host := range sortedKeys(s.late) {
+ s.deliverLate(host)
+ }
s.checkIntents()
}
@@ -734,7 +889,16 @@ func (s *sim) pass() {
still[r.Build] = true
}
for _, b := range sortedKeys(recorded) {
- if !still[b] && installed[b] {
+ if still[b] {
+ continue
+ }
+ // The record is gone, cleared or pruned, and with it every host
+ // retry's exemption from it: the build's next failure writes a new
+ // record, which holds every host (spec §8.4).
+ for _, host := range sortedKeys(s.exempt) {
+ delete(s.exempt[host], b)
+ }
+ if installed[b] {
s.clearedAt[b] = s.now
s.logf("ground truth: a pass cleared %s's record on a re-signed pointer", b)
}
@@ -949,6 +1113,11 @@ func (s *sim) wakeBegin() {
if h == nil {
return
}
+ // The activator's outcome fence (awaitOutcomes): an earlier wake's
+ // events reach the agent before the host plans again.
+ if _, ok := s.late[h.name]; ok {
+ s.deliverLate(h.name)
+ }
row := s.rows.get(h.name)
h.phase = simWaking
plan := s.m.Plan(context.Background(), row)
@@ -961,12 +1130,18 @@ func (s *sim) wakeBegin() {
h.plan, h.planRoute, h.wakeStart, h.planAt, h.stamp = plan, row, s.now, s.now, s.stampFor(h, plan.Boot.Version)
h.underMark = s.holdRan[h.name] != ""
s.logf("wake %s planned: boot %s first %v fallback %v mode %s", h.name, plan.Boot.Version, plan.FirstWake, plan.Fallback != nil, plan.Mode)
+ s.checkPlanned(h, plan.Boot.Version)
}
func (s *sim) checkWakeRefused(h *simHost, installed, why string) {
if h.good == "" || s.heldDownOn(h.name, installed) {
return
}
+ if s.fault && strings.Contains(why, BookUnreadableRefusal) {
+ // A first wake whose intent could not be saved boots nothing (Task 5):
+ // its failure could not outlive a restart (I8).
+ return
+ }
if mark := s.holdRan[h.name]; mark != "" {
if installed == mark && installed == h.good {
// The marked build is the installed good build (Task 3 fix round
@@ -1004,6 +1179,17 @@ func (s *sim) checkBoot(h *simHost, build string) {
}
}
+// checkPlanned is I1 at a wake's plan, its first gate: the planner never
+// names a build whose failure on this box stands for an instance with a
+// good build (decideWake, and decideHoldRan for the marked build, Astra R3
+// N7). The plan's Check at the exec is the second gate, for records written
+// since the plan, and is not what keeps a held build out of a plan.
+func (s *sim) checkPlanned(h *simHost, build string) {
+ if h.good != "" && build != h.good && !h.served[build] && s.blockedFor(h.name, build) {
+ violate("I1", "%s (good build %s) plans a wake of %s, whose failure on this box stands", h.name, h.good, build)
+ }
+}
+
// wakeExec is the activator's checkPlan and exec (spec §7.4, §8.2): the
// plan's Check, one replan if it refuses, the verdict floor, and the build's
// outcome reported through the real OnGen.
@@ -1035,7 +1221,7 @@ func (s *sim) wakeExec() {
}
s.cancelUnrun(rt, plan)
// The replan read the row afresh (Plan replans a moved row).
- s.checkWakeRefused(h, s.rows.get(h.name).Version, "the plan went stale and the replan refused")
+ s.checkWakeRefused(h, s.rows.get(h.name).Version, "the plan went stale and the replan refused: "+next.Refuse)
return
}
// The replan replaces the plan, which checkPlan cancels: it never ran.
@@ -1043,6 +1229,7 @@ func (s *sim) wakeExec() {
plan, h.plan, h.planAt, h.stamp = next, next, s.now, s.stampFor(h, next.Boot.Version)
h.underMark = s.holdRan[h.name] != ""
s.logf("wake %s replanned: boot %s first %v", h.name, plan.Boot.Version, plan.FirstWake)
+ s.checkPlanned(h, plan.Boot.Version)
}
canFall := plan.FirstWake && plan.Fallback != nil && plan.Mode == activator.ModeOverlap
if left := h.wakeStart.Add(simHealthWindow).Sub(s.now); canFall && left < simMinVerdict {
@@ -1063,9 +1250,9 @@ func (s *sim) wakeExec() {
s.checkI5(h, rt, good.Version)
h.phase, h.gen = simAwake, s.newGen(good)
h.served[good.Version] = true
- s.goodAt(h, good.Version, h.planAt) // served: the good build is written again
s.logf("wake %s: %s untried, booted the good build %s", h.name, plan.Boot.Version, good.Version)
- s.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
+ // served: the good build is written again, when the event lands.
+ s.healthy(h, activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
return
}
boot := plan.Boot
@@ -1077,10 +1264,9 @@ func (s *sim) wakeExec() {
}
if !plan.FirstWake {
h.phase, h.gen = simAwake, s.newGen(boot)
- s.goodAt(h, boot.Version, h.planAt)
h.served[boot.Version] = true
s.logf("wake %s: booted %s", h.name, boot.Version)
- s.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
+ s.healthy(h, activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
return
}
if s.r.Intn(12) == 0 {
@@ -1150,6 +1336,59 @@ func (s *sim) cancelUnrun(rt registry.Route, plan activator.WakePlan) {
Token: plan.Token, ConfigRev: plan.ConfigRev})
}
+// healthy reports an ordinary wake's healthy event (not a first wake's),
+// which writes the good build (served). Half of them are delivered late
+// (sim.late): the activator serves the generation at once, but its event,
+// and with it the good write, reaches the agent some steps later (OnGen
+// takes a late event as a matter of course). The good write is then
+// decided by a wake whose execution began before whatever ran on the host
+// meanwhile: an adoption, admitted because the generation serves, whose
+// hold build ran and failed leaves a marker the write must not clear (Astra
+// R3 N6, ClearHoldRanBefore).
+func (s *sim) healthy(h *simHost, ev activator.GenEvent) {
+ s.late[h.name] = simLate{ev: ev, start: h.planAt}
+ if s.r.Intn(2) == 0 {
+ s.logf("wake %s: its healthy event is late", h.name)
+ return
+ }
+ s.deliverLate(h.name)
+}
+
+// deliverLate delivers host's healthy event. The good write follows only
+// while the event's generation is the one serving (Manager.acknowledged),
+// and it lifts only a restriction set before the wake's execution began
+// (goodAt).
+func (s *sim) deliverLate(host string) {
+ l, ok := s.late[host]
+ if !ok {
+ return
+ }
+ delete(s.late, host)
+ h := s.byName[host]
+ s.logf("wake %s: healthy event of %s delivered", host, l.ev.Gen.Build)
+ s.goodWrite(h, l.ev.Gen.Build, l.start, h.phase == simAwake && h.gen.N == l.ev.Gen.N, func() { s.m.OnGen(l.ev) })
+}
+
+// goodWrite is a good-build write of build on h by an execution that began
+// at start (when acked: the code writes only while the generation serves),
+// made by deliver, the real event. It is I5 at the write: a write may lift
+// only a restriction set before its execution began (goodAt), so a marker
+// that stood in the book before the event and is gone after it, while the
+// model's restriction stands, was cleared by a write decided before its
+// hold build ran (Astra R3 N6). The book is read only to see what the write
+// cleared, as pass reads it for a Pass.
+func (s *sim) goodWrite(h *simHost, build string, start time.Time, acked bool, deliver func()) {
+ _, had := s.book.HoldRanOn(h.name)
+ if acked {
+ s.goodAt(h, build, start)
+ }
+ deliver()
+ if _, still := s.book.HoldRanOn(h.name); had && !still && s.holdRan[h.name] != "" {
+ violate("I5", "%s's good write of %s, by an execution begun at %s, cleared the hold-ran restriction of %s, set at %s after it began",
+ h.name, build, start.Sub(t0), s.holdRan[h.name], s.holdRanSet[h.name].Sub(t0))
+ }
+}
+
// checkIntents is I6 once the run has drained (Task 5): every execution's
// intent ends with its outcome, so the only intents left are those of first
// wakes still in flight (planned or under observation), each the host's
@@ -1184,10 +1423,11 @@ func (s *sim) observeEnd() {
return
}
h.phase = simAwake
- s.goodAt(h, h.gen.Build, h.planAt)
h.served[h.gen.Build] = true
s.logf("wake %s: %s settled", h.name, h.gen.Build)
- s.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: rt, Gen: h.gen, Mode: plan.Mode, FirstWake: true, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
+ s.goodWrite(h, h.gen.Build, h.planAt, true, func() {
+ s.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: rt, Gen: h.gen, Mode: plan.Mode, FirstWake: true, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
+ })
}
// sleep hibernates an instance with no attempt in Swap: at once, or through a
@@ -1271,10 +1511,15 @@ func (s *sim) hostRetry() {
// The console's grant names the build and the epoch the box reports
// for it (Task 8); the sim reads the epoch just as the console would.
ok := s.m.RetryHost(h.name, b, s.book.RestrictionEpoch("bab", "blog", b, h.name))
- if s.exempt[h.name] == nil {
- s.exempt[h.name] = map[string]bool{}
+ // The retry exempts h from the record that stands now, and from no
+ // record written after that one goes (pass): with no record there is
+ // nothing to exempt it from.
+ if recorded {
+ if s.exempt[h.name] == nil {
+ s.exempt[h.name] = map[string]bool{}
+ }
+ s.exempt[h.name][b] = true
}
- s.exempt[h.name][b] = true
if clears {
if s.retryAt[h.name] == nil {
s.retryAt[h.name] = map[string]time.Time{}
diff --git a/internal/rollout/testdata/rolloutsim/mutate.sh b/internal/rollout/testdata/rolloutsim/mutate.sh
index 65b232cf7..c5379b518 100755
--- a/internal/rollout/testdata/rolloutsim/mutate.sh
+++ b/internal/rollout/testdata/rolloutsim/mutate.sh
@@ -5,6 +5,13 @@
# runs TestSimInvariants against it. A clean baseline run of the unmutated
# copy comes first: a kill means nothing if the simulation fails anyway.
#
+# A mutant whose invariant the simulation cannot reach names a unit test
+# instead (its third field "test:TestName"; zero-downtime flag readiness,
+# task 16 rule 2). Its run is that one test (-run '^TestName$'), and the
+# test gets a clean baseline run of its own on the unmutated copy too. Such
+# a mutant is the exception: each says in the list below why the simulation
+# cannot catch it.
+#
# internal/rollout/testdata/rolloutsim/mutate.sh # 200 seeds
# internal/rollout/testdata/rolloutsim/mutate.sh 300 # 300 seeds
# internal/rollout/testdata/rolloutsim/mutate.sh 200 M3 # only M3
@@ -14,21 +21,26 @@
# budget on shared boxes).
#
# Execution integrity is recorded apart from detection. A run must build,
-# start, and end in its own --- PASS or --- FAIL line for TestSimInvariants,
-# with no panic, no timeout and no "rolloutsim: crash at seed" line (a panic
-# of the simulation or of the code under test, which the test recovers and
-# reports apart from violations). A run that breaks integrity fails the
-# mutant's gate even when it also reported the expected violation; that kill
-# is still printed, as a diagnostic.
+# start, and end in its own --- PASS or --- FAIL line for the test it ran
+# (TestSimInvariants, or a unit-test mutant's own test), with no panic, no
+# timeout and no "rolloutsim: crash at seed" line (a panic of the simulation
+# or of the code under test, which the test recovers and reports apart from
+# violations). A run that breaks integrity fails the mutant's gate even when
+# it also reported the expected violation; that kill is still printed, as a
+# diagnostic.
#
# Verdicts:
# CAUGHT the run executed intact and reported a violation of the
-# mutant's own invariant ("rolloutsim: I2 violated at seed")
+# mutant's own invariant ("rolloutsim: I2 violated at seed",
+# any of I1 to I8), or, for a unit-test mutant, its own test
+# ended in --- FAIL
# BROKEN the run panicked, timed out, crashed or never ran
# MISSED the run passed, or reported only other invariants' violations
# ERROR the marker is not on exactly one line, the substitution did not
# apply, or the copy did not build
-# The baseline is PASSED or, like a mutant, BROKEN or FAILED. Exit 1 on
+# Each baseline (the simulation's, and one per unit test a selected mutant
+# names) is PASSED or, like a mutant, BROKEN or FAILED. The simulation's
+# baseline runs only when a selected mutant runs the simulation. Exit 1 on
# anything but a PASSED baseline and CAUGHT for every mutant.
#
# A mutant named on the command line that is not in the list below is an
@@ -52,15 +64,43 @@ SEEDS=${1:-200}
shift $(($# > 0 ? 1 : 0))
ONLY=" $* "
-# name|file|invariant|perl substitution applied to the marked line only. The
-# marker match is anchored (\b), so rolloutsim:M1 never matches
-# rolloutsim:M10. The substitution is the last field, so it may contain "|".
+# name|file|check|perl substitution applied to the marked line only. check
+# is the simulation invariant that must catch the mutant (I1 to I8), or
+# test:TestName for a unit-test mutant. The marker match is anchored (\b),
+# so rolloutsim:M1 never matches rolloutsim:M10. The substitution is the
+# last field, so it may contain "|".
+#
+# M7 to M12 are zero-downtime flag readiness task 16's:
+# M7 decideHoldRan's Blocks check on the marked build (Astra R3 N7): I1.
+# M8 ClearHoldRanBefore's sequence test (Astra R3 N6): I5, at a good
+# write delivered late, after a hold adoption failed on the host.
+# M9 preRetryLocked never reports a failure stale (ruling 191): I7.
+# M10 a Begin whose save failed runs the attempt anyway (F5): I8, on a
+# seed whose book is on disk, across a read-only window.
+# M11 failCohort halts the cohort on a PreRetry failure (plan review
+# finding 3): I7's cohort check.
+# M12 markLocked keeps the marker's Seq for another execution of the
+# same build (plan review finding 4). A unit test, by rule 2: the
+# simulation cannot reach it. The kill needs a good write whose
+# execution began while a marker of build X stood, then a second
+# execution of X marking the host before that write lands. A wake
+# planned under X's marker boots X as a first wake, whose intent
+# refuses every adoption of the host until it settles, so no second
+# run of X can start inside that window; only an ordinary wake of a
+# build the host served before, under another build's marker, leaves
+# it open, and that needs X installed again and adopted on a hold.
MUTANTS='M1|queue.go|I2|s/if q\.running >= q\.concurrency \{/if false \&\& q.running >= q.concurrency {/
M2|queue.go|I3|s/class := classRecover/class := classAdopt/
M3|queue.go|I1|s/res, err = book\.Record\(rec\)/res, err = Recorded, nil/
M4|queue.go|I4|s/h\.buildRev = q\.nextRev\(\)/_ = q.nextRev()/
M5|wakeplan.go|I1|s/if hasGood \&\& in\.Book\.Blocks\(/if false \&\& in.Book.Blocks(/
-M6|wakeplan.go|I1|s/plan\.Fallback = \&good/plan.Fallback = nil/'
+M6|wakeplan.go|I1|s/plan\.Fallback = \&good/plan.Fallback = nil/
+M7|wakeplan.go|I1|s/if hasGood \&\& in\.Book\.Blocks\(/if false \&\& hasGood \&\& in.Book.Blocks(/
+M8|failures.go|I5|s/cur\.Seq >= snap \{/cur.Seq > ^uint64(0) {/
+M9|failures.go|I7|s/if r\.Seq == 0 \{/if true {/
+M10|scheduler.go|I8|s/; err != nil \{/; errors.Is(err, ErrIntentStands) {/
+M11|queue.go|I7|s/if res == PreRetry \{/if false \&\& res == PreRetry {/
+M12|failures.go|test:TestAnotherExecutionOfTheMarkedBuildRenewsTheMarker|s/h\.Seq = b\.bumpLocked\(\)/if ok \&\& cur.Build == h.Build { h.Seq = cur.Seq } else { h.Seq = b.bumpLocked() }/'
# Every name selected must be a mutant in the list, checked before any work.
KNOWN=" $(printf '%s\n' "$MUTANTS" | cut -d'|' -f1 | tr '\n' ' ')"
@@ -104,14 +144,23 @@ git -C "$ROOT" archive HEAD | tar -x -C "$SCRATCH/tree" || exit 2
echo "tree: $(git -C "$ROOT" rev-parse --short HEAD)"
cp -a "$SCRATCH/tree/$P/." "$SCRATCH/orig/" || exit 2
-# run LOG: one go test call against the scratch tree, verbose so that the
-# test prints its --- PASS or --- FAIL line.
+# testOf CHECK: the test a mutant's run executes.
+testOf() {
+ case "$1" in
+ test:*) echo "${1#test:}" ;;
+ *) echo TestSimInvariants ;;
+ esac
+}
+
+# run LOG TEST: one go test call of TEST against the scratch tree, verbose
+# so that the test prints its --- PASS or --- FAIL line.
run() {
(cd "$SCRATCH/tree" && ROLLOUT_SIM_SEEDS="$SEEDS" go test -v -p 1 -count=1 -timeout 20m \
- -run '^TestSimInvariants$' ./$P/) >"$1" 2>&1
+ -run "^$2\$" ./$P/) >"$1" 2>&1
}
-# integrity LOG: empty when the run executed intact, else what broke it.
+# integrity LOG TEST: empty when the run of TEST executed intact, else what
+# broke it.
integrity() {
if grep -q '\[build failed\]\|\[setup failed\]' "$1"; then
echo BUILD
@@ -121,33 +170,47 @@ integrity() {
echo PANIC
elif grep -q 'rolloutsim: crash at seed' "$1"; then
echo CRASH
- elif ! grep -qE -- '--- (PASS|FAIL): TestSimInvariants' "$1"; then
+ elif ! grep -qE -- "--- (PASS|FAIL): $2 \(" "$1"; then
echo NOT-RUN
fi
}
# found LOG: the invariants the run reported violated, e.g. "I1 I3".
found() {
- grep -oE 'rolloutsim: I[1-4] violated at seed' "$1" | awk '{print $2}' | sort -u | tr '\n' ' ' | sed 's/ $//'
+ grep -oE 'rolloutsim: I[0-9]+ violated at seed' "$1" | awk '{print $2}' | sort -u | tr '\n' ' ' | sed 's/ $//'
}
-blog="$SCRATCH/logs/baseline.log"
-run "$blog"
-broke=$(integrity "$blog")
-if [ -n "$broke" ]; then
- echo "BROKEN baseline: $broke; see $blog"
- fail=1
-elif grep -q -- '--- FAIL: TestSimInvariants' "$blog"; then
- echo "FAILED baseline: violations of $(found "$blog") on the unmutated tree; see $blog"
- fail=1
-else
- echo "PASSED baseline: $SEEDS seeds"
-fi
+selected() {
+ [ "$ONLY" = " " ] || [[ "$ONLY" == *" $1 "* ]]
+}
+
+# The baselines: the simulation's when a selected mutant runs it, and each
+# unit test a selected mutant names, once.
+tests=$(while IFS='|' read -r name _ check _; do
+ selected "$name" && testOf "$check"
+done <<<"$MUTANTS" | awk '!seen[$0]++')
+for t in $tests; do
+ blog="$SCRATCH/logs/baseline-$t.log"
+ what="$t"
+ [ "$t" = TestSimInvariants ] && what="$SEEDS seeds"
+ run "$blog" "$t"
+ broke=$(integrity "$blog" "$t")
+ if [ -n "$broke" ]; then
+ echo "BROKEN baseline: $t: $broke; see $blog"
+ fail=1
+ elif grep -qF -- "--- FAIL: $t (" "$blog"; then
+ echo "FAILED baseline: $t failed on the unmutated tree (violations: $(found "$blog")); see $blog"
+ fail=1
+ else
+ echo "PASSED baseline: $what"
+ fi
+done
while IFS='|' read -r name file inv sub; do
- if [ "$ONLY" != " " ] && [[ "$ONLY" != *" $name "* ]]; then
+ if ! selected "$name"; then
continue
fi
+ test=$(testOf "$inv")
marker="rolloutsim:$name"
src="$SCRATCH/tree/$P/$file"
# Each marker sits on exactly one line of the package's Go sources.
@@ -166,13 +229,19 @@ while IFS='|' read -r name file inv sub; do
fi
mutated=$(grep -E "$marker\\b" "$src" | sed 's/^[[:space:]]*//')
log="$SCRATCH/logs/$name.log"
- run "$log"
+ run "$log" "$test"
cp "$SCRATCH/orig/$file" "$src"
- broke=$(integrity "$log")
- got=$(found "$log")
+ broke=$(integrity "$log" "$test")
caught=0
- [[ " $got " == *" $inv "* ]] && caught=1
- detail="expects $inv; reported: ${got:-none}; line: $mutated"
+ if [ "$test" = TestSimInvariants ]; then
+ got=$(found "$log")
+ [[ " $got " == *" $inv "* ]] && caught=1
+ detail="expects $inv; reported: ${got:-none}; line: $mutated"
+ else
+ got=passed
+ grep -qF -- "--- FAIL: $test (" "$log" && got=failed caught=1
+ detail="expects $test to fail; it $got; line: $mutated"
+ fi
if [ "$broke" = BUILD ]; then
echo "ERROR $name: the mutant did not build; $detail; see $log"
fail=1
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go
index a4eb34fc7..b516ec3f1 100644
--- a/internal/rollout/wakeplan.go
+++ b/internal/rollout/wakeplan.go
@@ -152,7 +152,7 @@ func decideHoldRan(in wakeInputs, mark HoldRan, hasGood bool) (activator.WakePla
installed := rt.Version
switch {
case installed == mark.Build:
- if hasGood && in.Book.Blocks(acct, app, installed, host, installed == rt.GoodVersion) {
+ if hasGood && in.Book.Blocks(acct, app, installed, host, installed == rt.GoodVersion) { // rolloutsim:M7
return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
}
case hasGood && installed == rt.GoodVersion: