| index 62fdf0471..760ae6613 100644 |
| --- a/internal/activator/claimwindow_test.go |
| +++ b/internal/activator/claimwindow_test.go |
| @@ -88,3 +88,91 @@ func TestClaimProbeWindowJournalsFirstStartAndSurvivesRetry(t *testing.T) { |
| t.Fatalf("expired retry: %v", err) |
| } |
| } |
| + |
| +// Ported from the model's TestProbeWindow and wProbeWindowAt (R12-2, |
| +// INV-19, internal/sim/hostlife/witness_test.go): readiness k seconds after |
| +// the attempt's first start. Main's boundary is adopted (ruling H17): the |
| +// window and the cut deadline both fail at the instant, not after it |
| +// (claimwindow.go: !now.Before), so 29 is accepted, 30 and 31 are not. |
| +// |
| +// The loop around probeWindow is not reachable here. Its Ready hook runs |
| +// only after RoleLaunch.Start, which needs the isolated root, so the |
| +// readiness decision is tested where the loop reads it: probeWindow.start |
| +// before a start and probeWindow.result after readiness. |
| +func TestProbeWindowBoundaries(t *testing.T) { |
| + t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + far := t0.Add(time.Hour) |
| + newWindow := func(deadline time.Time, now *time.Time) (*probeWindow, func(context.Context, time.Time) (time.Time, error), *int) { |
| + var saved time.Time |
| + calls := 0 |
| + journal := func(_ context.Context, proposed time.Time) (time.Time, error) { |
| + calls++ |
| + if saved.IsZero() { |
| + saved = proposed |
| + } |
| + return saved, nil |
| + } |
| + return &probeWindow{policy: startPolicy{MaxStarts: 5, Probe: true, Window: 30 * time.Second, Deadline: deadline}, now: func() time.Time { return *now }}, journal, &calls |
| + } |
| + for k, want := range map[int]bool{29: true, 30: false, 31: false} { |
| + now := t0 |
| + b, journal, _ := newWindow(far, &now) |
| + if err := b.start(t.Context(), journal); err != nil { |
| + t.Fatalf("k=%d first start: %v", k, err) |
| + } |
| + now = t0.Add(time.Duration(k) * time.Second) |
| + err := b.result(nil) |
| + if (err == nil) != want || (err != nil && !errors.Is(err, ErrAttemptOver)) { |
| + t.Fatalf("k=%d: readiness result %v, accepted want %v", k, err, want) |
| + } |
| + } |
| + // The environment becomes ready only after the window: no further start |
| + // is journaled, and the first start's time is not reset. |
| + now := t0 |
| + b, journal, calls := newWindow(far, &now) |
| + if err := b.start(t.Context(), journal); err != nil || *calls != 1 { |
| + t.Fatalf("first start: %v (journal calls %d)", err, *calls) |
| + } |
| + now = t0.Add(30 * time.Second) |
| + if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) || *calls != 1 { |
| + t.Fatalf("start after the window: %v (journal calls %d)", err, *calls) |
| + } |
| + // The absolute cut deadline bounds the whole, inside the window. |
| + now = t0 |
| + c, journal, _ := newWindow(t0.Add(4*time.Second), &now) |
| + if err := c.start(t.Context(), journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + now = t0.Add(3 * time.Second) |
| + if err := c.result(nil); err != nil { |
| + t.Fatalf("readiness before the cut deadline: %v", err) |
| + } |
| + now = t0.Add(4 * time.Second) |
| + if err := c.result(nil); !errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("readiness at the cut deadline: %v", err) |
| + } |
| + now = t0.Add(5 * time.Second) |
| + if err := c.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) { |
| + t.Fatalf("start past the cut deadline: %v", err) |
| + } |
| + // A readiness already accepted is not revisited by a later clock. |
| + now = t0 |
| + d, journal, _ := newWindow(far, &now) |
| + if err := d.start(t.Context(), journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + d.ready = true |
| + now = t0.Add(time.Hour) |
| + if err := d.result(nil); err != nil { |
| + t.Fatalf("accepted readiness revisited: %v", err) |
| + } |
| + // Five starts at most, whatever the clock says. |
| + a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + t.Fatal("six starts were allowed to prepare") |
| + return ClaimStart{}, nil |
| + }}}} |
| + obs := hostclaim.Observed{Host: "x.example"} |
| + if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 6, Window: 30 * time.Second, Probe: true}); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a sixth start: %v", err) |
| + } |
| +} |