package pipeline import ( "context" "sync" "sync/atomic" "testing" "time" ) // TestRateGuardLimitsConcurrency proves the semaphore REALLY bounds concurrency (WS6 precondition, // ревью-2 F4): with max_concurrency=2, no more than 2 goroutines are ever inside the guarded region // at once, even under 20 racing callers. A broken guard (missing/over-sized semaphore) trips the // observed-max assertion. func TestRateGuardLimitsConcurrency(t *testing.T) { const cap, workers = 2, 20 g := newRateGuard(cap, 0) var inFlight, maxSeen int64 var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() rel, err := g.acquire(context.Background()) if err != nil { t.Errorf("acquire: %v", err) return } cur := atomic.AddInt64(&inFlight, 1) for { m := atomic.LoadInt64(&maxSeen) if cur <= m || atomic.CompareAndSwapInt64(&maxSeen, m, cur) { break } } time.Sleep(time.Millisecond) // widen the window a concurrency bug would exploit atomic.AddInt64(&inFlight, -1) rel() }() } wg.Wait() if maxSeen > cap { t.Fatalf("rate-guard admitted %d concurrent callers, cap is %d", maxSeen, cap) } if maxSeen == 0 { t.Fatalf("no caller was observed in-flight — the guard never admitted anyone") } } // TestRateGuardNilAndUnlimitedAreNoOps: a nil guard and a zero-config guard both admit instantly and // never block (the sequential path / an unconfigured model must cost nothing). func TestRateGuardNilAndUnlimitedAreNoOps(t *testing.T) { var nilGuard *rateGuard rel, err := nilGuard.acquire(context.Background()) if err != nil { t.Fatalf("nil guard acquire: %v", err) } rel() // must not panic unlimited := newRateGuard(0, 0) for i := 0; i < 100; i++ { rel, err := unlimited.acquire(context.Background()) if err != nil { t.Fatalf("unlimited acquire %d: %v", i, err) } rel() } } // TestRateGuardHonoursContextCancel: a caller blocked on a full semaphore unblocks on ctx cancel and // does NOT leak a slot (the wave dispatcher must be able to abort a run cleanly). func TestRateGuardHonoursContextCancel(t *testing.T) { g := newRateGuard(1, 0) rel, err := g.acquire(context.Background()) if err != nil { t.Fatalf("first acquire: %v", err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { r, err := g.acquire(ctx) // blocks: the one slot is held r() done <- err }() cancel() select { case err := <-done: if err != context.Canceled { t.Fatalf("blocked acquire on cancel = %v, want context.Canceled", err) } case <-time.After(time.Second): t.Fatal("acquire did not unblock on ctx cancel") } rel() // release the first; a fresh acquire must now succeed (no leaked slot) r2, err := g.acquire(context.Background()) if err != nil { t.Fatalf("acquire after release: %v", err) } r2() } // TestRateGuardPacingSpacesStarts: with min_interval set, two serial acquires are spaced by at least // the interval (best-effort pacing of call STARTS). func TestRateGuardPacingSpacesStarts(t *testing.T) { g := newRateGuard(0, 30*time.Millisecond) start := time.Now() r1, _ := g.acquire(context.Background()) r1() r2, _ := g.acquire(context.Background()) r2() if elapsed := time.Since(start); elapsed < 30*time.Millisecond { t.Fatalf("paced acquires took %v, want ≥30ms between starts", elapsed) } }