package activator import ( "context" "errors" "fmt" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/registry" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) type maintenanceProc struct { calls atomic.Int32 exited atomic.Bool gate <-chan struct{} entered chan struct{} err error remains bool } func (p *maintenanceProc) Stop(ctx context.Context) error { if p.calls.Add(1) == 1 && p.entered != nil { close(p.entered) } if p.gate != nil { select { case <-p.gate: case <-ctx.Done(): return ctx.Err() } } if !p.remains { p.exited.Store(true) } return p.err } func (p *maintenanceProc) Exited() bool { return p.exited.Load() } type maintenanceRep struct { gate <-chan struct{} entered chan struct{} calls atomic.Int32 err error } func (r *maintenanceRep) Err() error { return nil } func (r *maintenanceRep) Close(ctx context.Context) error { if r.calls.Add(1) == 1 && r.entered != nil { close(r.entered) } if r.gate != nil { select { case <-r.gate: case <-ctx.Done(): return ctx.Err() } } return r.err } func maintenanceContext(t *testing.T, duration time.Duration) context.Context { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), duration) t.Cleanup(cancel) return ctx } func maintenanceAwake(t *testing.T) (*Service, *actEntry, *maintenanceProc, *maintenanceProc, *maintenanceRep) { t.Helper() a, _, _, reg := testActivator(t, time.Hour) t.Cleanup(func() { reg.Close() }) main, side, rep := &maintenanceProc{}, &maintenanceProc{}, &maintenanceRep{} e := &actEntry{host: "fence.example", route: testRoute("fence.example"), state: actAwake, proc: main, sidecar: side, rep: rep, lastActive: a.now()} a.entries[e.host] = e return a, e, main, side, rep } func TestMaintenanceFenceChecksEveryStopOutcome(t *testing.T) { t.Parallel() for _, scenario := range []string{"success", "main-error", "sidecar-error", "replica-error", "main-alive", "sidecar-alive", "missing-main", "missing-replica", "uncertain-sidecar"} { t.Run(scenario, func(t *testing.T) { a, e, main, side, rep := maintenanceAwake(t) fail := errors.New("private backend detail") switch scenario { case "main-error": main.err = fail case "sidecar-error": side.err = fail case "replica-error": rep.err = fail case "main-alive": main.remains = true case "sidecar-alive": side.remains = true case "missing-main": e.proc = nil case "missing-replica": e.rep = nil case "uncertain-sidecar": e.sidecarUncertain = true } proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1) if scenario == "success" { if err != nil || !proof.Matches(e.host, 1) || proof.Matches(e.host, 2) { t.Fatal("checked stop missing", err) } } else if err != ErrStopUnproven || proof != nil { t.Fatal("failed stop became evidence", err) } if a.fences[e.host] != 1 { t.Fatal("failure removed fence") } if !a.Quiesced(e.host) { t.Fatal("ordinary entry removal changed") } retry, retryErr := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1) if scenario != "success" && (retryErr != ErrStopUnproven || retry != nil) { t.Fatal("deleted entry erased failure") } if main.calls.Load() > 1 || side.calls.Load() > 1 || rep.calls.Load() > 1 { t.Fatal("replayed spent stop") } }) } } func TestMaintenanceFenceBlocksEveryAdmissionIncludingStaleRoute(t *testing.T) { t.Parallel() a, _, _, reg := testActivator(t, time.Hour) defer reg.Close() host := "absent.example" if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), host, 3); err != ErrStopUnproven || proof != nil { t.Fatal("absence became stop proof", err) } rt := testRoute(host) if _, err := a.beginRequest(t.Context(), rt); err != ErrFenced { t.Fatal("member wake passed", err) } if err := a.BeginSidecarRequest(t.Context(), rt); err != ErrFenced { t.Fatal("sidecar passed", err) } for _, scheduled := range []bool{false, true} { for _, stale := range []bool{false, true} { for _, sidecar := range []bool{false, true} { route := rt if stale { route.Hibernate = false route.Kind = "service" } req := httptest.NewRequest("GET", "https://"+host+"/", nil) req, answered := edge.WithAnswerFlag(req) if sidecar { req.Header.Set(SidecarHeader, "1") } out := httptest.NewRecorder() proxied := false proxy := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { proxied = true }) var handled bool if scheduled { handled = a.ServeScheduled(out, req, route, proxy) } else { handled = a.ServeHTTP(out, req, route, proxy) } if !handled || proxied || !*answered || out.Code != 503 || out.Header().Get("Cache-Control") != "no-store" { t.Fatal("fence bypassed by stale or scheduled route") } } } } if _, err := a.FenceAndStop(maintenanceContext(t, time.Second), host, 2); err != ErrFenceGeneration { t.Fatal("older generation accepted") } // A new process has no closure evidence from the old activator's lifetime. fresh := New(a.cfg, a.backend, reg) if proof, err := fresh.FenceAndStop(maintenanceContext(t, time.Second), host, 3); err != ErrStopUnproven || proof != nil { t.Fatal("restart invented old evidence") } } func TestMaintenanceFenceContextAndGenerationDoNotRestoreAdmission(t *testing.T) { a, e, main, _, _ := maintenanceAwake(t) gate := make(chan struct{}) main.gate = gate main.entered = make(chan struct{}) first := make(chan error, 1) go func() { _, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); first <- err }() <-main.entered if proof, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), e.host, 2); err != ErrStopUnproven || proof != nil { t.Fatal("timed-out stop claimed proof") } if _, err := a.beginRequest(t.Context(), e.route); err != ErrFenced { t.Fatal("timeout reopened admission") } close(gate) if err := <-first; err != ErrFenceGeneration { t.Fatal("old waiter survived newer fence", err) } proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 2) if err != nil || !proof.Matches(e.host, 2) { t.Fatal("checked outcome lost after timeout", err) } for _, ctx := range []context.Context{nil, context.Background(), maintenanceContext(t, -time.Second)} { if p, err := a.FenceAndStop(ctx, e.host, 2); err == nil || p != nil { t.Fatal("unbounded or expired stop accepted") } } a.mu.Lock() started := time.Now() if _, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), e.host, 2); err != ErrFenceNotInstalled { t.Fatal(err) } a.mu.Unlock() if time.Since(started) > 250*time.Millisecond { t.Fatal("fence mutex escaped deadline") } } func TestMaintenanceOrdinaryStopFailureRemainsObservable(t *testing.T) { t.Parallel() a, e, main, _, _ := maintenanceAwake(t) main.err = errors.New("stop refused") a.mu.Lock() a.beginStopLocked(e, StopIdle) done := e.stopped a.mu.Unlock() <-done if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 4); err != ErrStopUnproven || proof != nil { t.Fatal("ordinary stop erased failure", err) } } func TestMaintenanceLaterOrdinaryStopCannotEraseUnresolvedCleanup(t *testing.T) { t.Parallel() a, e, main, _, _ := maintenanceAwake(t) main.err = errors.New("earlier process may still run") a.mu.Lock() a.beginStopLocked(e, StopIdle) done := e.stopped a.mu.Unlock() <-done replacement := &actEntry{host: e.host, route: e.route, state: actAwake, proc: &maintenanceProc{}, sidecar: &maintenanceProc{}, rep: &maintenanceRep{}} a.mu.Lock() a.entries[e.host] = replacement a.beginStopLocked(replacement, StopIdle) done = replacement.stopped a.mu.Unlock() <-done if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("new entry erased unresolved predecessor", err) } } type maintenanceBackend struct { *fakeBackend main *maintenanceProc side *maintenanceProc rep *maintenanceRep acquireGate <-chan struct{} acquireFailure error acquireEntered chan struct{} replicateGate <-chan struct{} replicateEntered chan struct{} sideGate <-chan struct{} sideEntered chan struct{} startFailure error sideFailure error replicateFailure error } func (b *maintenanceBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) { if b.acquireEntered != nil { close(b.acquireEntered) } if b.acquireGate != nil { <-b.acquireGate } lease, err := b.fakeBackend.Acquire(ctx, rt) if err != nil { return nil, err } return &maintenanceLease{Lease: lease, b: b}, b.acquireFailure } func (b *maintenanceBackend) Start(context.Context, registry.Route) (Proc, string, error) { b.mu.Lock() b.starts++ b.mu.Unlock() if b.startFailure != nil { if b.main == nil { return nil, "", b.startFailure } return b.main, "", b.startFailure } return b.main, "", nil } func (b *maintenanceBackend) StartSidecar(context.Context, registry.Route) (Proc, error) { b.mu.Lock() b.sidecarStarts++ b.mu.Unlock() if b.sideEntered != nil { close(b.sideEntered) } if b.sideGate != nil { <-b.sideGate } return b.side, b.sideFailure } type maintenanceLease struct { Lease b *maintenanceBackend } func (l *maintenanceLease) Replicate(context.Context, string, time.Duration) (Rep, error) { if l.b.replicateEntered != nil { close(l.b.replicateEntered) } if l.b.replicateGate != nil { <-l.b.replicateGate } if l.b.rep == nil { return nil, l.b.replicateFailure } return l.b.rep, l.b.replicateFailure } func TestMaintenanceFenceDuringWakeOwnsItsCleanup(t *testing.T) { t.Parallel() for _, phase := range []string{"acquire", "replicate"} { t.Run(phase, func(t *testing.T) { a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() gate, entered := make(chan struct{}), make(chan struct{}) b := &maintenanceBackend{fakeBackend: base, main: &maintenanceProc{}, rep: &maintenanceRep{}} if phase == "acquire" { b.acquireGate = gate b.acquireEntered = entered } else { b.replicateGate = gate b.replicateEntered = entered } a.backend = b rt := testRoute("waking.example") request := make(chan error, 1) go func() { release, err := a.beginRequest(t.Context(), rt) if release != nil { release() } request <- err }() <-entered if proof, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("in-flight wake yielded proof", err) } close(gate) if err := <-request; err != ErrFenced { t.Fatal("waking request admitted after fence", err) } proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1) if phase == "acquire" { if err != ErrStopUnproven || proof != nil { t.Fatal("canceled pre-spawn wake invented closure", err) } if b.starts != 0 || b.restores != 0 { t.Fatal("fenced acquisition continued startup") } } else { if err != nil || !proof.Matches(rt.Host, 1) { t.Fatal("started wake did not yield checked cleanup", err) } if b.main.calls.Load() != 1 || b.rep.calls.Load() != 1 { t.Fatal("partial wake resources not closed") } } }) } } func TestMaintenanceFailedBackendStartRemainsUnproven(t *testing.T) { t.Parallel() a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() a.backend = &maintenanceBackend{fakeBackend: base, startFailure: errors.New("hidden backend cleanup failed")} rt := testRoute("hidden.example") if _, err := a.beginRequest(t.Context(), rt); err == nil { t.Fatal("failed backend started") } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("hidden cleanup failure became proof", err) } } // Before design §6.7 this restart ran synchronously under a.mu, so a fence // attempt that raced a stalled StartSidecar found the lock held and came // back ErrFenceNotInstalled without ever touching the entry. Moving the // call off a.mu means a stalled restart can't block anything else, // including FenceAndStop: it now installs its fence immediately. What // still bounds a short-context attempt is stop()'s own wait // (sidecarStopTimeout) for the stalled restart to land before it can act // on e.sidecar, so the outcome changes (ErrStopUnproven, fence installed) // but a stalled sidecar still keeps a checked stop from completing // instantly, which is the property this test exists to cover. func TestMaintenanceFenceSerializesSidecarSweepReplacement(t *testing.T) { t.Parallel() a, e, _, side, rep := maintenanceAwake(t) gate, entered := make(chan struct{}), make(chan struct{}) base := a.backend.(*fakeBackend) base.sidecarStaged = true next := &maintenanceProc{} a.backend = &maintenanceBackend{fakeBackend: base, side: next, sideGate: gate, sideEntered: entered, rep: rep} side.exited.Store(true) e.sidecarWanted = true // sweepOnce itself now returns immediately. The restart it launches // runs in its own goroutine, which is what's actually gated below. a.sweepOnce() <-entered if proof, err := a.FenceAndStop(maintenanceContext(t, 15*time.Millisecond), e.host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("blocked replacement yielded proof", err) } if a.wakeAllowed(e.host) { t.Fatal("fenced admission attempt failed to install its fence") } close(gate) if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); err != nil || !proof.Matches(e.host, 1) { t.Fatal("replacement escaped checked stop", err) } waitFor(t, "the discarded replacement to be stopped", func() bool { return next.calls.Load() == 1 }) before := base.sidecarStartCount() a.sweepOnce() if base.sidecarStartCount() != before { t.Fatal("fenced sweep restarted sidecar") } } type maintenanceDue struct { DueStore route registry.Route entered chan struct{} gate <-chan struct{} } func (d *maintenanceDue) DueInstances(int64) ([]registry.Route, error) { close(d.entered) <-d.gate return []registry.Route{d.route}, nil } func TestMaintenanceFenceStopsStaleScheduledWake(t *testing.T) { t.Parallel() a, b, _, reg := testActivator(t, time.Hour) defer reg.Close() rt := testRoute("scheduled.example") rt.Status = "ready" gate, entered := make(chan struct{}), make(chan struct{}) a.due = &maintenanceDue{DueStore: reg, route: rt, entered: entered, gate: gate} swept := make(chan struct{}) go func() { a.sweepOnce(); close(swept) }() <-entered if _, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven { t.Fatal(err) } close(gate) <-swept b.mu.Lock() starts, acquires := b.starts, b.acquires b.mu.Unlock() if starts != 0 || acquires != 0 || a.entryCount() != 0 { t.Fatal("stale due query crossed installed fence") } } func TestMaintenanceAwakeFenceSuppressesSidecarAdoption(t *testing.T) { t.Parallel() a, e, _, side, _ := maintenanceAwake(t) base := a.backend.(*fakeBackend) base.sidecarStaged = true side.exited.Store(true) a.fences = map[string]uint64{e.host: 1} a.sweepOnce() if base.sidecarStartCount() != 0 { t.Fatal("awake fence allowed sidecar adoption") } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); err != nil || proof == nil { t.Fatal(err) } } func TestMaintenanceFailedStartCleansReturnedHandleBeforeRelease(t *testing.T) { t.Parallel() a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() gate, entered := make(chan struct{}), make(chan struct{}) proc := &maintenanceProc{gate: gate, entered: entered} a.backend = &maintenanceBackend{fakeBackend: base, main: proc, startFailure: errors.New("started then failed")} rt := testRoute("partial.example") done := make(chan error, 1) go func() { _, err := a.beginRequest(t.Context(), rt); done <- err }() select { case <-entered: case <-time.After(3 * time.Second): t.Fatal("returned process cleanup was never attempted") } base.mu.Lock() released := base.releases base.mu.Unlock() if released != 0 { t.Fatal("lease released before returned process cleanup") } close(gate) if err := <-done; err == nil { t.Fatal("partial failed start admitted") } if proc.calls.Load() != 1 || !proc.Exited() { t.Fatal("returned process handle was abandoned") } base.mu.Lock() released = base.releases base.mu.Unlock() if released != 1 { t.Fatal("lease release missing or repeated", released) } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("partial failure erased uncertainty", err) } } func TestMaintenanceFailedSidecarHandlesRemainOwned(t *testing.T) { t.Parallel() for _, phase := range []string{"wake", "sweep"} { t.Run(phase, func(t *testing.T) { a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() base.sidecarStaged = true main, partial, rep := &maintenanceProc{}, &maintenanceProc{}, &maintenanceRep{} b := &maintenanceBackend{fakeBackend: base, main: main, side: partial, rep: rep, sideFailure: errors.New("sidecar started then failed")} a.backend = b rt := testRoute("partial-sidecar.example") if phase == "wake" { release, err := a.beginRequest(t.Context(), rt) if err != nil { t.Fatal(err) } release() } else { old := &maintenanceProc{} old.exited.Store(true) a.entries[rt.Host] = &actEntry{host: rt.Host, route: rt, state: actAwake, proc: main, rep: rep, sidecar: old, sidecarWanted: true, lastActive: a.now()} a.sweepOnce() // The restart call runs off a.mu (design §6.7); give it a // moment to land before inspecting what it installed. waitFor(t, "sidecar restart to land", func() bool { return base.sidecarStartCount() == 1 && a.sidecarSettled(rt.Host) }) } a.mu.Lock() entry := a.entries[rt.Host] owned := entry.sidecar == partial && entry.sidecarUncertain a.mu.Unlock() if !owned { t.Fatal("failed sidecar handle was discarded") } before := base.sidecarStartCount() a.sweepOnce() if base.sidecarStartCount() != before { t.Fatal("live partial sidecar was replaced") } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("failed sidecar yielded trusted evidence", err) } if partial.calls.Load() != 1 || !partial.Exited() { t.Fatal("checked stop did not own partial sidecar") } }) } } func TestMaintenanceFailedReplicationClosesReturnedReplicaWithoutDoubleRelease(t *testing.T) { t.Parallel() for _, closeFails := range []bool{false, true} { t.Run(fmt.Sprint(closeFails), func(t *testing.T) { a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() mainGate, mainEntered := make(chan struct{}), make(chan struct{}) repGate, repEntered := make(chan struct{}), make(chan struct{}) main := &maintenanceProc{gate: mainGate, entered: mainEntered} rep := &maintenanceRep{gate: repGate, entered: repEntered} if closeFails { rep.err = errors.New("replica close failed") } a.backend = &maintenanceBackend{fakeBackend: base, main: main, rep: rep, replicateFailure: errors.New("replica opened then failed")} rt := testRoute("partial-replica.example") done := make(chan error, 1) go func() { _, err := a.beginRequest(t.Context(), rt); done <- err }() select { case <-mainEntered: case <-time.After(3 * time.Second): t.Fatal("returned main cleanup missing") } if rep.calls.Load() != 0 { t.Fatal("replica closed before main stopped") } close(mainGate) select { case <-repEntered: case <-time.After(3 * time.Second): t.Fatal("returned replica cleanup missing") } if !main.Exited() { t.Fatal("replica closed before main exited") } base.mu.Lock() released := base.releases base.mu.Unlock() if released != 0 { t.Fatal("lease released while replica close pending") } close(repGate) if err := <-done; err == nil { t.Fatal("partial replica failure admitted") } base.mu.Lock() released = base.releases base.mu.Unlock() if released != 0 || rep.calls.Load() != 1 { t.Fatal("returned replica lease released twice", released, rep.calls.Load()) } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("partial replica uncertainty erased", err) } }) } } func TestMaintenanceFailedAcquireReleasesReturnedLease(t *testing.T) { t.Parallel() a, base, _, reg := testActivator(t, time.Hour) defer reg.Close() a.backend = &maintenanceBackend{fakeBackend: base, acquireFailure: errors.New("lease returned with acquisition failure")} rt := testRoute("partial-lease.example") if _, err := a.beginRequest(t.Context(), rt); err == nil { t.Fatal("failed acquisition admitted") } base.mu.Lock() released, starts := base.releases, base.starts base.mu.Unlock() if released != 1 || starts != 0 { t.Fatal("returned lease abandoned or wake continued", released, starts) } if proof, err := a.FenceAndStop(maintenanceContext(t, time.Second), rt.Host, 1); err != ErrStopUnproven || proof != nil { t.Fatal("failed acquisition became stop proof", err) } } // TestFencedReportsAnInstalledFenceUntilItIsLifted: Fenced is the layout // pass's own affirmative read of what FenceAndStop and LiftFence leave // behind, so it must agree with them at every step rather than invert // wakeAllowed's private sense of the same map. func TestFencedReportsAnInstalledFenceUntilItIsLifted(t *testing.T) { a, e, _, _, _ := maintenanceAwake(t) if a.Fenced(e.host) { t.Fatal("fence reported before one was installed") } if _, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 1); err != nil { t.Fatal(err) } if !a.Fenced(e.host) { t.Fatal("fence not reported as installed") } if !a.LiftFence(e.host, 1) { t.Fatal("LiftFence reported nothing to release") } if a.Fenced(e.host) { t.Fatal("fence still reported after it was released") } } // TestFenceGenerationReportsTheHeldGeneration: the layout pass tells its // own kept fence from another operation's by the generation, so the read // must say exactly which generation holds, and 0 once none does. func TestFenceGenerationReportsTheHeldGeneration(t *testing.T) { a, e, _, _, _ := maintenanceAwake(t) if g := a.FenceGeneration(e.host); g != 0 { t.Fatalf("generation %d before any fence", g) } if _, err := a.FenceAndStop(maintenanceContext(t, time.Second), e.host, 9); err != nil { t.Fatal(err) } if g := a.FenceGeneration(e.host); g != 9 { t.Fatalf("generation = %d, want 9", g) } a.LiftFence(e.host, 9) if g := a.FenceGeneration(e.host); g != 0 { t.Fatalf("generation %d after the lift", g) } } // TestStopUncertainCoversLiveEntriesAndIncompleteHistory: a pass deciding // whether ErrStopUnproven can be treated as a completed stop needs both // halves of this: an entry that is still live right now (waking, awake or // stopping), and a past stop observation that never finished every check. // It must answer false for a host it has never seen at all, since that is // not evidence of anything. func TestStopUncertainCoversLiveEntriesAndIncompleteHistory(t *testing.T) { t.Parallel() a, e, main, _, _ := maintenanceAwake(t) if !a.StopUncertain(e.host) { t.Fatal("a live entry must read as stop-uncertain") } if a.StopUncertain("never-seen.example") { t.Fatal("a host with no entry and no history must not read as uncertain") } // Drive the entry through an ordinary stop that fails, which removes the // entry but leaves an incomplete observation in stopHistory. main.err = errors.New("stop refused") a.mu.Lock() a.beginStopLocked(e, StopIdle) done := e.stopped a.mu.Unlock() <-done if a.Owns(e.host) { t.Fatal("entry should have been forgotten once the stop finished") } if !a.StopUncertain(e.host) { t.Fatal("an incomplete stop observation must read as uncertain") } // A different host's clean stop completes its observation and leaves no // live entry either. StopUncertain must read false for it, proving the // predicate is per-host rather than sticky once anything has failed. clean, ce, _, _, _ := maintenanceAwake(t) if proof, err := clean.FenceAndStop(maintenanceContext(t, time.Second), ce.host, 1); err != nil || !proof.Matches(ce.host, 1) { t.Fatalf("checked clean stop: %v", err) } if clean.StopUncertain(ce.host) { t.Fatal("a complete stop observation with no live entry must not read as uncertain") } } // TestFenceAndStopIfUnfencedRefusesAnyHeldFence: the layout pass shares the // one fence slot per host with the residence move, and FenceAndStop would // replace a lower generation. The exclusive variant decides under the same // lock that installs, so a fence of any generation, lower, equal or // higher, refuses it and stays exactly as it was, and nothing is stopped. func TestFenceAndStopIfUnfencedRefusesAnyHeldFence(t *testing.T) { t.Parallel() for _, held := range []uint64{5, 10, 20} { t.Run(fmt.Sprint(held), func(t *testing.T) { a, e, main, _, _ := maintenanceAwake(t) a.mu.Lock() a.fences = map[string]uint64{e.host: held} a.mu.Unlock() _, err := a.FenceAndStopIfUnfenced(maintenanceContext(t, time.Second), e.host, 10) if !errors.Is(err, ErrFenceHeld) { t.Fatalf("err = %v, want ErrFenceHeld", err) } a.mu.Lock() got := a.fences[e.host] a.mu.Unlock() if got != held { t.Fatalf("held fence is %d, want %d", got, held) } if main.calls.Load() != 0 || !a.Owns(e.host) { t.Fatal("a refused exclusive fence stopped the host") } }) } } // TestFenceAndStopIfUnfencedIsFenceAndStopOnAnUnfencedHost: with no fence // held it installs, stops and proves exactly as FenceAndStop does. func TestFenceAndStopIfUnfencedIsFenceAndStopOnAnUnfencedHost(t *testing.T) { t.Parallel() a, e, _, _, _ := maintenanceAwake(t) proof, err := a.FenceAndStopIfUnfenced(maintenanceContext(t, time.Second), e.host, 7) if err != nil || !proof.Matches(e.host, 7) { t.Fatalf("exclusive fence on an unfenced awake host: %v", err) } if !a.Fenced(e.host) || a.Owns(e.host) { t.Fatal("not fenced and stopped") } // An entry-less host with no stop on record answers ErrStopUnproven // with its fence installed, as FenceAndStop does. if _, err := a.FenceAndStopIfUnfenced(maintenanceContext(t, time.Second), "idle.example", 3); !errors.Is(err, ErrStopUnproven) { t.Fatalf("entry-less host: %v", err) } if !a.LiftFence("idle.example", 3) { t.Fatal("the unproven exclusive fence was not installed at its generation") } }