carlos / platform Public

moves: into-home witnesses through the claim runtime (home part 2, Task 4 fix round 1)

The production probe mode runs the claim runtime's real start order on
either box: the production Stop of this box's own predecessor writer
session, Admit, Prepare, Restore (the production cold restore, with the
production claimArtifactRetirement so RetireSuperseded re-tags a retired
installation's inventory), Reserve, Materialize, Failed on an error;
stand-ins only for the launch and its readiness. The <db>.gen stand-in is
gone.

The source recoveries (clean rollback, dirty return, pre-flip revert, and
TestRevertProbedAfterARealAdmission) go through the production recovery
adapter into that runtime on ca-box; the hand-rolled writer admission is
removed. The source is made what serving leaves first (sourceServed:
installation record, inventory, src_gen's files manifest and origin).

The clean rollback and the dirty return now fail two landing attempts;
the second's restore retires the first's installation.

Fixture identity: the recovery fixture's root stop inventory names the
controller's own box, and its stop identity carries the home binding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4c180d4a53990f6faf1e34ed00c11f4ec18cc1f3 parent 4fde5f9
4 files changed, +275 −241
  • internal/host/claimretryrecovery_test.go +7 −2
  • internal/host/moveshome_target_test.go +125 −177
  • internal/host/movesparks_test.go +126 −47
  • internal/host/movesrevert_test.go +17 −15

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m

amadan: job cancelled — a newer push superseded it
diff --git a/internal/host/claimretryrecovery_test.go b/internal/host/claimretryrecovery_test.go
index 3226254bf..e849a7250 100644
--- a/internal/host/claimretryrecovery_test.go
+++ b/internal/host/claimretryrecovery_test.go
@@ -142,6 +142,10 @@ type recoveryFixture struct {
// captures the writer. Unset, every admission's evidence passes.
homeRegion string
admissionEvidence func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error
+ // fleetHomeRegion is the fleet's signed home designation the box's
+ // actor carries; with homeRegion it is the controller's own stop
+ // identity's home binding (park.VerifiedHome). "" for none.
+ fleetHomeRegion string
}
// identity is the controller's box identity, the defaults filled in.
@@ -247,7 +251,8 @@ func (f *recoveryFixture) root(drain StopDrainer) ClaimStopCoordinator {
return StopInventory{}, err
}
h := o.Claim.Holder
- return StopInventory{Complete: true, Host: host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "7"}, nil
+ box, enrollment, _, _ := f.identity()
+ return StopInventory{Complete: true, Host: host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: box, Enrollment: enrollment, DirectoryRevision: "7"}, nil
})}
}
@@ -260,7 +265,7 @@ func (f *recoveryFixture) controller(boot, controller string, pid int, start str
revision = 7
}
box, enrollment, region, leaseRegion := f.identity()
- local := claimStopLocal{Actor: hostbox.Actor{Box: box, Enrollment: enrollment, Revision: revision, Region: region}, BootID: boot, ControllerID: controller, PID: pid, ProcessStart: start}
+ local := claimStopLocal{Actor: hostbox.Actor{Box: box, Enrollment: enrollment, Revision: revision, Region: region, HomeRegion: f.homeRegion, FleetHomeRegion: f.fleetHomeRegion}, BootID: boot, ControllerID: controller, PID: pid, ProcessStart: start}
f.peer = StopPeer{UID: 1001, PID: pid, ProcessStart: start, BootID: boot, ControllerID: controller}
producers := &ClaimProducerSet{}
w := &ClaimWakeAssembly{LeaseRegion: leaseRegion, Inputs: recoveryInputs{f}, Manager: new(cgroup.Manager)}
diff --git a/internal/host/moveshome_target_test.go b/internal/host/moveshome_target_test.go
index ee06c87f8..729bf8d99 100644
--- a/internal/host/moveshome_target_test.go
+++ b/internal/host/moveshome_target_test.go
@@ -8,21 +8,19 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
"sync"
"testing"
"time"
"github.com/carlosframework/platform/internal/activator"
- "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
- "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostops"
- "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
@@ -1409,135 +1407,6 @@ func intoHomeAbort(t *testing.T) ([]hostclaim.Claim, []string) {
return watch.since(0), leases.all()
}
-// prodRecovery is the named source's recovery wake through the production
-// recovery owners (movesRecoveryOwners, dispatched by movesProbeOwners on
-// the op's kind) over the harness's claim, records and buckets: the
-// activator's recovery probe entry as far as the claim runs on main. Each
-// wake selects the probe's inputs with the owners' Select and admits through
-// the claim's writer with the owners' AdmissionEvidence bound, as the claim
-// runtime's Admit does. The source is ca-box in ca, a named box: no home
-// alias, the fleet's designation only. The owners compare the launch
-// environment by the full local digest (localEnvDigest), the recovery's
-// same-box rule.
-type prodRecovery struct {
- h *phase1Harness
- owners *movesProbeOwners
- // observed, selected and admitted are each wake's claim before the
- // admission, its inputs and the claim after it.
- observed []hostclaim.Observed
- selected []ClaimLaunchInputs
- admitted []hostclaim.Observed
- // before, when set, runs after Select and before the admission.
- before func(o hostclaim.Observed, in ClaimLaunchInputs)
- // work, when set, is the start's selected work Materialize runs under.
- work hostwrite.Work
- seq int
-}
-
-func newProdRecovery(t *testing.T, h *phase1Harness, dataDir string, route func() registry.Route) *prodRecovery {
- t.Helper()
- key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey)
- if err != nil {
- t.Fatal(err)
- }
- r := &prodRecovery{h: h}
- actor := h.roster[h.srcBox]
- filesDir := func(host string) (string, string) {
- return filepath.Join(dataDir, host), filepath.Join(dataDir, host+".env")
- }
- recorded := func() *provision.MoveInputs {
- m := h.read(t, p1Host).Move
- if m.Rollback != nil {
- return m.Rollback.RecoveryInputs
- }
- return m.RecoveryInputs
- }
- owners := &movesProbeOwners{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Claims: h.s, Records: MovesRecordsFrom(h.s),
- ReadyPath: movesReadyPath(h.s), Data: h.vs, Bucket: h.srcBucket, Region: actor.Region, Box: actor.Box, DataDir: dataDir, FilesDir: filesDir,
- Markers: p1Markers{h.vs},
- Route: func(host string) (*registry.Route, error) { rt := route(); return &rt, nil },
- // What this box would launch: its own composition over its data
- // directory with its own region, the live release.
- Launch: func(_ context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
- composed, supplied, _, err := (&activator.ExecBackend{DataDir: dataDir, Region: h.src}).LaunchLayers(rt)
- if err != nil {
- return ClaimServingLaunch{}, moves.EnvReport{}, err
- }
- delete(composed, "CARLOS_MOVE_PROBE")
- env := make([]string, 0, len(composed))
- for k, v := range composed {
- env = append(env, k+"="+v)
- }
- sort.Strings(env)
- return ClaimServingLaunch{Executable: "/opt/carlos/live/" + p1Host, ExecutableSHA256: strings.Repeat("a", 64), Env: env, Version: "v7"}, moves.ReportLaunch(key, composed, supplied), nil
- },
- EnvKey: key,
- Admitted: MovesRecordAdmittedAttempts,
- Works: func(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error) {
- return p1Work{order: new([]string), name: "held"}, nil
- },
- Render: func(_ context.Context, _ registry.Route, _ string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
- in := recorded()
- hr := appconfig.HeldRender{BundleETags: in.BundleETags, OverlayETag: in.OverlayETag}
- if _, err := work(hr); err != nil {
- return appconfig.HeldRender{}, err
- }
- return hr, nil
- },
- Release: func(ctx context.Context, _ registry.Route, version string, work hostwrite.Work) error {
- return work.With(ctx, hostwrite.Identity{Release: version}, func() error { return nil })
- },
- NewID: func() string { r.seq++; return fmt.Sprintf("fresh%d", r.seq) },
- Now: h.clock.Now}
- owners.Files = &movesFiles{Files: p1FilesStore{vs: h.vs, bucket: h.srcBucket}, Data: h.vs, Bucket: h.srcBucket, HomeRegion: actor.FleetHomeRegion, Region: actor.Region, Box: actor.Box,
- DataDir: dataDir, EnvFile: envFileOf(filesDir), Proof: ClaimOriginProof{Claims: h.s, HomeRegion: actor.FleetHomeRegion, Region: actor.Region}}
- if !owners.valid() {
- t.Fatal("fixture: the source's recovery owners are not valid")
- }
- r.owners = owners
- return r
-}
-
-// materializeWith makes each wake materialize the admitted inputs too,
-// under work, after the restore stand-in (the activator's restore rewrites
-// <dbPath>.gen to the admitted generation): the owners' held render and
-// release, the launch environment's full local digest, the start gate, the
-// C8 establishment and the start check.
-func (r *prodRecovery) materializeWith(work hostwrite.Work) { r.work = work }
-
-// wake is one recovery probe wake: Select, then the writer's admission.
-func (r *prodRecovery) wake(ctx context.Context, rt registry.Route, p activator.StartPolicy) error {
- h := r.h
- o, err := hostclaim.Read(ctx, h.s, p1Host)
- if err != nil {
- return err
- }
- in, err := r.owners.Select(ctx, rt, o, p)
- if err != nil {
- return err
- }
- r.observed, r.selected = append(r.observed, o), append(r.selected, in)
- if r.before != nil {
- r.before(o, in)
- }
- w := hostbox.Writer{Claims: h.s, Data: h.vs, Bucket: h.srcBucket, Region: r.owners.Region, Box: r.owners.Box, AdmissionEvidence: r.owners.AdmissionEvidence}
- admitted, err := w.Admit(ctx, o, in.Admission, in.Baseline)
- if err != nil {
- return err
- }
- r.admitted = append(r.admitted, admitted)
- if r.work == nil {
- return nil
- }
- gen := admitted.Claim.Holder.Generation
- if cur, err := hostbox.ReadGenerationMarker(rt.DBPath + ".gen"); err != nil || cur != gen {
- if err := hostbox.WriteGenerationMarker(rt.DBPath+".gen", gen); err != nil {
- return err
- }
- }
- return r.owners.Materialize(ctx, admitted, in, r.work)
-}
-
// TestMoveIntoHomeCleanRollback: the home target's probe, through the
// production probe owners, is admitted on home-box and fails before any
// write, until its attempts are spent. The rollback releases clean:
@@ -1564,26 +1433,26 @@ func intoHomeCleanRollback(t *testing.T) ([]hostclaim.Claim, []string) {
leases := &stagingLeaseKeys{}
leases.wrap(&h.cfg)
cleanHomeStart(t, firstMoving(t, watch.since(0)))
- // One attempt: the probe's prepared admission keeps the flip's stamp,
- // the stamp the target's installation is registered at.
+ // Two attempts, each admitted through the production owners and the
+ // claim runtime and failing before any write. The second is a fresh
+ // generation with its own stamp: its restore retires the first's
+ // installation and re-tags its inventory (RetireSuperseded).
if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
- rec.Move.CutAttempts = 1
+ rec.Move.CutAttempts = 2
return nil
}); err != nil {
t.Fatal(err)
}
- // The probe through the production owners, admitted and failing before
- // any write.
p := h.productionProbe(t)
p.afterMaterialize = func(int) error { return errors.New("test: the probe failed") }
- for i := 0; i < 6 && h.read(t, p1Host).Move.State != provision.MoveRollingBack; i++ {
+ for i := 0; i < 8 && h.read(t, p1Host).Move.State != provision.MoveRollingBack; i++ {
h.rollbackPass(t)
}
- if len(p.admissions) == 0 {
- t.Fatal("the probe was never admitted")
+ if len(p.admissions) != 2 || p.stopped != 1 {
+ t.Fatalf("the probe's attempts: admissions %+v, predecessors stopped %d", p.admissions, p.stopped)
}
failed := p.admissions[len(p.admissions)-1]
- if failed.Box != "home-box" || failed.Region != "ie" {
+ if failed.Box != "home-box" || failed.Region != "ie" || failed.Generation == p1ID || failed.Predecessor != p1ID {
t.Fatalf("the home target's last admission: %+v", failed)
}
for i := 0; i < 4 && h.rb(t).TargetPhase != provision.RbTargetReleased; i++ {
@@ -1613,10 +1482,9 @@ func intoHomeCleanRollback(t *testing.T) ([]hostclaim.Claim, []string) {
// The source's recovery through the production recovery owners. Its
// bucket is versioned, as production's is: the harness stored the
// source's replica directly, so each object gets its version here.
- sourceMarker(t, h.phase1Harness)
srcGen := c.Op.Journal[hostclaim.JournalSrcGen]
- rec := newProdRecovery(t, h.phase1Harness, h.sdir, func() registry.Route { return h.srcRoute })
- rec.before = func(o hostclaim.Observed, in ClaimLaunchInputs) {
+ src := sourceRecovery(t, h)
+ src.beforeAdmit = func(o hostclaim.Observed, in ClaimLaunchInputs) {
// Stage one: the clean ReturnToSource put the holder back on
// src_gen, and the owners restore from src_gen.
if o.Claim.Holder.Generation != srcGen || srcGen != "cur" || in.Baseline == nil || in.Baseline.Generation != srcGen || in.Prepared {
@@ -1626,18 +1494,21 @@ func intoHomeCleanRollback(t *testing.T) ([]hostclaim.Claim, []string) {
t.Fatalf("the selected admission: %+v", a)
}
}
- h.recoveryWake = rec.wake
h.srcPass(t)
- if len(rec.admitted) != 1 {
- t.Fatalf("the recovery admitted %d times", len(rec.admitted))
+ if src.wakes != 1 || src.materialized.Claim == nil {
+ t.Fatalf("the recovery's starts: %d, materialized %v", src.wakes, src.materialized.Claim != nil)
}
- // Stage two: the holder is on the fresh generation; the admission is
- // clean-return, in ca, after the home target's last admission in ie.
- ah := rec.admitted[0].Claim.Holder
+ // Stage two: the holder is on the fresh generation the runtime admitted
+ // and restored; the admission is clean-return, in ca, after the home
+ // target's last admission in ie.
+ ah := src.admitted.Claim.Holder
last := ah.Admissions[len(ah.Admissions)-1]
- if ah.Generation != rec.selected[0].Admission.NewGeneration || ah.Generation == srcGen || ah.WriterBox != "ca-box" {
+ if ah.Generation != src.materializedIn.Admission.NewGeneration || ah.Generation == srcGen || ah.WriterBox != "ca-box" {
t.Fatalf("the holder after the admission: %+v", ah)
}
+ if gen, err := hostbox.ReadGenerationMarker(h.srcRoute.DBPath + ".gen"); err != nil || gen != ah.Generation {
+ t.Fatalf("the restore's generation marker %q %v, want %s", gen, err, ah.Generation)
+ }
if last.Coverage != "clean-return" || last.WritesLost != nil || len(last.AdditionalWritesLost) != 0 || last.Region != "ca" || last.Box != "ca-box" {
t.Fatalf("the clean return's admission: %+v", last)
}
@@ -1724,12 +1595,14 @@ func intoHomeDirtyReturn(t *testing.T) ([]hostclaim.Claim, []string) {
leases.wrap(&h.cfg)
cleanHomeStart(t, firstMoving(t, watch.since(0)))
if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
- rec.Move.CutAttempts = 1
+ rec.Move.CutAttempts = 2
return nil
}); err != nil {
t.Fatal(err)
}
- // The probe is admitted, the app writes, and the probe fails.
+ // Each attempt is admitted through the production owners and the claim
+ // runtime, the app writes, and the attempt fails. The second's restore
+ // retires the first's installation (RetireSuperseded).
p := h.productionProbe(t)
p.afterMaterialize = func(int) error {
h.targetChanged(t)
@@ -1746,8 +1619,8 @@ func intoHomeDirtyReturn(t *testing.T) ([]hostclaim.Claim, []string) {
for i := 0; i < 6 && h.read(t, p1Host).Move.State != provision.MoveRollingBack; i++ {
h.rollbackPass(t)
}
- if len(p.admissions) != 1 || p.admissions[0].Box != "home-box" || p.admissions[0].Region != "ie" {
- t.Fatalf("the home target's admission: %+v", p.admissions)
+ if len(p.admissions) != 2 || p.stopped != 1 || p.admissions[1].Box != "home-box" || p.admissions[1].Region != "ie" || p.admissions[1].Predecessor != p1ID {
+ t.Fatalf("the home target's admissions: %+v (predecessors stopped %d)", p.admissions, p.stopped)
}
before := len(leases.all())
for i := 0; i < 6 && h.rb(t).TargetPhase != provision.RbTargetReturned; i++ {
@@ -1778,18 +1651,17 @@ func intoHomeDirtyReturn(t *testing.T) ([]hostclaim.Claim, []string) {
t.Fatalf("the return lane's staging leases: %v", got)
}
- // The source's recovery through the production recovery owners, the
- // real C8 provider establishing from the return's prepared manifest.
- sourceMarker(t, h.phase1Harness)
- rec := newProdRecovery(t, h.phase1Harness, h.sdir, func() registry.Route { return h.srcRoute })
- rec.materializeWith(p1Work{order: new([]string), name: "spawn"})
+ // The source's recovery through the claim runtime on ca-box: the
+ // production recovery owners, the real C8 provider establishing from the
+ // return's prepared manifest.
+ rec := sourceRecovery(t, h)
// Under CARLOS_HOST_CLAIM (the probe mode sets it) the source's
// configured step publishes the live release through the held release;
// the stand-in answers as that publication would.
h.releaseLive = func(context.Context, release.Store, provision.Record, string) moves.Check {
return moves.Check{OK: true}
}
- rec.before = func(o hostclaim.Observed, in ClaimLaunchInputs) {
+ rec.beforeAdmit = func(o hostclaim.Observed, in ClaimLaunchInputs) {
residueQueued := false
for _, ob := range o.Claim.Obligations {
if ob.Kind == "park-generation" && ob.Region == "ca" && ob.Owner == "region:ca" {
@@ -1803,10 +1675,9 @@ func intoHomeDirtyReturn(t *testing.T) ([]hostclaim.Claim, []string) {
t.Fatalf("the dirty return's selected admission: %+v prepared %v", a, in.Prepared)
}
}
- h.recoveryWake = rec.wake
h.srcPass(t)
- if len(rec.admitted) != 1 {
- t.Fatalf("the recovery admitted %d times", len(rec.admitted))
+ if rec.wakes != 1 || rec.materialized.Claim == nil || rec.materializedIn.Admission.NewGeneration != rb.ID {
+ t.Fatalf("the recovery's starts: %d, materialized %+v", rec.wakes, rec.materializedIn.Admission)
}
got := h.opened(t)
if got.ID != rb.ID {
@@ -1866,7 +1737,9 @@ func intoHomePreFlipRevert(t *testing.T) ([]hostclaim.Claim, []string) {
t.Helper()
ctx := context.Background()
var watch *claimWatch
- h := newRevertHarness(t, intoHome, realLaunchEnv, watchClaims(&watch))
+ // The cut harness under a landing harness: its source replica is a real
+ // one, which the recovery's cold restore restores.
+ h := newRevertHarnessOn(t, newLandingHarness(t, intoHome, realLaunchEnv, watchClaims(&watch)).cutHarness)
leases := &stagingLeaseKeys{}
leases.wrap(&h.cfg)
h.fromStopped(t)
@@ -1889,27 +1762,20 @@ func intoHomePreFlipRevert(t *testing.T) ([]hostclaim.Claim, []string) {
// The source's revert probe through the production recovery owners.
h.arm(t)
- sourceMarker(t, h.phase1Harness)
- rec := newProdRecovery(t, h.phase1Harness, h.cfg.DataDir, func() registry.Route { return h.route })
srcGen := c.Holder.Generation
- rec.before = func(o hostclaim.Observed, in ClaimLaunchInputs) {
+ rec := revertRecovery(t, h)
+ rec.beforeAdmit = func(o hostclaim.Observed, in ClaimLaunchInputs) {
if a := in.Admission; a.Box != "ca-box" || a.Region != "ca" || a.PredecessorRegion != "ca" || a.Coverage != "" || a.Mode != "cold" || a.ObservedGeneration != srcGen ||
in.Baseline == nil || in.Baseline.Generation != srcGen {
t.Fatalf("the revert's selected admission: %+v baseline %+v", a, in.Baseline)
}
}
- h.cfg.recoveryProbe = func(ctx context.Context, s release.Store, r provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) {
- h.probes = append(h.probes, req)
- w := h.w()
- a := recoveryProbeAdapter{Box: w.cfg.Box, Claims: true, Route: w.cfg.Route, Current: w.recoveryInputs, Wake: rec.wake}
- return a.probe(ctx, s, r, req)
- }
h.pass(t)
h.reopened(t)
- if len(rec.admitted) != 1 {
- t.Fatalf("the revert probe admitted %d times", len(rec.admitted))
+ if rec.wakes != 1 || rec.materialized.Claim == nil {
+ t.Fatalf("the revert probe's starts: %d, materialized %v", rec.wakes, rec.materialized.Claim != nil)
}
- ah := rec.admitted[0].Claim.Holder
+ ah := rec.admitted.Claim.Holder
last := ah.Admissions[len(ah.Admissions)-1]
if ah.Generation == srcGen || ah.WriterBox != "ca-box" || last.Region != "ca" || last.PredecessorRegion != "ca" || last.Predecessor != srcGen || last.Coverage != "" {
t.Fatalf("the revert's admission: holder %+v last %+v", ah, last)
@@ -1953,6 +1819,88 @@ func intoHomePreFlipRevert(t *testing.T) ([]hostclaim.Claim, []string) {
return watch.since(0), leases.all()
}
+// sourceRecovery binds the recover harness's source box (ca-box) to the
+// production mode: the recovery adapter's wake runs the claim runtime on
+// ca-box (newProdWake's start: Admit, Prepare, Restore, Reserve and
+// Materialize through the production recovery owners and the real C8).
+// The source is first made what a served source leaves (sourceServed).
+func sourceRecovery(t *testing.T, h *recoverHarness) *prodProbe {
+ t.Helper()
+ sourceServed(t, h.phase1Harness, h.sdir)
+ p := newProdWake(t, h.phase1Harness, prodSide{box: h.srcBox, code: h.src, dataDir: h.sdir, bucket: h.srcBucket, aws: h.srcAWS,
+ route: func() registry.Route { return h.srcRoute },
+ inputs: func() *provision.MoveInputs { return h.read(t, p1Host).Move.Rollback.RecoveryInputs }})
+ h.recoveryWake = p.recoveryWake
+ return p
+}
+
+// revertRecovery binds a revert harness's source box (ca-box) to the
+// production mode, as sourceRecovery does for a rollback: the revert's
+// recovery adapter wakes the claim runtime on ca-box.
+func revertRecovery(t *testing.T, h *revertHarness) *prodProbe {
+ t.Helper()
+ sourceServed(t, h.phase1Harness, h.cfg.DataDir)
+ p := newProdWake(t, h.phase1Harness, prodSide{box: h.srcBox, code: h.src, dataDir: h.cfg.DataDir, bucket: h.srcBucket, aws: h.srcAWS,
+ route: func() registry.Route { return h.route },
+ inputs: func() *provision.MoveInputs { return h.read(t, p1Host).Move.RecoveryInputs }})
+ h.cfg.recoveryProbe = func(ctx context.Context, s release.Store, r provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) {
+ h.probes = append(h.probes, req)
+ w := h.w()
+ a := recoveryProbeAdapter{Box: w.cfg.Box, Claims: true, Route: w.cfg.Route, Current: w.recoveryInputs, Wake: p.recoveryWake}
+ return a.probe(ctx, s, r, req)
+ }
+ return p
+}
+
+// sourceServed makes the source box and its bucket what serving the
+// instance under claims leaves (the harness built them by hand): the
+// bucket versioned with a generation.json naming src_gen (sourceMarker);
+// the source's installation in the claim's inventory at the flip's source
+// stamp; src_gen's files manifest in its bucket, built from the source's
+// files and env; and src_gen's origin recorded, as an admission through the
+// real CAS (or the #250 backfill) records it.
+func sourceServed(t *testing.T, h *phase1Harness, dataDir string) {
+ t.Helper()
+ ctx := context.Background()
+ sourceMarker(t, h)
+ c := h.claim(t)
+ var err error
+ // The source's installation: the flip's journalled stamp and src_gen,
+ // or, for a revert whose holder never left the source, the holder's.
+ stamp, gen := c.Holder.Stamp, c.Holder.Generation
+ if raw, ok := c.Op.Journal[hostclaim.JournalSourceStamp]; ok {
+ if stamp, err = strconv.ParseUint(raw, 10, 64); err != nil || stamp == 0 {
+ t.Fatalf("fixture: the claim's source stamp %q: %v", raw, err)
+ }
+ gen = c.Op.Journal[hostclaim.JournalSrcGen]
+ }
+ // The box's guard directories and its installation record at that
+ // stamp, unless a step of the recovery already wrote it.
+ for _, d := range []string{"hostguard", ".parked"} {
+ if err := os.MkdirAll(filepath.Join(dataDir, d), 0o770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ record := filepath.Join(dataDir, "hostguard", p1Host+".json")
+ if _, err := os.Stat(record); errors.Is(err, os.ErrNotExist) {
+ body := fmt.Sprintf(`{"v":1,"host":%q,"incarnation":%q,"active_stamp":%d,"retired_stamps":[]}`, p1Host, h.inc, stamp)
+ if err := os.WriteFile(record, []byte(body), 0o660); err != nil {
+ t.Fatal(err)
+ }
+ }
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
+ if c.Inventory == nil {
+ c.Inventory = map[string]hostclaim.InventoryEntry{}
+ }
+ c.Inventory[h.srcBox] = hostclaim.InventoryEntry{Layout: "flat", Paths: map[string]uint64{p1Host + ".db": stamp, p1Host + ".db.gen": stamp, p1Host: stamp, p1Host + ".env": stamp}}
+ })
+ if _, err = buildPreparedManifest(ctx, h.vs, h.srcBucket, cutRoot, gen, p1ID, stamp, filepath.Join(dataDir, p1Host), filepath.Join(dataDir, p1Host+".env"), true,
+ hostfiles.WakeState{V: 1, Host: p1Host, Timers: []hostfiles.WakeTimer{}}); err != nil {
+ t.Fatalf("src_gen's files manifest: %v", err)
+ }
+ recordOrigins(t, h.s, p1Host, hostclaim.OriginTuple{Incarnation: h.inc, Location: cutRoot, Region: park.PlacementRegion(h.src), Generation: gen})
+}
+
// firstMoving is the first claim written with the move's op: the start
// CAS's.
func firstMoving(t *testing.T, writes []hostclaim.Claim) *hostclaim.Claim {
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go
index 3dcab785f..2af37cdd0 100644
--- a/internal/host/movesparks_test.go
+++ b/internal/host/movesparks_test.go
@@ -529,10 +529,13 @@ func (p1FilesStore) Delete(context.Context, string) error {
return errors.New("test: not implemented")
}
-// prodProbe is the production probe mode's state.
+// prodProbe is the production mode's state on one box: the claim runtime
+// (claim Plan 9 Group D's ClaimWakeAssembly, the recovery fixture's
+// controller) with the moves owners bound, on the box the side names.
type prodProbe struct {
t *testing.T
- h *landingHarness
+ h *phase1Harness
+ side prodSide
ctl *recoveryController
owners *movesProbeOwners
// order is the held works, render and release, in the order they ran.
@@ -551,32 +554,74 @@ type prodProbe struct {
selected ClaimLaunchInputs
attempt hostclaim.WakeRetryAttempt
// admissions are the admissions the runtime made, one per attempt, and
- // admitted the claim each left, the last attempt's last.
+ // admitted the claim each left, the last attempt's last; observed is
+ // the claim each start read before its admission.
admissions []hostclaim.AdmissionRecord
admitted hostclaim.Observed
+ observed []hostclaim.Observed
+ // stopped counts the predecessor writer sessions this box's own wakes
+ // stopped through the production Stop.
+ stopped int
// materialized is the claim and the launch inputs the runtime last
// materialized: the admitted inputs, which for a fresh generation are
// not the selected ones (each Select mints its own generation).
materialized hostclaim.Observed
materializedIn ClaimLaunchInputs
// afterMaterialize, when set, runs in the n-th wake (from 1) after
- // Materialize and before readiness, while the attempt is live; an error
- // ends the wake there, as an agent that died there leaves it (home part
- // 2, Task 3).
+ // Materialize and before the launch, while the attempt is live; an error
+ // fails the attempt there (the runtime's Failed), as an attempt that
+ // failed before readiness, or an agent that died there, leaves it (home
+ // part 2, Tasks 3 and 4).
afterMaterialize func(n int) error
- wakes int
- last activator.ProbeResult
- live bool
- seq int
+ // beforeAdmit, when set, sees each start's claim and the owners'
+ // selection before the runtime's admission.
+ beforeAdmit func(o hostclaim.Observed, in ClaimLaunchInputs)
+ wakes int
+ last activator.ProbeResult
+ live bool
+ seq int
+}
+
+// prodSide is the box a production mode runs on: its roster box, its
+// placement code (its composer's region), its data directory, data bucket
+// and the bucket's AWS region, its registry row, and the inputs its held
+// render renders (the move's pins for a landing, the recorded recovery
+// inputs for a recovery).
+type prodSide struct {
+ box, code, dataDir, bucket, aws string
+ route func() registry.Route
+ inputs func() *provision.MoveInputs
}
// productionProbe switches the harness's probe to the production mode: the
// target's probe wakes through movesProbeOwners over the claim runtime, with
// the target box's roster identity.
func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
+ t.Helper()
+ p := newProdWake(t, h.phase1Harness, prodSide{box: h.dstBox, code: h.dst, dataDir: h.tdir, bucket: h.dstBucket, aws: h.dstAWS,
+ route: func() registry.Route { return h.route },
+ inputs: func() *provision.MoveInputs { return h.read(t, p1Host).Move.Inputs }})
+ h.probe, h.probeLive = p.wake, p.liveProbe
+ return p
+}
+
+// newProdWake builds the production mode on side's box. The owners are the
+// production movesProbeOwners (which dispatch a recovery op to their
+// recovery owners) with the real C8 provider, bound into the claim runtime
+// with the owners' admission evidence, the moves attempt journal and the
+// production artifact retirement (claimArtifactRetirement over park.Local,
+// as claimconstruction.go wires RetireArtifacts and RecoverArtifacts).
+// What stands in is what main cannot run here: the launch (every claim
+// start is refused on main until the isolated root launcher lands), its
+// readiness, the held works and render (the claim's move read access over
+// this box's registry), root's stop transport (the recovery fixture's
+// coordinator), the cross-box cold handover (AfterAdmission) and the
+// activator's live-probe entry. A Materialize that passes is the attempt
+// passing.
+func newProdWake(t *testing.T, h *phase1Harness, side prodSide) *prodProbe {
t.Helper()
if !h.realEnv {
- // The probe's launch is the target's own composition; a source that
+ // The probe's launch is the box's own composition; a source that
// pinned the h.env fixture would never match it.
t.Fatal("fixture: the production probe mode needs realLaunchEnv")
}
@@ -584,34 +629,36 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
// under both switches; the real C8's proof reads that record.
t.Setenv("CARLOS_HOST_CLAIM", "1")
t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1")
- actor := h.roster[h.dstBox]
+ actor := h.roster[side.box]
journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { journal.Close() })
- p := &prodProbe{t: t, h: h}
+ p := &prodProbe{t: t, h: h, side: side}
+ guard := hostguard.Guard{DataDir: side.dataDir, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: h.s}}
f := &recoveryFixture{t: t, claims: h.s, evidence: release.NewDirStore(t.TempDir()), signer: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)),
- guard: hostguard.Guard{DataDir: h.tdir, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: h.s}},
- journal: journal, kernel: &recoveryKernel{journal: journal}, now: h.clock.Now(), recordPrelaunch: true,
- data: h.vs, bucket: h.dstBucket, box: actor.Box, enrollment: actor.Enrollment, region: actor.Region, leaseRegion: h.dstAWS}
+ guard: guard, journal: journal, kernel: &recoveryKernel{journal: journal}, now: h.clock.Now(), recordPrelaunch: true,
+ data: h.vs, bucket: side.bucket, box: actor.Box, enrollment: actor.Enrollment, region: actor.Region, leaseRegion: side.aws}
key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey)
if err != nil {
t.Fatal(err)
}
- filesDir := h.lcfg.FilesDir
+ filesDir := func(host string) (string, string) {
+ return filepath.Join(side.dataDir, host), filepath.Join(side.dataDir, host+".env")
+ }
owners := &movesProbeOwners{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Claims: h.s, Records: MovesRecordsFrom(h.s),
- ReadyPath: movesReadyPath(h.s), Data: h.vs, Bucket: h.dstBucket, Region: actor.Region, Box: actor.Box, DataDir: h.tdir, FilesDir: filesDir,
+ ReadyPath: movesReadyPath(h.s), Data: h.vs, Bucket: side.bucket, Region: actor.Region, Box: actor.Box, DataDir: side.dataDir, FilesDir: filesDir,
Markers: p1Markers{h.vs},
- Route: func(host string) (*registry.Route, error) { rt := h.route; return &rt, nil },
+ Route: func(host string) (*registry.Route, error) { rt := side.route(); return &rt, nil },
// The held row's launch: what this box would launch, composed by
- // the production composer over the target's data directory with
- // the target's own region, as claimMoveLaunchSource composes it
+ // the production composer over its data directory with its own
+ // region, as claimMoveLaunchSource composes it
// (ExecBackend.LaunchLayers, CARLOS_MOVE_PROBE dropped), the live
// release, and the env report made from that same composition, whose
// digest is the launch's move-portable pin.
Launch: func(_ context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
- composed, supplied, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchLayers(rt)
+ composed, supplied, _, err := (&activator.ExecBackend{DataDir: side.dataDir, Region: side.code}).LaunchLayers(rt)
if err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
}
@@ -631,7 +678,7 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
},
Render: func(ctx context.Context, _ registry.Route, _ string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
p.order = append(p.order, "render")
- in := h.read(t, p1Host).Move.Inputs
+ in := side.inputs()
hr := appconfig.HeldRender{BundleETags: in.BundleETags, OverlayETag: in.OverlayETag}
if _, err := work(hr); err != nil {
return appconfig.HeldRender{}, err
@@ -644,21 +691,21 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
},
NewID: func() string { p.seq++; return fmt.Sprintf("fresh%d", p.seq) },
Now: h.clock.Now}
- // The C8 provider is the real one over the target's bucket, as the
- // claim assembly binds it, with Plan 15's proof over the claim store.
- owners.Files = &movesFiles{Files: p1FilesStore{vs: h.vs, bucket: h.dstBucket}, Data: h.vs, Bucket: h.dstBucket, HomeRegion: actor.FleetHomeRegion, Region: actor.Region, Box: actor.Box,
- DataDir: h.tdir, EnvFile: envFileOf(filesDir), Proof: ClaimOriginProof{Claims: h.s, HomeRegion: actor.FleetHomeRegion, Region: actor.Region}}
+ // The C8 provider is the real one over the box's bucket, as the claim
+ // assembly binds it, with Plan 15's proof over the claim store.
+ owners.Files = &movesFiles{Files: p1FilesStore{vs: h.vs, bucket: side.bucket}, Data: h.vs, Bucket: side.bucket, HomeRegion: actor.FleetHomeRegion, Region: actor.Region, Box: actor.Box,
+ DataDir: side.dataDir, EnvFile: envFileOf(filesDir), Proof: ClaimOriginProof{Claims: h.s, HomeRegion: actor.FleetHomeRegion, Region: actor.Region}}
if !owners.valid() {
t.Fatal("fixture: the production probe owners are not valid")
}
- attempts := &movesProbeAttempts{Claims: h.s, Records: MovesRecordsFrom(h.s), DataDir: h.tdir, NewID: owners.NewID, Now: h.clock.Now, Admitted: MovesRecordAdmittedAttempts}
+ attempts := &movesProbeAttempts{Claims: h.s, Records: MovesRecordsFrom(h.s), DataDir: side.dataDir, NewID: owners.NewID, Now: h.clock.Now, Admitted: MovesRecordAdmittedAttempts}
bound := owners.MovesOwners(attempts)
dispatch := ClaimOwnerDispatch{Moves: &bound.ClaimKindOwners}
// The writer's admission evidence is the owner dispatch's, as the claim
// assembly binds it (claimassembly.go), bound before the runtime is
// built: Build captures the writer, so a later swap would leave the
// controller's allow-all evidence in force.
- f.homeRegion = actor.HomeRegion
+ f.homeRegion, f.fleetHomeRegion = actor.HomeRegion, actor.FleetHomeRegion
f.admissionEvidence = func(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, plan *genselect.Plan) error {
p.evidence++
if p.rejectEvidence != nil {
@@ -672,8 +719,13 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
c.wake.Continue = hostclaim.SessionContinues
c.wake.Operations = bound.Operations
c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }
+ // The production artifact retirement: a restore over a database of
+ // another generation retires that installation through park.Local's
+ // RetireSuperseded, which re-tags its inventory paths with the admitted
+ // installation's stamp.
+ retirement := claimArtifactRetirement{Local: park.Local{Guard: guard, Claims: h.s, DataDir: side.dataDir, Box: actor.Box}, Continue: hostclaim.SessionContinues}
+ c.wake.RetireArtifacts, c.wake.RecoverArtifacts = retirement.Retire, retirement.Recover
p.ctl, p.owners = c, owners
- h.probe, h.probeLive = p.wake, p.liveProbe
return p
}
@@ -692,14 +744,43 @@ func (r p1RecordingInputs) Materialize(ctx context.Context, o hostclaim.Observed
// wake is one probe attempt through the claim runtime: the activator's
// ProbeWake with its start policy, as far as the claim runs on main.
func (p *prodProbe) wake(ctx context.Context, cfg activator.ProbeConfig) (activator.ProbeResult, error) {
+ policy := activator.StartPolicy{MaxStarts: 5, Window: time.Duration(moves.ProbeSeconds * float64(time.Second)), Probe: true,
+ Deadline: cfg.Deadline, Prepared: cfg.Prepared, Inputs: cfg.Inputs}
+ return p.start(ctx, p.side.route(), policy)
+}
+
+// recoveryWake is the activator's recovery probe entry
+// (ClaimRecoveryProbeWake) on this box: one claim wake under the
+// adapter's policy.
+func (p *prodProbe) recoveryWake(ctx context.Context, rt registry.Route, policy activator.StartPolicy) error {
+ _, err := p.start(ctx, rt, policy)
+ return err
+}
+
+// start is one claim wake's first start, in the activator's order
+// (claimwake.go): Prerequisites' stop of this box's own predecessor writer
+// session (the production Stop, through the fixture's root coordinator),
+// Admit, Prepare, Restore (the production cold restore of the selected
+// baseline, retiring a superseded installation), then the attempt's
+// Reserve and Materialize; a failure from then on is the attempt's
+// (Failed). The launch, its readiness and Healthy stand in: a Materialize
+// that passes is the attempt passing.
+func (p *prodProbe) start(ctx context.Context, rt registry.Route, policy activator.StartPolicy) (activator.ProbeResult, error) {
p.live = false
h := p.h
o, err := hostclaim.Read(ctx, h.s, p1Host)
if err != nil {
return activator.ProbeResult{}, err
}
- policy := activator.StartPolicy{MaxStarts: 5, Window: time.Duration(moves.ProbeSeconds * float64(time.Second)), Probe: true,
- Deadline: cfg.Deadline, Prepared: cfg.Prepared, Inputs: cfg.Inputs}
+ if prior := o.Claim.Holder; prior != nil && prior.WriterBox == p.side.box && prior.Session != 0 {
+ if err := p.ctl.wake.Stop(ctx, o); err != nil {
+ return activator.ProbeResult{}, fmt.Errorf("stop the predecessor session %d: %w", prior.Session, err)
+ }
+ p.stopped++
+ if o, err = hostclaim.Read(ctx, h.s, p1Host); err != nil {
+ return activator.ProbeResult{}, err
+ }
+ }
live, cancel := context.WithCancel(context.Background())
p.t.Cleanup(cancel)
done := make(chan struct{})
@@ -707,10 +788,13 @@ func (p *prodProbe) wake(ctx context.Context, cfg activator.ProbeConfig) (activa
p.ctl.wake.mu.Lock()
p.ctl.wake.pending[p1Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done}
p.ctl.wake.mu.Unlock()
- rt := h.route
+ p.observed = append(p.observed, o)
if p.selected, err = p.owners.Select(ctx, rt, o, policy); err != nil {
return activator.ProbeResult{}, err
}
+ if p.beforeAdmit != nil {
+ p.beforeAdmit(o, p.selected)
+ }
admitted, err := p.ctl.runtime.Admit(ctx, rt, o, policy)
if err != nil {
return activator.ProbeResult{}, err
@@ -723,30 +807,25 @@ func (p *prodProbe) wake(ctx context.Context, cfg activator.ProbeConfig) (activa
if err != nil {
return activator.ProbeResult{}, err
}
- if s.OpenProbe != nil {
- if _, err := s.OpenProbe(ctx, h.clock.Now()); err != nil {
- return activator.ProbeResult{}, err
- }
+ if err := s.Restore(ctx); err != nil {
+ return activator.ProbeResult{}, fmt.Errorf("restore: %w", err)
}
if err := s.Reserve(ctx, 0); err != nil {
return activator.ProbeResult{}, err
}
p.attempt = s.CurrentAttempt()
- // The activator's cold restore from the selected baseline stands in
- // here: it rewrites <dbPath>.gen to the admitted generation. The
- // landing's install already wrote the move id, so only a restarted
- // attempt's fresh generation changes it (home part 2, Task 3).
- if gen, err := hostbox.ReadGenerationMarker(rt.DBPath + ".gen"); err != nil || gen != ah.Generation {
- if err := hostbox.WriteGenerationMarker(rt.DBPath+".gen", ah.Generation); err != nil {
+ if err := s.Materialize(ctx); err != nil {
+ return activator.ProbeResult{}, errors.Join(err, s.Failed(ctx, 0))
+ }
+ if s.OpenProbe != nil {
+ // At the exec (the start's beforeStart hook).
+ if _, err := s.OpenProbe(ctx, h.clock.Now()); err != nil {
return activator.ProbeResult{}, err
}
}
- if err := s.Materialize(ctx); err != nil {
- return activator.ProbeResult{}, err
- }
if p.afterMaterialize != nil {
if err := p.afterMaterialize(p.wakes); err != nil {
- return activator.ProbeResult{}, err
+ return activator.ProbeResult{}, errors.Join(err, s.Failed(ctx, 0))
}
}
p.last = activator.ProbeResult{Session: ah.Session, Stamp: ah.Stamp, Generation: ah.Generation}
diff --git a/internal/host/movesrevert_test.go b/internal/host/movesrevert_test.go
index 9f839a2ca..47242a196 100644
--- a/internal/host/movesrevert_test.go
+++ b/internal/host/movesrevert_test.go
@@ -50,7 +50,14 @@ type revertHarness struct {
func newRevertHarness(t *testing.T, opts ...p1Option) *revertHarness {
t.Helper()
- h := &revertHarness{cutHarness: newCutHarness(t, opts...), tdir: t.TempDir(), tleases: &fakeParkLeases{}}
+ return newRevertHarnessOn(t, newCutHarness(t, opts...))
+}
+
+// newRevertHarnessOn is newRevertHarness over a cut harness the caller
+// built (a landing harness's, whose source replica is a real one).
+func newRevertHarnessOn(t *testing.T, ch *cutHarness) *revertHarness {
+ t.Helper()
+ h := &revertHarness{cutHarness: ch, tdir: t.TempDir(), tleases: &fakeParkLeases{}}
// The row follows the cut's hold, as holdMoving keeps it: held moving
// under the op, a row held by another op refused.
h.cfg.HoldMoving = func(host, op string) error {
@@ -652,28 +659,23 @@ func countString(xs []string, s string) int {
// the claim again; with the step's own read it lost the CAS every pass, and
// each pass's probe admitted one more fresh generation until the attempts
// were spent. Here, on the default named move, the probe runs through the
-// production adapter and recovery owners and admits once; the source then
-// reopens in the same pass.
+// production adapter and the claim runtime (Admit, Prepare, Restore,
+// Reserve, Materialize) with the production recovery owners, and admits
+// once; the source then reopens in the same pass.
func TestRevertProbedAfterARealAdmission(t *testing.T) {
- h := newRevertHarness(t, realLaunchEnv)
+ h := newRevertHarnessOn(t, newLandingHarness(t, realLaunchEnv).cutHarness)
h.fromStopped(t)
h.arm(t)
- sourceMarker(t, h.phase1Harness)
- rec := newProdRecovery(t, h.phase1Harness, h.cfg.DataDir, func() registry.Route { return h.route })
- h.cfg.recoveryProbe = func(ctx context.Context, s release.Store, r provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error) {
- w := h.w()
- a := recoveryProbeAdapter{Box: w.cfg.Box, Claims: true, Route: w.cfg.Route, Current: w.recoveryInputs, Wake: rec.wake}
- return a.probe(ctx, s, r, req)
- }
+ rec := revertRecovery(t, h)
h.pass(t)
h.reopened(t)
- if len(rec.admitted) != 1 {
- t.Fatalf("the revert probe admitted %d times", len(rec.admitted))
+ if rec.wakes != 1 || rec.materialized.Claim == nil {
+ t.Fatalf("the revert probe's starts: %d, materialized %v", rec.wakes, rec.materialized.Claim != nil)
}
h.pass(t)
h.reopened(t)
- if len(rec.admitted) != 1 {
- t.Fatalf("a pass after the reopen probed again: %d admissions", len(rec.admitted))
+ if rec.wakes != 1 {
+ t.Fatalf("a pass after the reopen probed again: %d starts", rec.wakes)
}
}