| index 86d696090..62bad5c92 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -808,6 +808,61 @@ func TestActivatorWakeFailuresReleaseLeaseOnce(t *testing.T) { |
| } |
| } |
| |
| +// The singleflight hole behind flake #184: the wake INITIATOR must wait on |
| +// the entry it created, never re-read the map — a fast-FAILING wake deletes |
| +// its entry, and an initiator that looks the host up again finds nothing and |
| +// starts a second wake: two lease acquires, two restore attempts, doubled |
| +// error events, for one request. The window is one preemption wide (between |
| +// the initiator's unlock and its next lock), so this drives many failed |
| +// wakes under -race and contending goroutines and asserts the invariant |
| +// acquires == requests exactly. |
| +func TestActivatorFailedWakeIsSingleflight(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreErr = errors.New("s3 down") |
| + rt := testRoute("singleflight.example.com") |
| + |
| + // Background lock churn widens the initiator's unlock→lock window the |
| + // way a loaded test suite does. |
| + stopChurn := make(chan struct{}) |
| + var churn sync.WaitGroup |
| + for i := 0; i < 4; i++ { |
| + churn.Add(1) |
| + go func() { |
| + defer churn.Done() |
| + for { |
| + select { |
| + case <-stopChurn: |
| + return |
| + default: |
| + a.mu.Lock() |
| + _ = len(a.entries) //nolint:staticcheck // hold briefly |
| + a.mu.Unlock() |
| + } |
| + } |
| + }() |
| + } |
| + |
| + const requests = 300 |
| + for i := 0; i < requests; i++ { |
| + if _, err := a.beginRequest(context.Background(), rt); err == nil { |
| + t.Fatal("wake succeeded despite a failing restore") |
| + } |
| + } |
| + close(stopChurn) |
| + churn.Wait() |
| + |
| + b.mu.Lock() |
| + acquires, releases := b.acquires, b.releases |
| + b.mu.Unlock() |
| + if acquires != requests || releases != requests { |
| + t.Fatalf("acquires=%d releases=%d for %d requests — a failed wake was not singleflight (#184)", |
| + acquires, releases, requests) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("failed wakes left entries behind") |
| + } |
| +} |
| + |
| // A restore failure never reaches the seed: seeding a host whose data we |
| // couldn't materialize is precisely the "half-seeded instance" case. |
| func TestActivatorRestoreFailureSkipsSeed(t *testing.T) { |