host: two test seams, the stage round's start and statusTickAt
moveCfg.stageFault is now also called with "round-start" as a stager round begins (after the stager finds a request it has not answered, before any copy); the two crash tests that faulted any point now fault "round" only, as they meant. statusTick is statusTickAt at the wall clock, so a caller can stamp the snapshot at another clock's reading. Nil seams and the agent's own calls are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+28
−6
internal/host/agent.go+7 −1internal/host/movesoffline_reports_test.go+12 −2internal/host/movesoffline_stage.go+5 −0internal/host/movespass.go+4 −3
diff --git a/internal/host/agent.go b/internal/host/agent.go| index db43a997e..0d3d1a126 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -3978,11 +3978,17 @@ func statusLoop(reg *registry.Registry, svc *activator.Service, buildStates func |
| // dials, not an opaque pseudonym only the public status page ever wanted. |
| // "" (every platform box) leaves CollectBox's own label untouched. |
| func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p status.Probes, publish bool, labelOverride string) error { |
| + return statusTickAt(ctx, s, reg, p, publish, labelOverride, time.Now()) |
| +} |
| + |
| +// statusTickAt is statusTick at the clock reading now: the box's snapshot |
| +// is stamped generated at now. The agent passes the wall clock (statusTick); |
| +// the end-to-end bridge passes its boxes' clock. |
| +func statusTickAt(ctx context.Context, s release.Store, reg *registry.Registry, p status.Probes, publish bool, labelOverride string, now time.Time) error { |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| } |
| - now := time.Now() |
| // Aliases carries route_aliases' DNS/cert stamps (Task 2/5) up to the box |
| // snapshot (issue #151) — read fresh alongside routes above rather than |
diff --git a/internal/host/movesoffline_reports_test.go b/internal/host/movesoffline_reports_test.go| index c6cf3e883..a585f00d3 100644 |
| --- a/internal/host/movesoffline_reports_test.go |
| +++ b/internal/host/movesoffline_reports_test.go |
| @@ -717,7 +717,12 @@ func TestStageBookAcrossRounds(t *testing.T) { |
| // still lists it, the restarted stager seeds from the report, and the |
| // next complete round drops it. |
| sw.fake.BareDelete(sealIE, repl+"extra/single") |
| - ie.w.cfg.stageFault = func(string) error { return errors.New("crash before the report") } |
| + ie.w.cfg.stageFault = func(point string) error { |
| + if point != "round" { |
| + return nil |
| + } |
| + return errors.New("crash before the report") |
| + } |
| sw.restage() |
| if err := sw.stagePass(ie); err == nil { |
| t.Fatal("no crash") |
| @@ -788,7 +793,12 @@ func TestStageBookAcrossRounds(t *testing.T) { |
| // A crash before the report: the next round re-copies what it must and |
| // the book converges on the staged prefix. |
| sw.fake.Put(sealIE, repl+"extra/late", []byte("late")) |
| - ie.w.cfg.stageFault = func(string) error { return errors.New("crash before the report") } |
| + ie.w.cfg.stageFault = func(point string) error { |
| + if point != "round" { |
| + return nil |
| + } |
| + return errors.New("crash before the report") |
| + } |
| sw.restage() |
| if err := sw.stagePass(ie); err == nil { |
| t.Fatal("no crash") |
diff --git a/internal/host/movesoffline_stage.go b/internal/host/movesoffline_stage.go| index bc47de2cc..7cbbf1973 100644 |
| --- a/internal/host/movesoffline_stage.go |
| +++ b/internal/host/movesoffline_stage.go |
| @@ -91,6 +91,11 @@ func (w *movesWorker) offlineStageStep(ctx context.Context, s release.Store, rec |
| if prev != nil && prev.StageRequest.Equal(request) && (prev.Phase == moves.OfflinePhaseStaged || prev.Phase == moves.OfflinePhaseFailed) { |
| return nil // this request has had its round |
| } |
| + if cfg.stageFault != nil { |
| + if err := cfg.stageFault("round-start"); err != nil { |
| + return err // a crash at the round's start, before any copy |
| + } |
| + } |
| r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleStager, Phase: moves.OfflinePhaseStaged, StageRequest: request, |
| BoxID: cfg.BoxID, InstanceID: cfg.instance()} |
| book := w.stageBookFor(rec, prev) |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 010ca699e..e23d9ae17 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -1052,9 +1052,10 @@ type moveCfg struct { |
| // is active on this box, nil when it cannot tell: the source's precheck |
| // and the target's ready report carry it. Nil hook: unknown. |
| StagePathArmed func() *bool |
| - // stageFault is the stage round's test seam: called after a round's |
| - // copies with "round", before its report, an error being a crash there. |
| - // Nil in production. |
| + // stageFault is the stage round's test seam: called with "round-start" |
| + // as a round begins, before any copy, and with "round" after a round's |
| + // copies, before its report, an error being a crash there. Nil in |
| + // production. |
| stageFault func(point string) error |
| // FenceAs installs, or meets again, an owned activator fence |
| // (activator.Service.FenceAndStopAs under a 2 s deadline, |