carlos / platform Public

sim: hostlife: deterministic witness scenarios beside the random sweep (rev 7.3)

Every new biased schedule reshuffles every seed, and a rare rule's catch
vanishes with it (lost-return-park in rev 7.1, generation-conditional in
rev 7.2). witness_test.go adds one fixed, hand-scheduled scenario per
rule the sweep catches rarely or only in combination: each forces the
interleaving the rule exists for, under a quiet RNG that injects
nothing, holds with the rules in place, and violates an invariant under
the rule's mutation, alone or in the stated combination. Witnesses never
draw from a seed's RNG; the 300-seed gate of the committed rev 7.2 model
and of this one are identical.

22 witnesses for 20 rules. Alone: generation-conditional,
lost-return-park, declare-lost-compute, lost-quarantine, markers-last,
rollback-journal, seal-by-version, staging-quiet, dirty-return. Held
alone and violated in their combination (redundant alone):
watchdog-independent, claim-cas-opid, commit-before-open, the admission
triple and start-check, strict-spawn, touched-incomplete, door-reread,
door-by-version (with C3a alone; door-reread is not needed),
registration-check (with either the fence or the ETag condition gone).

Model hooks for the witnesses, none drawn from the seed RNG: the RNG is
an interface, issue() honours a witness's hold, moveTo and resumeStale
are callable directly. mutate.sh runs TestWitnesses beside the sweep and
reports both in one verdict; a rule counts as caught if either does.
expected-outcomes.txt drops declare-lost-compute and
generation-conditional, now caught by their witnesses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c493a988db68d576964e74d9b93c45ae8888ac36 parent 0c6058b
6 files changed, +856 −34
  • internal/sim/hostlife/console_test.go +13 −1
  • internal/sim/hostlife/expected-outcomes.txt +5 −6
  • internal/sim/hostlife/moves_test.go +24 −5
  • internal/sim/hostlife/mutate.sh +58 −18
  • internal/sim/hostlife/witness_test.go +738 −0
  • internal/sim/hostlife/world_test.go +18 −4
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go
index d3f19f994..d50aca6c7 100644
--- a/internal/sim/hostlife/console_test.go
+++ b/internal/sim/hostlife/console_test.go
@@ -344,6 +344,19 @@ func (w *world) userDelete() {
}
func (w *world) userMove() {
+ c := w.claim
+ if c == nil || c.Holder == nil || c.Op.Kind != "serving" {
+ return
+ }
+ if src := w.execBox(c); src == nil || src.Destroyed {
+ return
+ }
+ w.moveTo(w.rng.Intn(nRegions)) // drawn only when a move is possible, as before rev 7.3
+}
+
+// moveTo requests a move of the serving host to region r. A witness calls
+// it directly (witness_test.go).
+func (w *world) moveTo(r int) {
c := w.claim
if c == nil || c.Holder == nil || c.Op.Kind != "serving" {
return
@@ -352,7 +365,6 @@ func (w *world) userMove() {
if src == nil || src.Destroyed {
return
}
- r := w.rng.Intn(nRegions)
if r == src.Region {
return
}
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt
index 9c053c58c..2c97a5e1c 100644
--- a/internal/sim/hostlife/expected-outcomes.txt
+++ b/internal/sim/hostlife/expected-outcomes.txt
@@ -1,14 +1,14 @@
# rule outcome reason. Read by mutate.sh: a rule listed `missed` is not
-# expected to be caught alone, and is reported EXPECTED-MISS; the sweep
-# fails if it is caught (the expectation is stale). Spec §14.2.5 gives the
+# expected to be caught alone, by the random sweep or by its witness
+# (witness_test.go), and is reported EXPECTED-MISS; the sweep fails if it
+# is caught (the expectation is stale). Spec §14.2.5 gives the
# combination that catches each, or why none does.
claim-cas-opid missed caught with revalidate-before-effect
commit-before-open missed caught with dirty-return
config-before-probe missed liveness only: a target that cannot render config fails its probe and rolls back
readiness-key missed liveness only: T2 avoids a pointless cut
readiness-quiet missed unreachable under claim rev 4: acknowledgements wait out their own horizon and CC11 holds
-watchdog-independent missed caught with declare-lost-compute (3 / 100,000 on rev 7.2)
-declare-lost-compute missed caught with watchdog-independent (3 / 100,000 on rev 7.2); alone 0 / 100,000 on rev 7.2 (1 / 100,000 on rev 7.1, 0 on rev 7)
+watchdog-independent missed caught with declare-lost-compute (witness; sweep 3 / 100,000 on rev 7.2); alone, compute fencing makes it redundant
exec-admission missed one of three admission fences; caught with hold-op-open and start-check-claim-gen
hold-op-open missed one of three admission fences; caught with exec-admission and start-check-claim-gen
start-check-claim-gen missed one of three admission fences; caught with exec-admission and hold-op-open
@@ -16,6 +16,5 @@ start-check missed caught with exec-admission and hold-op-open
strict-spawn missed caught with evidence-whole-tree
touched-incomplete missed caught with legacy-discovery
door-reread missed subsumed by C3a: caught with location-per-incarnation
-door-by-version missed subsumed by C3a: caught with door-reread and location-per-incarnation
+door-by-version missed subsumed by C3a: caught with location-per-incarnation (witness), and with door-reread as well (sweep)
registration-check missed a cheap extra: subsumed by purge-claim-fence and fence-etag together
-generation-conditional missed unexercised on rev 7.2: 0 / 300,000 (2 / 100,000 on rev 7.1, before the mid-return schedule reshuffled every seed)
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go
index f79615731..11f1cfb90 100644
--- a/internal/sim/hostlife/moves_test.go
+++ b/internal/sim/hostlife/moves_test.go
@@ -38,15 +38,34 @@ func (w *world) workerMode(b *box, obl bool) {
}
if b.stale != nil && w.rng.Intn(6) == 0 {
// a stalled worker of this box resumes with the claim it read long ago
- old := b.stale.clone()
+ old := b.stale
b.stale = nil
- if on("revalidate-before-effect") && !sameStep(old, c) {
- return // it re-reads under its lease, sees the world moved, and stops
- }
- c = old
+ w.resumeStale(b, old)
+ return
} else if w.rng.Intn(10) == 0 {
b.stale = c.clone()
}
+ w.role(b, c)
+}
+
+// resumeStale is a stalled worker goroutine of b resuming with a claim it
+// read long ago (rev 4's actor). With claim C3's revalidation it re-reads
+// under its lease, sees the world moved, and stops; the mutation acts on
+// the old read. A witness calls this directly (witness_test.go).
+func (w *world) resumeStale(b *box, old *claim) {
+ c, ok := w.readClaim(b)
+ if !ok {
+ return
+ }
+ if on("revalidate-before-effect") && !sameStep(old, c) {
+ return
+ }
+ w.role(b, old.clone())
+}
+
+// role is the worker's exec-lease renewal, quarantine and operation role,
+// acting on the claim c it read.
+func (w *world) role(b *box, c *claim) {
// exec lease (claim §7.1)
if w.isParticipant(c, b) && !contains(c.Lost, b.ID) {
if b.ExecUntil == 0 || b.ExecUntil-w.localNow(b) < execRenew {
diff --git a/internal/sim/hostlife/mutate.sh b/internal/sim/hostlife/mutate.sh
index a15c58db0..eb64cd513 100755
--- a/internal/sim/hostlife/mutate.sh
+++ b/internal/sim/hostlife/mutate.sh
@@ -10,21 +10,32 @@
# internal/sim/hostlife/mutate.sh 30000 rule-a rule-b # named rules
# internal/sim/hostlife/mutate.sh 30000 rule-a,rule-b # a combination, dropped together
#
-# Classification (joint round 2, R2-10):
+# Each rule gets two runs under its mutation (rev 7.3): the random sweep
+# (TestHostLifecycle over the seeds) and the rule's deterministic witness
+# scenarios (TestWitnesses, witness_test.go). The verdict line reports
+# both, separately:
#
-# CAUGHT the run completed and reported at least one seed that
+# CAUGHT rule: sweep N of S (first seed n); witness violated|held|none
+#
+# Classification (joint round 2, R2-10; rev 7.3):
+#
+# CAUGHT the sweep completed and reported at least one seed that
# violated an invariant, with the seed number parsed from its
-# output (so it can be replayed with HOSTLIFE_SEED);
-# MISSED the run completed and every seed passed;
+# output (so it can be replayed with HOSTLIFE_SEED), OR a
+# witness of the rule violated an invariant under the mutation;
+# MISSED the sweep completed with every seed passing, and every
+# witness held (or the rule has none);
# ERROR anything else: an unknown rule, a stale binary, a panic, a
-# timeout, or output the harness cannot parse. An error is
-# never a detection.
+# timeout, output the harness cannot parse, or a witness run
+# that failed (a witness that must violate under this mutation
+# and held: the witness is broken). An error is never a
+# detection.
#
# Expected outcomes live in expected-outcomes.txt beside this script: a
# rule listed there as `missed` (liveness-only, unreachable, or caught
-# only in a stated combination) is reported as EXPECTED-MISS. The sweep
-# exits nonzero on any ERROR, on any unexpected MISSED, and on any rule
-# listed as `missed` that is CAUGHT (STALE).
+# only in a stated combination: its witness holds alone) is reported as
+# EXPECTED-MISS. The sweep exits nonzero on any ERROR, on any unexpected
+# MISSED, and on any rule listed as `missed` that is CAUGHT (STALE).
#
# Scratch (the binary, every run's log, the verdicts) lives under
# ${HOSTLIFE_SCRATCH:-$TMPDIR/hostlife-sim}; remove it once, after the
@@ -62,26 +73,55 @@ one() {
return
fi
done
- HOSTLIFE_MUTATE=$r HOSTLIFE_SEEDS=$SEEDS "$BIN" -test.run TestHostLifecycle -test.count=1 -test.v -test.timeout 180m >"$log" 2>&1
+ # the random sweep
+ HOSTLIFE_MUTATE=$r HOSTLIFE_SEEDS=$SEEDS "$BIN" -test.run '^TestHostLifecycle$' -test.count=1 -test.v -test.timeout 180m >"$log" 2>&1
rc=$?
expect=$(awk -v r="$r" '$1==r {print $2}' "$PKG/expected-outcomes.txt" 2>/dev/null)
nviol=$(grep -o "[0-9]* of $SEEDS seeds violated an invariant" "$log" | head -1 | awk '{print $1}')
seed=$(grep -o "first seed [0-9]*" "$log" | head -1 | awk '{print $3}')
clean=$(grep -c "^ sim_test.go:[0-9]*: $SEEDS seeds, 0 failed.*mutated=\"$r\"" "$log")
+ sweep=""
if [ $rc -ne 0 ] && [ -n "$nviol" ] && [ -n "$seed" ] && ! grep -q "^panic:\|test timed out" "$log"; then
- if [ "$expect" = "missed" ]; then
- echo "STALE $r: $nviol of $SEEDS seeds violated (first seed $seed), but expected-outcomes.txt says missed" | tee "$verdict"
- else
- echo "CAUGHT $r: $nviol of $SEEDS seeds violated (first seed $seed)" | tee "$verdict"
- fi
+ sweep="sweep $nviol of $SEEDS (first seed $seed)"
elif [ $rc -eq 0 ] && [ "$clean" -eq 1 ]; then
+ sweep="sweep 0 of $SEEDS"
+ fi
+ # the witnesses (rev 7.3): the rule's own hand-scheduled scenarios, run
+ # under the same mutation; "violated" if any of them violates an
+ # invariant, "held" if they all hold, "none" if the rule has no witness.
+ # A witness that must violate under this mutation and holds fails the
+ # test, which is an ERROR here: the witness is broken, not the rule
+ wlog="$SCR/logs/$tag.witness.log"
+ HOSTLIFE_MUTATE=$r "$BIN" -test.run '^TestWitnesses$' -test.count=1 -test.v -test.timeout 10m >"$wlog" 2>&1
+ wrc=$?
+ wit=none
+ if grep -q "^ witness_test.go:[0-9]*: witness .*: violated" "$wlog"; then
+ wit=violated
+ elif grep -q "^ witness_test.go:[0-9]*: witness .*: held" "$wlog"; then
+ wit=held
+ fi
+ if [ $wrc -ne 0 ] || grep -q "^panic:\|test timed out" "$wlog"; then
+ echo "ERROR $r: witness run rc=$wrc; see $wlog" | tee "$verdict"
+ return
+ fi
+ if [ -z "$sweep" ]; then
+ echo "ERROR $r: rc=$rc, no parsable sweep verdict; see $log" | tee "$verdict"
+ return
+ fi
+ caught=0
+ if [ "$sweep" != "sweep 0 of $SEEDS" ] || [ "$wit" = violated ]; then
+ caught=1
+ fi
+ if [ $caught -eq 1 ]; then
if [ "$expect" = "missed" ]; then
- echo "EXPECTED-MISS $r ($SEEDS seeds)" | tee "$verdict"
+ echo "STALE $r: $sweep; witness $wit; but expected-outcomes.txt says missed" | tee "$verdict"
else
- echo "MISSED $r ($SEEDS seeds)" | tee "$verdict"
+ echo "CAUGHT $r: $sweep; witness $wit" | tee "$verdict"
fi
+ elif [ "$expect" = "missed" ]; then
+ echo "EXPECTED-MISS $r: $sweep; witness $wit" | tee "$verdict"
else
- echo "ERROR $r: rc=$rc, no parsable verdict; see $log" | tee "$verdict"
+ echo "MISSED $r: $sweep; witness $wit" | tee "$verdict"
fi
}
export -f one
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go
new file mode 100644
index 000000000..842abb56b
--- /dev/null
+++ b/internal/sim/hostlife/witness_test.go
@@ -0,0 +1,738 @@
+package hostlife
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+)
+
+// Witness scenarios (moves spec §14.2, rev 7.3).
+//
+// The random sweep reaches some interleavings rarely, and every new biased
+// schedule reshuffles every seed, so a rare rule's catch can vanish from
+// one revision to the next (lost-return-park in rev 7.1,
+// generation-conditional in rev 7.2). A witness is one fixed, hand-scheduled
+// scenario for one rule: it forces exactly the interleaving the rule
+// exists for, as a sequence of protocol steps the witness chooses, with a
+// quiet RNG that injects nothing. It must hold (no invariant violated) with
+// the rules in place, and violate an invariant when the rule's mutation is
+// applied, alone or in the stated combination.
+//
+// Witnesses never draw from a seed's RNG and never run inside the sweep's
+// seeds, so adding one reshuffles nothing. They are the deterministic half
+// of the mutation evidence; mutate.sh runs TestWitnesses beside the sweep
+// and reports both.
+
+// quiet is the witness's RNG: every draw takes the branch that injects
+// nothing (no fault, no stall, no duplicate, no late completion, no
+// second process), so a scenario is exactly the steps the witness scripts.
+type quiet struct{}
+
+func (quiet) Intn(n int) int {
+ if n <= 1 {
+ return 0
+ }
+ return 1
+}
+
+type witness struct {
+ rule string // the rule it is for
+ needs string // the mutation set under which it must violate: the rule alone, or a combination the rule belongs to
+ note string // what it shows
+ run func(t *testing.T, w *world)
+}
+
+var witnesses = []witness{
+ {"generation-conditional", "generation-conditional",
+ "an earlier push's generation.json PUT completes after a later push's; unconditional, it puts the old generation back and the serving box cannot start", wGenerationConditional},
+ {"lost-return-park", "lost-return-park",
+ "a target stopped after its first return PUT is declared lost; without the park the lane stays in the source region at the release (INV-10)", wLostReturnPark},
+ {"declare-lost-compute", "declare-lost-compute",
+ "a frozen target's lease expires; declared lost on expiry alone, it resumes beside the recovered source (INV-1)", wDeclareLostCompute},
+ {"watchdog-independent", "declare-lost-compute,watchdog-independent",
+ "redundant alone: with compute fencing a paused agent cannot be declared lost; with expiry-based declare-lost, a watchdog inside the paused agent never fences, and the target runs beside the recovered source (INV-1)", wWatchdogIndependent},
+ {"lost-quarantine", "lost-quarantine",
+ "a lost box starts again with its old disk; without the quarantine it keeps a database and files for a host served elsewhere (INV-10)", wLostQuarantine},
+ {"markers-last", "markers-last",
+ "the target's agent crashes between extracting the files and writing the marker; a marker written first vouches for timers that were never installed (INV-2)", wMarkersLast},
+ {"rollback-journal", "rollback-journal",
+ "the source's agent restarts during a dirty rollback's probe; without the journal it restarts at `claimed`, parks the return generation it had replicated into, and a durable write is lost on cold restore (INV-2)", wRollbackJournal},
+ {"seal-by-version", "seal-by-version",
+ "a pre-cut staging round completes after the seal; a landing that copies the current version installs the stale round and loses the writes made after it (INV-2)", wSealByVersion},
+ {"staging-quiet", "staging-quiet",
+ "a staging round completes after the landing's staging park acknowledged; retired at the seal rather than after the writers' horizon, the round is residue at the release (INV-10)", wStagingQuiet},
+ {"claim-cas-opid", "claim-cas-opid,revalidate-before-effect",
+ "redundant alone: a stalled source goroutine with the rollback's last read resumes after a purge claimed the host; revalidation stops it, and so does the op-id condition on its release CAS; with both gone the release overwrites the purge and the store is torn down under a serving host", wClaimCasOpID},
+ {"dirty-return", "dirty-return",
+ "a target that wrote during its probe is rolled back; without the return its writes are lost (INV-2)", wDirtyReturn},
+ {"commit-before-open", "commit-before-open,dirty-return",
+ "redundant alone in this model: a row opened before the commit admits nothing while the claim is uncommitted; the only violation the scenario can show is dirty-return's", wDirtyReturn},
+ {"exec-admission", "exec-admission,hold-op-open,start-check-claim-gen",
+ "one of three admission fences (J2-R2-8): a paused open from an earlier operation makes the old source ready after the commit; hold_op refuses the open, the exec check refuses the wake, the generation check refuses the start; with all three gone the source serves beside the target (INV-1)", wAdmissionTriple},
+ {"hold-op-open", "exec-admission,hold-op-open,start-check-claim-gen", "one of three admission fences (see exec-admission)", wAdmissionTriple},
+ {"start-check-claim-gen", "exec-admission,hold-op-open,start-check-claim-gen", "one of three admission fences (see exec-admission)", wAdmissionTriple},
+ {"start-check", "exec-admission,hold-op-open,start-check", "the whole start check in place of its generation clause (see exec-admission)", wAdmissionTriple},
+ {"strict-spawn", "strict-spawn,evidence-whole-tree",
+ "redundant alone: an escaped descendant is killed by the whole-tree cleanup; with evidence blind to escapees, a non-strict descendant survives the cut and writes beside the target (INV-1)", wStrictSpawn},
+ {"touched-incomplete", "touched-incomplete,legacy-discovery",
+ "redundant alone: discovery at adoption parks the other regions; without discovery, an incomplete touched set must park every region at delete, or a pre-release replica is residue after free (INV-10)", wTouchedIncomplete},
+ {"door-reread", "door-reread,location-per-incarnation",
+ "subsumed by C3a: a listing authorized for the old incarnation selects, after a recreate into the shared location, the new incarnation's object; O2 refuses it", wDoorReread},
+ {"door-by-version", "door-by-version,location-per-incarnation",
+ "subsumed by C3a: a handle signed before the delete opens, after a recreate into the shared location, the key's current object unless bound to the version it selected", wDoorByVersion},
+ {"registration-check", "registration-check,fence-etag",
+ "a cheap extra: a creator paused across a whole purge resumes; the fence and the ETag condition refuse it without the re-read; with the ETag condition gone the re-read is what stops it", wRegistrationCheck},
+ {"registration-check", "registration-check,purge-claim-fence",
+ "the same, with the fence gone instead", wRegistrationCheck},
+}
+
+// TestWitnesses runs every witness. With no mutation every witness must
+// hold. Under HOSTLIFE_MUTATE=m it runs the witnesses of the rules in m
+// and reports each as violated or held; a witness whose stated mutation
+// set is within m must violate, or the witness is broken and the test
+// fails. HOSTLIFE_WITNESS=rule runs one rule's witnesses with their trace.
+func TestWitnesses(t *testing.T) {
+ set := map[string]bool{}
+ for _, m := range strings.Split(mutated, ",") {
+ if m != "" {
+ set[m] = true
+ }
+ }
+ only := os.Getenv("HOSTLIFE_WITNESS")
+ ran := 0
+ for _, x := range witnesses {
+ if only != "" && x.rule != only {
+ continue
+ }
+ if mutated != "" && !set[x.rule] {
+ continue
+ }
+ ran++
+ w := newWitness()
+ x.run(t, w)
+ violated := len(w.violations) > 0
+ needs := true
+ for _, m := range strings.Split(x.needs, ",") {
+ if !set[m] {
+ needs = false
+ }
+ }
+ tag := "held"
+ if violated {
+ tag = "violated: " + w.violations[0]
+ }
+ switch {
+ case mutated == "":
+ t.Logf("witness %s: %s", x.rule, tag)
+ if violated {
+ t.Errorf("witness %s violated with every rule in place", x.rule)
+ dump(t, w)
+ }
+ default:
+ t.Logf("witness %s under %q (needs %q): %s", x.rule, mutated, x.needs, tag)
+ if needs && !violated {
+ t.Errorf("witness %s held under %q, which it must violate", x.rule, mutated)
+ dump(t, w)
+ }
+ }
+ if only != "" {
+ dump(t, w)
+ }
+ }
+ if only != "" && ran == 0 {
+ t.Fatalf("no witness for rule %q", only)
+ }
+}
+
+func dump(t *testing.T, w *world) {
+ for _, line := range w.trace {
+ t.Log(line)
+ }
+ t.Logf("state: %s%s", w.state(), w.boxesState())
+}
+
+// ---------- the fixture ----------
+
+// newWitness is a clean world: three boxes with the secrets key, no faults
+// armed, no legacy host, the quiet RNG, and the trace kept.
+func newWitness() *world {
+ w := &world{seed: -1, rng: quiet{}, verbose: true, now: 100,
+ stores: map[string]bool{app0: true, app1: true}, cfgVer: map[string]int{app0: 1, app1: 1}, purgeRes: map[string]bool{}, purgeStep: map[string]int{},
+ control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, legacyIncs: map[int]bool{}, purgeOp: map[string]string{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}}
+ for r := 0; r < nRegions; r++ {
+ w.buckets[r] = newBucket()
+ b := &box{ID: r, Region: r, HasKey: true}
+ b.forget()
+ w.boxes = append(w.boxes, b)
+ }
+ w.admit = map[string]*admission{app0: {Reg: map[string]regEntry{}}, app1: {Reg: map[string]regEntry{}}}
+ return w
+}
+
+// serve installs incarnation inc of app on b as a serving host: claim,
+// record, row, local database and files, config rendered, and a replica
+// object with its acknowledged writes.
+func (w *world) serve(b *box, inc int, app string) {
+ loc := locationFor(inc, false)
+ key := 1000 + inc
+ var writes []int
+ for i := 0; i < 2; i++ {
+ w.nextW++
+ writes = append(writes, w.nextW)
+ w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: w.now, Kind: "write"})
+ w.writeInc[w.nextW] = inc
+ }
+ if inc > w.nextInc {
+ w.nextInc = inc
+ }
+ w.serial++
+ n := &claim{Ver: 1}
+ if w.claim != nil {
+ n = w.claim.clone()
+ n.Ver++
+ }
+ n.Holder = &holder{Inc: inc, App: app, Serial: w.serial, Location: loc}
+ n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}}
+ n.Regions, n.Boxes, n.Complete, n.Obl = []int{b.Region}, []int{b.ID}, true, nil
+ w.claim = n
+ w.rec = &record{Inc: inc, App: app, Region: b.Region}
+ b.Row = &row{Inc: inc, App: app, Status: "ready"}
+ b.DB = &localDB{Inc: inc, Loc: loc, Key: key, Writes: append([]int(nil), writes...)}
+ b.Files = &filesDir{Inc: inc, Key: key, Env: key, Done: true}
+ b.Config, b.CfgVer, b.Live, b.LiveInc = inc, w.cfgVer[app], app, inc
+ k := dataKey(loc, "", "ltx")
+ bl := &blob{Kind: "ltx", Inc: inc, Writes: append([]int(nil), writes...), SealKey: key}
+ w.buckets[b.Region].objs[k] = append(w.buckets[b.Region].objs[k], &version{ID: w.newVer(), B: bl})
+ w.markDurable(k, bl)
+ w.logf("witness: inc %d of app %s serving on box %d", inc, app, b.ID)
+}
+
+// legacy installs a pre-release host: an untagged replica in region r0
+// where it serves with no claim, and a replica an earlier move left in r1.
+func (w *world) legacy(b *box, other int) {
+ w.nextInc++
+ inc := w.nextInc
+ w.rec = &record{Inc: inc, App: app0, Region: b.Region}
+ w.legacyIncs[inc] = true
+ key := 7
+ var writes []int
+ for i := 0; i < 2; i++ {
+ w.nextW++
+ writes = append(writes, w.nextW)
+ w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: w.now, Kind: "write"})
+ w.writeInc[w.nextW] = inc
+ }
+ b.Row = &row{Inc: inc, App: app0, Status: "ready"}
+ b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes}
+ b.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true}
+ b.Config, b.CfgVer, b.Live, b.LiveInc = inc, 1, app0, inc
+ k := "instances/" + host + "/ltx"
+ w.buckets[b.Region].objs[k] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes, SealKey: key}}}
+ w.markDurable(k, &blob{Writes: writes})
+ w.buckets[other].objs[k] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes[:1], SealKey: key}}}
+}
+
+// ---------- the schedule ----------
+
+// tick is one step of the witness's own schedule: time, S3 completions,
+// paused goroutines, then the actors the witness names, then the per-step
+// invariants.
+func (w *world) tick(actors ...func()) {
+ w.now++
+ w.completeDue(false)
+ if p := w.recPend; p != nil && p.at <= w.now {
+ w.recPend = nil
+ if w.rec == nil {
+ w.rec = p.rec
+ }
+ }
+ w.runStalled()
+ for _, a := range actors {
+ a()
+ }
+ w.checkStep()
+}
+
+// drive ticks until done, a violation, or max steps.
+func (w *world) drive(max int, done func() bool, actors ...func()) bool {
+ for i := 0; i < max; i++ {
+ if done() {
+ return true
+ }
+ w.tick(actors...)
+ if len(w.violations) > 0 {
+ return false
+ }
+ }
+ return done()
+}
+
+// reach drives to a milestone the scenario needs. Not reaching it, with no
+// violation to explain why, is a liveness failure and is recorded as one
+// (INV-3), so a mutation that leaves the protocol stuck counts as caught
+// and an unmutated scenario that stalls fails the test.
+func (w *world) reach(t *testing.T, max int, what string, done func() bool, actors ...func()) bool {
+ if w.drive(max, done, actors...) {
+ w.logf("witness: reached %s", what)
+ return true
+ }
+ if len(w.violations) == 0 {
+ w.violate("INV-3 (witness): %s not reached within %d steps: %s", what, max, w.state())
+ }
+ return false
+}
+
+// finish drives to quiescence and runs the final oracle, as runSeed does.
+func (w *world) finish(t *testing.T) {
+ w.drive(2000, w.quiescent, w.actAll(), w.runConsole, w.recoveryPass, w.operator)
+ if len(w.violations) > 0 {
+ return
+ }
+ w.completeDue(true)
+ w.final()
+}
+
+func (w *world) act(bs ...*box) func() {
+ return func() {
+ for _, b := range bs {
+ w.worker(b)
+ w.obligationRunner(b)
+ w.reconcile(b)
+ }
+ }
+}
+
+// actAll runs every live box, replacements included.
+func (w *world) actAll() func() {
+ return func() {
+ for _, b := range w.liveBoxes() {
+ w.worker(b)
+ w.obligationRunner(b)
+ w.reconcile(b)
+ }
+ }
+}
+
+// runConsole runs one step of every console plan, in order: a
+// deterministic consoleStep.
+func (w *world) runConsole() {
+ var keep []*consolePlan
+ for _, p := range w.console {
+ if p.Stall > w.now || !w.runPlan(p) {
+ keep = append(keep, p)
+ }
+ }
+ w.console = keep
+}
+
+// stop is the cloud stopping a box: destroyed, nothing on it runs.
+func (w *world) stop(b *box) {
+ b.Destroyed, b.Procs = true, nil
+ w.logf("witness: box %d stopped", b.ID)
+}
+
+// appWrite is one acknowledged application write by b's main process.
+func (w *world) appWrite(b *box) int {
+ if !hasMain(b) {
+ panic("appWrite: no main process")
+ }
+ p := b.Procs[0]
+ w.nextW++
+ wr := w.nextW
+ if b.DB != nil && b.DB.Inc == p.Inc {
+ b.DB.Writes = append(b.DB.Writes, wr)
+ }
+ w.acks = append(w.acks, ack{W: wr, Inc: p.Inc, Box: b.ID, At: w.now, Kind: "write"})
+ w.writeInc[wr] = p.Inc
+ w.logf("witness: box %d wrote %d", b.ID, wr)
+ return wr
+}
+
+// holdKey makes the witness hold every request for key until completeHeld.
+func (w *world) holdKey(key string) {
+ w.hold = func(r *request) bool { return r.key == key }
+}
+
+// completeHeld completes the requests the witness held for key, now.
+func (w *world) completeHeld(key string) int {
+ n := 0
+ var keep []*request
+ for _, r := range w.pending {
+ if r.key == key && r.doneAt == 1<<30 {
+ w.complete(r)
+ w.logf("witness: held %v %s r%d completes now", r.kind, r.key, r.region)
+ n++
+ continue
+ }
+ keep = append(keep, r)
+ }
+ w.pending = keep
+ return n
+}
+
+func (w *world) oblOpen(region int, prefix string) bool {
+ if w.claim == nil {
+ return false
+ }
+ for _, o := range w.claim.Obl {
+ if o.Region == region && o.Prefix == prefix {
+ return true
+ }
+ }
+ return false
+}
+
+// landProbed drives a move of the serving host to tgt until the landing's
+// probe has passed and the instance runs on tgt, before the settle window
+// ends.
+func (w *world) landProbed(t *testing.T, src, tgt *box) {
+ w.moveTo(tgt.Region)
+ if w.claim.Op.Kind != "moving" {
+ t.Fatalf("witness: move not started: %s", w.state())
+ }
+ w.reach(t, 600, "landing probed", func() bool {
+ c := w.claim
+ return c.Op.Kind == "landing" && c.Op.LandPhase == "probed" && hasMain(tgt)
+ }, w.act(src, tgt), w.recoveryPass)
+}
+
+// ---------- the scenarios ----------
+
+// wGenerationConditional (model 29, §5.1): push p1's first generation.json
+// PUT is accepted but its response is lost; the agent publishes again and
+// p1 completes. Push p2 completes. Then the stale PUT lands. Conditional on
+// the marker it superseded, it is refused (412); unconditional, the marker
+// names p1 while the claim names p2, and the serving box cannot start.
+func wGenerationConditional(t *testing.T, w *world) {
+ e := w.boxes[0]
+ w.serve(e, 1, app0)
+ loc := w.claim.Holder.Location
+ push := func(holdMarker bool) {
+ w.userPush()
+ id := w.claim.Op.ID
+ if !w.reach(t, 400, "push "+id+" published", func() bool { return w.claim.Op.ID == id && w.claim.Op.Phase == "published" }, w.act(e)) {
+ return
+ }
+ if holdMarker {
+ w.holdKey(genMarkerKey(loc))
+ w.tick(w.act(e)) // the first publication: accepted, response lost
+ w.hold = nil
+ }
+ w.reach(t, 400, "push "+id+" released", func() bool { return w.claim.Op.Kind == "serving" }, w.act(e))
+ w.idleStop(e)
+ }
+ push(true)
+ push(false)
+ if len(w.violations) > 0 {
+ return
+ }
+ if w.completeHeld(genMarkerKey(loc)) != 1 {
+ t.Fatalf("witness: no held marker PUT")
+ }
+ w.finish(t)
+}
+
+// wLostReturnPark (model 27, §7.10).
+func wLostReturnPark(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.appWrite(tgt) // real work in the settle window
+ w.rollback(w.claim, "settle check failed")
+ w.reach(t, 50, "target released dirty", func() bool {
+ c := w.claim
+ return c.Op.Kind == "restoring" && c.Op.Phase == "released" && c.Op.Dirty
+ }, w.act(src, tgt), w.recoveryPass)
+ w.tick(w.act(tgt)) // the first return PUT: moves/<host>/<rb>/db in the source region
+ if w.claim.Op.ReturnDB.Ver == 0 {
+ t.Fatalf("witness: the return did not begin: %s", w.state())
+ }
+ w.stop(tgt) // the cloud stops the target mid-return
+ w.reach(t, 20, "declared lost", func() bool { return contains(w.claim.Lost, tgt.ID) }, w.operator)
+ w.replaceDestroyed()
+ w.finish(t)
+}
+
+// wDeclareLostCompute (model 5, §7.10): the target is frozen (a whole-VM
+// pause) in its settle window; its execution lease expires.
+func wDeclareLostCompute(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ tgt.Frozen = true
+ exp := w.execLease[tgt.ID].Expires
+ w.reach(t, 100, "lease expired", func() bool { return w.now > exp+lostMargin }, w.act(src), w.recoveryPass)
+ // the operator: refused with compute fencing (the box is not stopped);
+ // on expiry alone, the rollback and the loss go through and the source
+ // recovers
+ w.drive(100, func() bool { return hasMain(src) }, w.act(src), w.recoveryPass, w.operator)
+ tgt.Frozen = false // the VM resumes: its application runs before its agent does
+ w.tick()
+ w.finish(t)
+}
+
+// wWatchdogIndependent (§7.1): as wDeclareLostCompute, with the agent
+// alone paused, so the application keeps running and only a watchdog
+// outside the agent can fence it.
+func wWatchdogIndependent(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ tgt.AgentPaused = true
+ exp := w.execLease[tgt.ID].Expires
+ dog := func() { w.watchdog(tgt) }
+ w.reach(t, 100, "lease expired", func() bool { return w.now > exp+lostMargin }, w.act(src), w.recoveryPass, dog)
+ w.drive(100, func() bool { return hasMain(src) }, w.act(src), w.recoveryPass, w.operator, dog)
+ tgt.AgentPaused = false
+ w.finish(t)
+}
+
+// wLostQuarantine (claim §7.2, CC12): the target is stopped while landing,
+// declared lost, and later started again with its old disk.
+func wLostQuarantine(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.moveTo(tgt.Region)
+ w.reach(t, 600, "landing installed", func() bool {
+ c := w.claim
+ return c.Op.Kind == "landing" && c.Op.LandPhase == "probing"
+ }, w.act(src, tgt), w.recoveryPass)
+ w.stop(tgt)
+ w.reach(t, 60, "declared lost", func() bool { return contains(w.claim.Lost, tgt.ID) }, w.act(src), w.recoveryPass, w.operator)
+ tgt.Destroyed = false // started again, with its old disk
+ tgt.forget()
+ w.logf("witness: box %d started again", tgt.ID)
+ w.finish(t)
+}
+
+// wMarkersLast (R2-6): the source has a registered timer; the target's
+// agent crashes between extracting the files and writing the marker.
+func wMarkersLast(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.nextW++
+ src.Timers = []int{w.nextW}
+ w.acks = append(w.acks, ack{W: w.nextW, Inc: 1, Box: src.ID, At: w.now, Kind: "timer"})
+ w.moveTo(tgt.Region)
+ id := w.claim.Op.ID
+ w.reach(t, 600, "files extracted", func() bool { return tgt.Files != nil && tgt.Files.Gen == id }, w.act(src, tgt), w.recoveryPass)
+ tgt.Procs = nil
+ tgt.forget() // the agent crashes and restarts: its memory, the pending timers included, is gone
+ w.logf("witness: box %d agent crashed between the extraction and the marker", tgt.ID)
+ w.finish(t)
+}
+
+// wRollbackJournal (R2-3): a dirty rollback; the source's agent restarts
+// after its probe spawned the instance, which wrote and replicated.
+func wRollbackJournal(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.appWrite(tgt)
+ w.rollback(w.claim, "settle check failed")
+ w.reach(t, 2000, "source probed on the return", func() bool {
+ c := w.claim
+ return c.Op.Kind == "restoring" && c.Op.SrcPhase == "probed" && hasMain(src)
+ }, w.act(src, tgt), w.recoveryPass)
+ w.appWrite(src)
+ w.replicate(src) // into the return generation: durable there
+ src.Procs = nil
+ src.forget() // the agent crashes and restarts
+ w.logf("witness: box %d agent crashed during the rollback's probe", src.ID)
+ w.finish(t)
+}
+
+// heldRound drives a move to its first staging round and holds that
+// round's db PUT. It returns the staging key.
+func (w *world) heldRound(t *testing.T, src, tgt *box) string {
+ w.moveTo(tgt.Region)
+ id := w.claim.Op.ID
+ key := fmt.Sprintf("moves/%s/%s/db", host, id)
+ w.reach(t, 100, "staging begins", func() bool {
+ c := w.claim
+ return c.Op.Phase == "preparing" && contains(c.Regions, tgt.Region)
+ }, w.act(src, tgt))
+ w.holdKey(key)
+ w.tick(w.act(src)) // this round's db PUT is accepted and completes late
+ w.hold = nil
+ return key
+}
+
+// wSealByVersion (J-N2): the held round completes after the seal, so the
+// staging key's current version is older than the sealed one.
+func wSealByVersion(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ key := w.heldRound(t, src, tgt)
+ w.wake(src) // the source serves during the move
+ if !hasMain(src) {
+ t.Fatalf("witness: source did not wake: %s", w.state())
+ }
+ w.appWrite(src)
+ w.replicate(src)
+ w.reach(t, 600, "sealed", func() bool { return w.claim.Op.Phase == "sealed" || w.claim.Op.Kind == "landing" }, w.act(src, tgt), w.recoveryPass)
+ w.completeHeld(key)
+ w.finish(t)
+}
+
+// wStagingQuiet (J-N2): the held round completes after the commit, once
+// the landing's staging park has acknowledged (if it retired at the seal
+// time) and before the release.
+func wStagingQuiet(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ key := w.heldRound(t, src, tgt)
+ prefix := key[:strings.LastIndex(key, "/")+1]
+ w.reach(t, 600, "committed", func() bool { return w.claim.Op.Committed }, w.act(src, tgt), w.recoveryPass)
+ w.drive(30, func() bool { return !w.oblOpen(tgt.Region, prefix) }, w.act(src, tgt), w.recoveryPass)
+ w.completeHeld(key)
+ w.finish(t)
+}
+
+// wClaimCasOpID (claim C2): a clean rollback; a second goroutine of the
+// source read the claim just before the release and stalls; the host is
+// released, a purge claims it, and the goroutine resumes.
+func wClaimCasOpID(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.rollback(w.claim, "cut deadline")
+ w.reach(t, 1500, "source about to release", func() bool {
+ c := w.claim
+ return c.Op.Kind == "restoring" && c.Op.SrcPhase == "opened" && c.oblFor(c.Op.ID) == 0
+ }, w.act(src, tgt), w.recoveryPass)
+ old := w.claim.clone() // the stalled goroutine's read
+ w.reach(t, 50, "released", func() bool { return w.claim.Op.Kind == "serving" }, w.act(src, tgt), w.recoveryPass)
+ pg := &consolePlan{Kind: "purge", App: app0}
+ w.reach(t, 20, "purge claimed the host", func() bool { return w.claim.Op.Kind == "purging" }, func() { w.runPlan(pg) })
+ w.resumeStale(src, old) // the goroutine wakes with the rollback's last read
+ w.tick()
+ w.tick(func() { w.runPlan(pg) }) // the purge tears the store down
+ w.tick(func() { w.runPlan(pg) })
+}
+
+// wDirtyReturn (INV-2; also commit-before-open's witness): the target
+// writes during its probe and is rolled back.
+func wDirtyReturn(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.appWrite(tgt)
+ w.rollback(w.claim, "settle check failed")
+ w.finish(t)
+}
+
+// wAdmissionTriple (J2-R2-8): an open from an earlier operation paused on
+// the source; it lands after the commit, while the source's row is still
+// held and before its park-local has run.
+func wAdmissionTriple(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ d := &localWrite{kind: "open", holdOp: "p0", epoch: src.Epoch, at: 1 << 30}
+ src.deferred = append(src.deferred, d)
+ w.moveTo(tgt.Region)
+ w.reach(t, 600, "committed", func() bool { return w.claim.Op.Committed }, func() { w.worker(src); w.worker(tgt) }, w.recoveryPass)
+ d.at = w.now // the paused open lands now
+ w.tick()
+ w.wake(src) // a request reaches the old source
+ w.checkStep()
+ w.finish(t)
+}
+
+// wStrictSpawn (§6.1): the source runs a main process and a descendant
+// placed as its parent was.
+func wStrictSpawn(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.wake(src)
+ if !hasMain(src) {
+ t.Fatalf("witness: source did not wake: %s", w.state())
+ }
+ main := src.Procs[0]
+ src.Procs = append(src.Procs, &proc{Main: false, Strict: main.Strict, Inc: 1, Box: src.ID})
+ w.moveTo(tgt.Region)
+ w.finish(t)
+}
+
+// wTouchedIncomplete (claim §9, CC4): a legacy host with a pre-release
+// replica in another region is adopted, then deleted.
+func wTouchedIncomplete(t *testing.T, w *world) {
+ b0 := w.boxes[0]
+ w.legacy(b0, 1)
+ w.tick(func() { w.reconcile(b0) }) // adoption
+ if w.claim == nil {
+ t.Fatalf("witness: not adopted")
+ }
+ w.reach(t, 400, "adoption settled", func() bool { return w.claim.oblFor("adopt") == 0 }, w.actAll(), w.recoveryPass)
+ w.userDelete()
+ w.finish(t)
+}
+
+// recreate deletes and frees the serving host, creates incarnation inc in
+// region r, and has it run and replicate.
+func (w *world) recreate(t *testing.T, r, inc int) {
+ w.userDelete()
+ w.reach(t, 600, "freed", func() bool {
+ c := w.claim
+ return c != nil && c.Holder == nil && c.Op.Kind == ""
+ }, w.actAll(), w.runConsole, w.recoveryPass)
+ w.console = append(w.console, &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: inc, Region: r})
+ b := w.regionBox(r)
+ w.reach(t, 200, "recreated", func() bool {
+ c := w.claim
+ return c.Holder != nil && c.Holder.Inc == inc && c.Op.Kind == "serving" && b.Row != nil
+ }, w.runConsole, w.actAll(), w.recoveryPass)
+ b.Config, b.CfgVer, b.Live, b.LiveInc = inc, w.cfgVer[app0], app0, inc // config sync and adoption have run
+ w.reach(t, 200, "new incarnation running", func() bool { return hasMain(b) }, func() { w.wake(b) })
+ w.appWrite(b)
+ w.replicate(b)
+}
+
+// wDoorReread (claim §6.5 O2): a listing authorized before the delete
+// selects after the recreate.
+func wDoorReread(t *testing.T, w *world) {
+ b0 := w.boxes[0]
+ w.serve(b0, 1, app0)
+ w.doorAuthorize() // O1
+ w.recreate(t, 0, 2)
+ w.doorOpen() // select and O2
+ w.doorOpen() // open
+}
+
+// wDoorByVersion (claim §6.5): a listing selects and signs before the
+// delete, and the file request opens after the recreate.
+func wDoorByVersion(t *testing.T, w *world) {
+ b0 := w.boxes[0]
+ w.serve(b0, 1, app0)
+ w.doorAuthorize() // O1
+ w.tick()
+ w.tick()
+ w.doorOpen() // select, O2, sign
+ if len(w.doorReads) != 1 || !w.doorReads[0].Signed {
+ t.Fatalf("witness: listing not signed")
+ }
+ w.recreate(t, 0, 2)
+ w.doorOpen() // open
+}
+
+// wRegistrationCheck (CC21, claim §5.3): the first create of a host
+// registers and its console pauses; a purge of the app drains the expired
+// registration, tears the store down and finishes; the creator resumes.
+func wRegistrationCheck(t *testing.T, w *world) {
+ p := &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0}
+ w.runPlan(p) // registered; the claim is absent
+ for i := 0; i < 61; i++ {
+ w.tick()
+ }
+ pg := &consolePlan{Kind: "purge", App: app0}
+ done := false
+ w.reach(t, 30, "purge done", func() bool { return done }, func() {
+ if w.runPlan(pg) {
+ done = true
+ }
+ })
+ for i := 0; i < 6; i++ {
+ w.tick(func() { w.runPlan(p) }) // the creator resumes
+ }
+}
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go
index ef191a1df..9b9687a43 100644
--- a/internal/sim/hostlife/world_test.go
+++ b/internal/sim/hostlife/world_test.go
@@ -30,7 +30,6 @@ package hostlife
import (
"fmt"
- "math/rand"
"sort"
"strings"
)
@@ -495,10 +494,19 @@ type ack struct {
Exempt bool // lost under a declared loss (M16) or past the last checkpoint of a lost disk
}
+// intner is what the model draws from: the seed's *rand.Rand in the random
+// sweep, a quiet source in a witness scenario (witness_test.go), so the
+// witnesses never touch the seeds' streams.
+type intner interface{ Intn(n int) int }
+
type world struct {
- t testingT
- seed int64
- rng *rand.Rand
+ t testingT
+ seed int64
+ rng intner
+ // hold, when set, is a witness's hand on S3: a request it answers true
+ // for is accepted and completes only when the witness says so
+ // (witness_test.go). The random sweep never sets it.
+ hold func(r *request) bool
now int
ver int
verbose bool
@@ -674,6 +682,12 @@ func (w *world) issue(actorBox int, r *request) int {
w.violate("IAM: box %d may not %v %s in region %d", actorBox, r.kind, r.key, r.region)
return 0
}
+ if w.hold != nil && w.hold(r) {
+ r.doneAt, r.by = 1<<30, fmt.Sprintf("box:%d", actorBox)
+ w.pending = append(w.pending, r)
+ w.logf("box %d issued %v %s r%d (held by the witness)", actorBox, r.kind, r.key, r.region)
+ return 0
+ }
if w.rng.Intn(10) < 7 {
return w.complete(r)
}