sim: hostlife signs staging at the check; C3a and admission-triple rules; expected outcomes
- sign-at-check (model 18): a staging request signed at its SafeUntil check, so S3's signature-age limit bounds any later pause, even a whole-VM freeze; SafeUntil leaves room for the signature's life. Without it seed 77725 of 100,000 leaves staging residue - location-per-incarnation (claim C3a) as a rule, and an oracle that no restored or started database holds another incarnation's writes - a worst-moment stale open stays paused until its moment, so the admission triple (exec-admission, hold-op-open, start-check-claim-gen) is reachable: 4 of 30,000; every pair of it 0 - expected-outcomes.txt for mutate.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+76
−18
internal/sim/hostlife/background_test.go+1 −0internal/sim/hostlife/expected-outcomes.txt+20 −1internal/sim/hostlife/moves_test.go+34 −14internal/sim/hostlife/protocol_test.go+1 −0internal/sim/hostlife/rules_test.go+2 −0internal/sim/hostlife/sim_test.go+14 −1internal/sim/hostlife/world_test.go+4 −2
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go| index d8c7c0fe1..e3c28c213 100644 |
| --- a/internal/sim/hostlife/background_test.go |
| +++ b/internal/sim/hostlife/background_test.go |
| @@ -20,6 +20,7 @@ func (w *world) traffic(b *box) { |
| // acknowledged to the client or the outside world either way: a |
| // write into a purged or foreign database is a lost write |
| w.acks = append(w.acks, ack{W: wr, Inc: p.Inc, Box: b.ID, At: w.now, Kind: "write"}) |
| + w.writeInc[wr] = p.Inc |
| if p.Main && w.rng.Intn(4) == 0 && b.Row != nil && b.Row.Inc == p.Inc { |
| w.nextW++ |
| b.Timers = append(b.Timers, w.nextW) |
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt| index 1cab9ebd4..0b01f17df 100644 |
| --- a/internal/sim/hostlife/expected-outcomes.txt |
| +++ b/internal/sim/hostlife/expected-outcomes.txt |
| @@ -1 +1,20 @@ |
| -# rule outcome reason (see mutate.sh) |
| +# 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 |
| +# 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 |
| +declare-lost-compute missed caught with watchdog-independent (100000 seeds) |
| +watchdog-independent missed caught with declare-lost-compute (100000 seeds) |
| +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 |
| +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 |
| +location-per-incarnation missed unexercised: guards completions later than the model's delete-to-recreate window |
| +door-by-version missed unexercised: subsumed by C3a, and not caught with it dropped either |
| +door-reread missed unexercised: subsumed by C3a, and not caught with it dropped either |
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index e23df1460..48e305ea5 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -390,7 +390,13 @@ func (w *world) holdRow(b *box, status, holdOp string, c *claim) { |
| // openRow is the conditional open (moves §5.2). |
| func (w *world) openRow(b *box, holdOp string) bool { |
| if w.rng.Intn(6) == 0 { |
| - b.deferred = append(b.deferred, &localWrite{kind: "open", holdOp: holdOp, epoch: b.Epoch, at: w.now + 1 + w.pauseLen(), worst: w.rng.Intn(2) == 0}) |
| + d := &localWrite{kind: "open", holdOp: holdOp, epoch: b.Epoch, at: w.now + 1 + w.pauseLen()} |
| + if w.rng.Intn(2) == 0 { |
| + // the adversary keeps this one paused until its worst moment, |
| + // or a long stall's end |
| + d.worst, d.at = true, w.now+4000 |
| + } |
| + b.deferred = append(b.deferred, d) |
| } |
| return w.openNow(b, holdOp) |
| } |
| @@ -449,12 +455,13 @@ func (w *world) maybeStallDuplicateFor(b *box, region int, r *request) { |
| } |
| type stalledSign struct { |
| - box int |
| - epoch int |
| - req *request |
| - signAt int |
| - until int |
| - checked bool // passed its SafeUntil check, not yet signed |
| + box int |
| + epoch int |
| + req *request |
| + signAt int |
| + until int |
| + checked bool // passed its SafeUntil check and signed, not yet sent |
| + checkedAt int |
| } |
| // runStalled lets paused goroutines resume: deferred registry writes and |
| @@ -471,16 +478,28 @@ func (w *world) runStalled() { |
| continue |
| } |
| if s.checked { |
| - w.issue(b.ID, s.req) // signs, at most maxPause after the check |
| + // the request is sent now, after a pause that a whole-VM |
| + // freeze can stretch without bound. Signed at the check, its |
| + // timestamp is the check's, and S3 refuses it once older than |
| + // the signature-age limit (model 18); signed at send time, it |
| + // is accepted however late |
| + if on("sign-at-check") && w.now > s.checkedAt+sigAge { |
| + w.stats["stale signature refused"]++ |
| + continue |
| + } |
| + w.issue(b.ID, s.req) |
| continue |
| } |
| - if on("sign-before-safeuntil") && w.localNow(b) > s.until-maxSkew-maxPause { |
| + margin := maxSkew + maxPause |
| + if on("sign-at-check") { |
| + margin = maxSkew + sigAge // SafeUntil leaves room for the signature's whole valid life |
| + } |
| + if on("sign-before-safeuntil") && w.localNow(b) > s.until-margin { |
| continue // checked SafeUntil immediately before signing: too late |
| } |
| - // the check passed; the goroutine may still pause before the |
| - // signature, within the claim's §3.1 bound, which SafeUntil's |
| - // margin covers |
| - s.checked, s.signAt = true, w.now+w.rng.Intn(maxPause+1) |
| + // the check passed and the request is signed; the goroutine may |
| + // still pause before sending it |
| + s.checked, s.checkedAt, s.signAt = true, w.now, w.now+w.rng.Intn(maxPause+1) |
| keep = append(keep, s) |
| } |
| w.stalled = keep |
| @@ -497,7 +516,7 @@ func (w *world) runStalled() { |
| // operation holds the row, at its worst moment: after that |
| // operation committed and stopped re-asserting the hold |
| worst := d.worst && d.kind == "open" && b.Row != nil && b.Row.HoldOp != "" && b.Row.HoldOp != d.holdOp && |
| - w.claim != nil && w.claim.Op.Committed && w.rng.Intn(10) == 0 |
| + w.claim != nil && w.claim.Op.Committed && w.rng.Intn(3) == 0 |
| if d.at > w.now && !worst { |
| rest = append(rest, d) |
| continue |
| @@ -1082,6 +1101,7 @@ func (w *world) pushStep(b *box, c *claim) { |
| w.nextW++ |
| mark := w.nextW |
| w.acks = append(w.acks, ack{W: mark, Inc: c.Holder.Inc, Box: b.ID, At: w.now, Kind: "write"}) |
| + w.writeInc[mark] = c.Holder.Inc |
| writes = append(writes, mark) |
| w.cas(c, func(n *claim) bool { |
| if !sameOp(n.Op.ID, op.ID) || n.Op.Phase != "stopping" { |
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go| index 675a9dc5e..620a24b5c 100644 |
| --- a/internal/sim/hostlife/protocol_test.go |
| +++ b/internal/sim/hostlife/protocol_test.go |
| @@ -293,6 +293,7 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| } |
| } |
| // what the app sees |
| + w.foreignWrites(b.DB, h.Inc, fmt.Sprintf("box %d start", b.ID)) |
| if b.DB.Inc != 0 && b.DB.Inc != h.Inc { |
| w.violate("box %d started inc %d over a database of inc %d", b.ID, h.Inc, b.DB.Inc) |
| } |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index 58b8fd7c5..07baf07d6 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -28,6 +28,8 @@ var allRules = []string{ |
| "claim-cas-opid", // C2: every CAS conditional on op.id |
| "revalidate-before-effect", // C3: a worker re-reads under its lease before acting |
| "sign-before-safeuntil", // C3/§3.1: a paused round signs only before its lease's SafeUntil |
| + "sign-at-check", // model 18: signed at the check, so S3's age limit bounds any later pause |
| + "location-per-incarnation", // claim rev 4 C3a (J2-R2-1): no location shared between incarnations |
| "fresh-admission", // C4: admission reads the claim fresh |
| "exec-admission", // C4/K1: op.exec names this box (and the role column, which is op.exec here) |
| "recovery-pass", // C2/§8: console claim-recovery pass |
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go| index 406b0116a..0d46d5ef3 100644 |
| --- a/internal/sim/hostlife/sim_test.go |
| +++ b/internal/sim/hostlife/sim_test.go |
| @@ -17,7 +17,7 @@ const ( |
| func newWorld(seed int64, verbose bool) *world { |
| w := &world{seed: seed, rng: rand.New(rand.NewSource(seed)), verbose: verbose, |
| 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{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| + control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, 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() |
| } |
| @@ -60,6 +60,7 @@ func (w *world) legacyStart() { |
| w.nextW++ |
| writes = append(writes, w.nextW) |
| w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: 0, 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} |
| @@ -444,6 +445,7 @@ func (w *world) checkDurable(c *claim, when string) { |
| for _, x := range db.Writes { |
| have[x] = true |
| } |
| + w.foreignWrites(db, c.Holder.Inc, "cold restore, "+when) |
| } |
| for _, a := range w.acks { |
| if a.Inc != c.Holder.Inc || a.Exempt || a.Kind != "write" || !need[a.W] { |
| @@ -456,6 +458,17 @@ func (w *world) checkDurable(c *claim, when string) { |
| } |
| } |
| +// foreignWrites: a database restored or started for incarnation inc holds |
| +// no write another incarnation acknowledged (C1, INV-4, claim C3a). |
| +func (w *world) foreignWrites(db *localDB, inc int, how string) { |
| + for _, x := range db.Writes { |
| + if i, ok := w.writeInc[x]; ok && i != inc { |
| + w.violate("C3a (%s): incarnation %d's database holds write %d of incarnation %d", how, inc, x, i) |
| + return |
| + } |
| + } |
| +} |
| + |
| func (w *world) checkAcks(c *claim, e *box, writes []int, how string) { |
| have := map[int]bool{} |
| for _, x := range writes { |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index e61d079b8..082999935 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -50,7 +50,8 @@ const ( |
| probeTries = 3 |
| readyWait = 800 // abort a move whose readiness never comes |
| maxSkew = 1 // a box clock is within this of true time |
| - maxPause = 3 // the claim's §3.1 bound on a pause between a check and the act |
| + maxPause = 3 // a typical pause between a check and the act (a VM freeze is longer) |
| + sigAge = 30 // S3's signature-age limit; H covers it plus the longest request |
| beyondH = 20 // one long-tail request in this many completes after H (R2-1) |
| ) |
| @@ -152,7 +153,7 @@ func (b *bucket) hasData(prefix string, skipLock bool) bool { |
| // locationFor is holder.location for an incarnation. |
| func locationFor(inc int, legacy bool) string { |
| - if legacy { |
| + if legacy || !on("location-per-incarnation") { |
| return "instances/" + host + "/" |
| } |
| return fmt.Sprintf("instances/%s/%d/", host, inc) |
| @@ -509,6 +510,7 @@ type world struct { |
| boxes []*box |
| pending []*request |
| acks []ack |
| + writeInc map[int]int // which incarnation acknowledged each write |
| durable map[string]map[int]bool // per generation prefix: writes a completed replica object holds |
| genParent map[string]string // generation prefix -> the one it superseded |
| nextW int |