| new file mode 100644 |
| index 000000000..de3dc3e51 |
| --- /dev/null |
| +++ b/internal/activator/swap_admit_test.go |
| @@ -0,0 +1,188 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +var errClaimChanged = errors.New("test: claim changed") |
| + |
| +// swapWork is claim-backed work. denyAt makes its n-th With refuse before |
| +// running fn, which is how hostguard refuses a changed claim or stamp. |
| +type swapWork struct { |
| + mu sync.Mutex |
| + calls int |
| + denyAt int |
| +} |
| + |
| +func (w *swapWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + w.mu.Lock() |
| + w.calls++ |
| + n := w.calls |
| + w.mu.Unlock() |
| + if id != (hostwrite.Identity{}) { |
| + return errors.New("test: a swap must not claim config or release inputs") |
| + } |
| + if n == w.denyAt { |
| + return errClaimChanged |
| + } |
| + return fn() |
| +} |
| + |
| +func (w *swapWork) withCalls() int { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return w.calls |
| +} |
| + |
| +type swapSelector struct { |
| + work *swapWork |
| + err error |
| +} |
| + |
| +func (s *swapSelector) Select(_ context.Context, _, action string) (hostwrite.Work, error) { |
| + if action != "spawn" { |
| + return nil, errors.New("test: unexpected action " + action) |
| + } |
| + if s.work == nil { |
| + return nil, s.err |
| + } |
| + return s.work, s.err |
| +} |
| + |
| +// The four §7.4 checks, one at a time. |
| +func TestSwapChecks(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + host := "checks.example.com" |
| + arrangeAwake(t, a, testRoute(host)) |
| + at := Attempt{Host: host, SchedRev: 4} |
| + four := func(string) uint64 { return 4 } |
| + check := func(newest func(string) uint64) error { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.swapChecksLocked(a.entries[host], at, newest) |
| + } |
| + if err := check(four); err != nil { |
| + t.Fatalf("an awake, leased, newest, unfenced entry was refused: %v", err) |
| + } |
| + if err := check(func(string) uint64 { return 5 }); !errors.Is(err, errSwapSuperseded) { |
| + t.Fatalf("superseded: %v", err) |
| + } |
| + a.mu.Lock() |
| + a.fences = map[string]uint64{host: 3} |
| + a.mu.Unlock() |
| + if err := check(four); !errors.Is(err, errSwapFenced) { |
| + t.Fatalf("fenced: %v", err) |
| + } |
| + a.mu.Lock() |
| + delete(a.fences, host) |
| + a.shuttingDown = true |
| + a.mu.Unlock() |
| + if err := check(four); !errors.Is(err, errSwapShutdown) { |
| + t.Fatalf("shutting down: %v", err) |
| + } |
| + a.mu.Lock() |
| + a.shuttingDown = false |
| + a.mu.Unlock() |
| + b.lastRep().setErr(errors.New("renewal failed")) |
| + if err := check(four); !errors.Is(err, errSwapLeaseLost) { |
| + t.Fatalf("lease lost: %v", err) |
| + } |
| + b.lastRep().setErr(nil) |
| + a.mu.Lock() |
| + a.entries[host].state = actStopping |
| + a.mu.Unlock() |
| + if err := check(four); !errors.Is(err, errSwapNotAwake) { |
| + t.Fatalf("stopping: %v", err) |
| + } |
| + a.mu.Lock() |
| + a.entries[host].state = actAwake |
| + err := a.swapChecksLocked(nil, at, four) |
| + a.mu.Unlock() |
| + if !errors.Is(err, errSwapNotAwake) { |
| + t.Fatalf("asleep: %v", err) |
| + } |
| +} |
| + |
| +// Spec §7.4: ownership is checked apart from supersession, so an exec that |
| +// is not an attempt's (a crash restart, a fallback, a restore, a sidecar) |
| +// can ask it. A superseded attempt still owns the host; a lost lease does |
| +// not, whatever the revision says. |
| +func TestOwnershipIsCheckedApartFromSupersession(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + host := "owned.example.com" |
| + arrangeAwake(t, a, testRoute(host)) |
| + owned := func(waking bool) error { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.ownedLocked(a.entries[host], waking) |
| + } |
| + if err := owned(false); err != nil { |
| + t.Fatalf("an awake, leased, unfenced entry: %v", err) |
| + } |
| + b.lastRep().setErr(errors.New("renewal failed")) |
| + if err := owned(false); !errors.Is(err, errSwapLeaseLost) { |
| + t.Fatalf("lease lost: %v", err) |
| + } |
| + if err := owned(true); err != nil { |
| + t.Fatalf("a waking entry's lease is the wake's own: %v", err) |
| + } |
| + b.lastRep().setErr(nil) |
| + a.mu.Lock() |
| + a.fences = map[string]uint64{host: 1} |
| + a.mu.Unlock() |
| + if err := owned(true); !errors.Is(err, errSwapFenced) { |
| + t.Fatalf("fenced while waking: %v", err) |
| + } |
| +} |
| + |
| +// Claims off: hostwrite's legacy work. The check runs alone, under the |
| +// entry lock that fn takes itself. |
| +func TestAdmitClaimsOffRunsTheCheckAlone(t *testing.T) { |
| + a := New(Config{}, &fakeBackend{}, nil) |
| + work, err := a.selectWork(context.Background(), "h.example.com") |
| + if err != nil || !hostwrite.Legacy(work) { |
| + t.Fatalf("claims off selected %T, %v; want the legacy work", work, err) |
| + } |
| + ran := 0 |
| + if err := a.admit(context.Background(), work, func() error { ran++; return nil }); err != nil || ran != 1 { |
| + t.Fatalf("admit = %v, ran %d", err, ran) |
| + } |
| +} |
| + |
| +// Claims on: the claim and stamp are validated first, and a refusal is |
| +// final. A selector that cannot answer refuses. Nothing is ever read as |
| +// claims off. |
| +func TestAdmitClaimsOnValidatesFirstAndNeverFallsBack(t *testing.T) { |
| + ctx := context.Background() |
| + w := &swapWork{} |
| + a := New(Config{HostWrites: hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}}, &fakeBackend{}, nil) |
| + work, err := a.selectWork(ctx, "h.example.com") |
| + if err != nil || hostwrite.Legacy(work) { |
| + t.Fatalf("claims on selected %T, %v", work, err) |
| + } |
| + ran := 0 |
| + fn := func() error { ran++; return nil } |
| + if err := a.admit(ctx, work, fn); err != nil || ran != 1 || w.withCalls() != 1 { |
| + t.Fatalf("admit = %v, ran %d, With %d", err, ran, w.withCalls()) |
| + } |
| + w.denyAt = 2 |
| + if err := a.admit(ctx, work, fn); !errors.Is(err, errClaimChanged) || ran != 1 { |
| + t.Fatalf("refused claim: err %v, ran %d (fn must not run)", err, ran) |
| + } |
| + for name, sel := range map[string]hostwrite.Selector{ |
| + "selector error": &swapSelector{err: hostwrite.ErrUnavailable}, |
| + "no selector": nil, |
| + "nil work": &swapSelector{}, |
| + } { |
| + a.cfg.HostWrites.Selector = sel |
| + if _, err := a.selectWork(ctx, "h.example.com"); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Errorf("%s: %v, want hostwrite.ErrUnavailable", name, err) |
| + } |
| + } |
| +} |