| index a3e7c1025..d189ec2a8 100644 |
| --- a/internal/activator/swap_review_test.go |
| +++ b/internal/activator/swap_review_test.go |
| @@ -42,23 +42,60 @@ func TestSwapWitnessAgentRestartMidSwap(t *testing.T) { |
| |
| t.Run("successor spawning", func(t *testing.T) { |
| f := newSwapFixture(t) |
| - f.b.genGate = make(chan struct{}) // never opened: only cancellation ends the spawn |
| + // A spawn that outlives cancellation: StartStaged ignores its ctx |
| + // until the hold opens, then returns a live successor. Shutdown |
| + // cancels the attempt, but only the join makes it wait for that |
| + // process, so the incumbent may not stop and the lease may not go |
| + // back until the successor exists and has been stopped. |
| + hold := make(chan struct{}) |
| + f.b.genHold = hold |
| incumbent, rep := f.b.lastProc(), f.b.lastRep() |
| var leaked atomic.Bool |
| rep.setOnClose(func() { |
| - if !incumbent.Exited() { |
| + if _, succ := f.b.lastGen(); succ == nil || !succ.Exited() || !incumbent.Exited() { |
| leaked.Store(true) |
| } |
| }) |
| results := make(chan SwapResult, 1) |
| go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }() |
| waitFor(t, "the successor's spawn", func() bool { return indexPrefix(f.b.events(), "startGen:") >= 0 }) |
| - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| - f.a.Shutdown(ctx) |
| - cancel() |
| + shut := make(chan struct{}) |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + f.a.Shutdown(ctx) |
| + close(shut) |
| + }() |
| + waitFor(t, "Shutdown to reach the entry", func() bool { |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + return f.a.entries[f.host].state == actStopping |
| + }) |
| + // The barrier holds the stop back while the spawn is in flight. As in |
| + // the draining case nothing signals the barrier itself, so the |
| + // negative is watched for a window; a stop that passed it would stop |
| + // the incumbent at once, and the poll fails then. |
| + for deadline := time.Now().Add(200 * time.Millisecond); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { |
| + if incumbent.isStopped() { |
| + t.Fatal("the incumbent was stopped while a successor was still spawning") |
| + } |
| + if rep.isClosed() { |
| + t.Fatal("the lease went back while a successor was still spawning") |
| + } |
| + } |
| + select { |
| + case <-shut: |
| + t.Fatal("Shutdown returned with a successor still spawning") |
| + default: |
| + } |
| + close(hold) |
| + <-shut |
| if res := <-results; res.Outcome != SwapAbandoned { |
| t.Fatalf("Swap = %+v, want abandoned by the shutdown", res) |
| } |
| + if _, succ := f.b.lastGen(); succ == nil || !succ.Exited() { |
| + t.Fatal("the successor that outlived the cancellation was never stopped") |
| + } |
| if !rep.isClosed() || leaked.Load() { |
| t.Fatalf("lease closed %v, released while a generation ran %v", rep.isClosed(), leaked.Load()) |
| } |