| index ef9ca4e7f..52d19633f 100644 |
| --- a/internal/activator/opset_test.go |
| +++ b/internal/activator/opset_test.go |
| @@ -4,8 +4,11 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "log" |
| + "os" |
| "os/exec" |
| "slices" |
| + "strings" |
| "sync" |
| "sync/atomic" |
| "testing" |
| @@ -863,3 +866,116 @@ func TestSwapWitnessUnprovenWakeKeepsTheLease(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// syncLog is a log destination that goroutines may write concurrently (the |
| +// reapers log off the test's goroutine). |
| +type syncLog struct { |
| + mu sync.Mutex |
| + b strings.Builder |
| +} |
| + |
| +func (s *syncLog) Write(p []byte) (int, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.b.Write(p) |
| +} |
| + |
| +func (s *syncLog) String() string { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.b.String() |
| +} |
| + |
| +// Shutdown covers a retained failed wake (spec §7.4, I2: nothing runs after |
| +// Shutdown returns). No entry waits for one, so Shutdown polls its reaper |
| +// until the generation is proven stopped and the lease given back, or the |
| +// budget runs out, which it logs with the lease still held. Before, Shutdown |
| +// saw no entries and returned at once, leaving the generation running and |
| +// its lease held until the TTL. The retention has no generations gate (the |
| +// sentinel exists only under GenContainment), so neither has the wait. |
| +func TestSwapWitnessShutdownWaitsForAnUnprovenWake(t *testing.T) { |
| + for _, row := range []struct { |
| + name string |
| + gens bool |
| + budget time.Duration |
| + heal bool |
| + }{ |
| + {"healed within the budget", true, 10 * time.Second, true}, |
| + {"healed within the budget, generations off", false, 10 * time.Second, true}, |
| + {"the budget runs out", true, 300 * time.Millisecond, false}, |
| + } { |
| + t.Run(row.name, func(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + a.gens = row.gens |
| + kb := &killGenBackend{fakeBackend: b, err: errors.New("the generation's child still holds a process")} |
| + a.backend = &unprovenStartBackend{killGenBackend: kb, fail: func(uint64) (Proc, error) { |
| + return nil, fmt.Errorf("start unit: health wait failed; %w: kill timed out", errGenUnproven) |
| + }} |
| + host := "unproven.example.com" |
| + retained := func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.unprovenWakes[host] != nil |
| + } |
| + if _, err := a.beginRequest(context.Background(), testRoute(host)); err == nil { |
| + t.Fatal("the wake should have failed") |
| + } |
| + if !retained() || b.releaseCount() != 0 { |
| + t.Fatalf("retained %v, lease releases %d: want the failed wake retained with its lease", retained(), b.releaseCount()) |
| + } |
| + logs := &syncLog{} |
| + if !row.heal { |
| + flags := log.Flags() |
| + log.SetOutput(logs) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr); log.SetFlags(flags) }) |
| + } |
| + kills := len(kb.killed()) |
| + |
| + shut := make(chan struct{}) |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), row.budget) |
| + defer cancel() |
| + a.Shutdown(ctx) |
| + close(shut) |
| + }() |
| + returned := func() bool { |
| + select { |
| + case <-shut: |
| + return true |
| + default: |
| + return false |
| + } |
| + } |
| + // Two more failed proofs since Shutdown began: it is still |
| + // polling the reaper, so it has not returned while the wake is |
| + // retained. |
| + waitFor(t, "two failed proofs during Shutdown, or its return", func() bool { |
| + return len(kb.killed()) >= kills+2 || returned() |
| + }) |
| + if returned() { |
| + t.Fatalf("Shutdown returned while the failed wake was retained (KillGen calls during it: %d)", len(kb.killed())-kills) |
| + } |
| + |
| + if row.heal { |
| + kb.setErr(nil) |
| + } |
| + select { |
| + case <-shut: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("Shutdown did not return") |
| + } |
| + if row.heal { |
| + if retained() || b.releaseCount() != 1 { |
| + t.Fatalf("after Shutdown: retained %v, lease releases %d; want the wake proven and its lease given back once", retained(), b.releaseCount()) |
| + } |
| + return |
| + } |
| + if !retained() || b.releaseCount() != 0 { |
| + t.Fatalf("after the budget ran out: retained %v, lease releases %d; want the lease kept", retained(), b.releaseCount()) |
| + } |
| + if got := logs.String(); !strings.Contains(got, host) || !strings.Contains(got, "shutdown") || !strings.Contains(got, "not proven stopped") { |
| + t.Fatalf("Shutdown's log does not name the retained wake:\n%s", got) |
| + } |
| + }) |
| + } |
| +} |