carlos / platform Public

rollout sim: mutants M13-M16 for the crash-epoch hard limit; mutate.sh timeout scales with seeds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a713ffb4e12d18cdbf3ef7dbbab036406c6426b9 parent caf9557
2 files changed, +43 −9
  • internal/rollout/failures.go +5 −4
  • internal/rollout/testdata/rolloutsim/mutate.sh +38 −5
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index 1a5878036..8e4d72155 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -508,7 +508,7 @@ func OpenFailureBook(dataDir string) (*FailureBook, error) {
b.intents = f.Intents
b.gens = f.Gens
b.id = f.Book
- b.seq = f.highestSaved() // the reopen's sequence (rule 3)
+ b.seq = f.highestSaved() // the reopen's sequence (rule 3); rolloutsim:M15
for i := range b.recs {
// An earlier agent's per-record epochs: read for the sequence above,
// never written again, and never what a grant is checked against.
@@ -897,7 +897,8 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
if ok && (cur.Account != h.Account || cur.App != h.App) {
b.touchLocked(cur.Account, cur.App) // the replaced marker's app lost a restriction
}
- b.touchLocked(h.Account, h.App) // the marker set, renewed or replaced: its app's generation moves
+ // The marker set, renewed or replaced: its app's generation moves.
+ b.touchLocked(h.Account, h.App) // rolloutsim:M14
b.held[host] = h
return true
}
@@ -1091,7 +1092,7 @@ func (b *FailureBook) RestrictionEpoch(acct, app, build, host string) uint64 {
func (b *FailureBook) PublishedEpoch(acct, app, build, host string) uint64 {
b.mu.Lock()
defer b.mu.Unlock()
- if b.dirty { // durable before use (rule 2)
+ if b.dirty { // durable before use (rule 2); rolloutsim:M13
return 0
}
return b.restrictionEpochLocked(acct, app, build, host)
@@ -1133,7 +1134,7 @@ func (b *FailureBook) RetryHostAt(acct, app, build, host, book string, epoch uin
if err := b.retryLocked(); b.dirty {
return false, false, now, err
}
- if book == "" || book != b.idLocked() || epoch == 0 || now != epoch {
+ if book == "" || book != b.idLocked() || epoch == 0 || now != epoch { // rolloutsim:M16
return false, false, now, nil
}
if m, ok := b.held[host]; ok && m.Account == acct && m.App == app && m.Build == build {
diff --git a/internal/rollout/testdata/rolloutsim/mutate.sh b/internal/rollout/testdata/rolloutsim/mutate.sh
index c5379b518..7d037e80d 100755
--- a/internal/rollout/testdata/rolloutsim/mutate.sh
+++ b/internal/rollout/testdata/rolloutsim/mutate.sh
@@ -18,7 +18,14 @@
#
# The script runs at nice 19 (it re-executes itself under nice if it has to,
# and warns if it cannot) and one run at a time with GOMAXPROCS=2 (the CPU
-# budget on shared boxes).
+# budget on shared boxes). Each go test run's timeout grows with the seeds,
+# SEEDS * 3 / 100 + 20 minutes (3000 seeds: 110 minutes); the header prints
+# it. A fixed 20 minutes was too short for 3000 seeds (round 1).
+#
+# The simulation's invariants are I1 to I10 (TestSimInvariants' doc). I9
+# and I10 run only on the seeds whose failure book is on disk, which the
+# simulation turns off when it runs as root (root writes into a read-only
+# directory, so it cannot fail a save): as root, M13 to M15 read MISSED.
#
# Execution integrity is recorded apart from detection. A run must build,
# start, and end in its own --- PASS or --- FAIL line for the test it ran
@@ -32,7 +39,7 @@
# Verdicts:
# CAUGHT the run executed intact and reported a violation of the
# mutant's own invariant ("rolloutsim: I2 violated at seed",
-# any of I1 to I8), or, for a unit-test mutant, its own test
+# any of I1 to I10), 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
@@ -63,9 +70,11 @@ fi
SEEDS=${1:-200}
shift $(($# > 0 ? 1 : 0))
ONLY=" $* "
+# Each go test run's timeout, scaled with the seeds (3000 seeds: 110m).
+TIMEOUT_MIN=$((SEEDS * 3 / 100 + 20))
# 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
+# is the simulation invariant that must catch the mutant (I1 to I10), 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 "|".
@@ -89,6 +98,25 @@ ONLY=" $* "
# 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.
+#
+# M13 to M16 are zero-downtime flag readiness round 2 task 2's (the
+# crash-epoch hard limit), each on a seed whose book is on disk:
+# M13 PublishedEpoch ignores its dirty check: I10. A change made while
+# saves fail moves the generation in memory only; status publishes
+# it, and the last landed file holds another.
+# M14 markLocked's marker set, renewed or replaced moves no generation:
+# I9. A grant issued under X's hold-ran marker, then another hold run
+# of X on the host (a first wake under the marker, ended after its
+# exec with no verdict) renews the marker at the same generation, and
+# the grant, delivered again, clears the newer run's marker.
+# M15 the reopen's seq keeps only the file's own seq, halved: I9. The
+# reopened book hands out again generations it had published, so a
+# grant issued before the restart matches a restriction a failure
+# after it made (reused numbers; the saved generations and markers
+# that would have repaired the max are not read).
+# M16 RetryHostAt skips the book-identity comparison: I9. A box rebuild
+# opens a new book whose generations start again from one, so a
+# grant for the old book matches a restriction the new one made.
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/
@@ -100,7 +128,11 @@ 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() }/'
+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() }/
+M13|failures.go|I10|s/if b\.dirty \{/if false \&\& b.dirty {/
+M14|failures.go|I9|s/b\.touchLocked\(h\.Account, h\.App\)//
+M15|failures.go|I9|s/b\.seq = f\.highestSaved\(\)/b.seq = f.Seq \/ 2/
+M16|failures.go|I9|s/ \|\| book != b\.idLocked\(\)//'
# Every name selected must be a mutant in the list, checked before any work.
KNOWN=" $(printf '%s\n' "$MUTANTS" | cut -d'|' -f1 | tr '\n' ' ')"
@@ -142,6 +174,7 @@ mkdir -p "$SCRATCH/tree" "$SCRATCH/orig" "$SCRATCH/logs" "$SCRATCH/gowork" || ex
export GOTMPDIR=$SCRATCH/gowork
git -C "$ROOT" archive HEAD | tar -x -C "$SCRATCH/tree" || exit 2
echo "tree: $(git -C "$ROOT" rev-parse --short HEAD)"
+echo "seeds: $SEEDS; timeout per run: ${TIMEOUT_MIN}m"
cp -a "$SCRATCH/tree/$P/." "$SCRATCH/orig/" || exit 2
# testOf CHECK: the test a mutant's run executes.
@@ -155,7 +188,7 @@ testOf() {
# 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 \
+ (cd "$SCRATCH/tree" && ROLLOUT_SIM_SEEDS="$SEEDS" go test -v -p 1 -count=1 -timeout "${TIMEOUT_MIN}m" \
-run "^$2\$" ./$P/) >"$1" 2>&1
}