192 lines
5.0 KiB
Go
192 lines
5.0 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAcquireGHSlot_BlocksWhenSaturated(t *testing.T) {
|
|
restore := setGHSemaphoreForTest(2)
|
|
defer restore()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Hold both slots.
|
|
releaseA, errA := acquireGHSlot(ctx)
|
|
if errA != nil {
|
|
t.Fatalf("acquire A: %v", errA)
|
|
}
|
|
releaseB, errB := acquireGHSlot(ctx)
|
|
if errB != nil {
|
|
t.Fatalf("acquire B: %v", errB)
|
|
}
|
|
|
|
// Third caller must block until a slot frees.
|
|
acquired := make(chan struct{})
|
|
var releaseC func()
|
|
go func() {
|
|
var err error
|
|
releaseC, err = acquireGHSlot(ctx)
|
|
if err != nil {
|
|
t.Errorf("acquire C: unexpected err %v", err)
|
|
return
|
|
}
|
|
close(acquired)
|
|
}()
|
|
|
|
select {
|
|
case <-acquired:
|
|
t.Fatalf("third acquire returned without waiting; pool size 2 not enforced")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
releaseA()
|
|
select {
|
|
case <-acquired:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("third acquire did not return after slot freed")
|
|
}
|
|
releaseB()
|
|
releaseC()
|
|
}
|
|
|
|
func TestAcquireGHSlot_ContextCancelledWhileQueued(t *testing.T) {
|
|
restore := setGHSemaphoreForTest(1)
|
|
defer restore()
|
|
|
|
// Saturate the single slot.
|
|
holdCtx, holdCancel := context.WithCancel(context.Background())
|
|
defer holdCancel()
|
|
release, err := acquireGHSlot(holdCtx)
|
|
if err != nil {
|
|
t.Fatalf("acquire hold: %v", err)
|
|
}
|
|
defer release()
|
|
|
|
// Queue a second caller with a short-lived context.
|
|
waitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
defer cancel()
|
|
relQueued, err := acquireGHSlot(waitCtx)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("expected context.DeadlineExceeded, got %v", err)
|
|
}
|
|
// Release fn must be safe to call even on error so callers can `defer` it
|
|
// unconditionally without leaking the held slot.
|
|
relQueued()
|
|
}
|
|
|
|
func TestAcquireGHSlot_ConcurrentCapEnforced(t *testing.T) {
|
|
const cap = 4
|
|
const callers = 16
|
|
restore := setGHSemaphoreForTest(cap)
|
|
defer restore()
|
|
|
|
var inFlight, peak int32
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < callers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
release, err := acquireGHSlot(context.Background())
|
|
if err != nil {
|
|
t.Errorf("acquire: %v", err)
|
|
return
|
|
}
|
|
defer release()
|
|
cur := atomic.AddInt32(&inFlight, 1)
|
|
// Track peak concurrency.
|
|
for {
|
|
old := atomic.LoadInt32(&peak)
|
|
if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
atomic.AddInt32(&inFlight, -1)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if got := atomic.LoadInt32(&peak); got > int32(cap) {
|
|
t.Errorf("peak concurrency %d exceeded cap %d", got, cap)
|
|
}
|
|
}
|
|
|
|
// TestAcquireGHSlot_DoubleReleaseIsNoOp regression-tests that a release
|
|
// closure called twice does not transfer a slot to another in-flight
|
|
// holder. Without sync.Once around the release, a double-defer (or a
|
|
// refactor that calls release in both an error path and via defer)
|
|
// would slowly drain the effective cap to zero.
|
|
func TestAcquireGHSlot_DoubleReleaseIsNoOp(t *testing.T) {
|
|
restore := setGHSemaphoreForTest(2)
|
|
defer restore()
|
|
|
|
ctx := context.Background()
|
|
releaseA, err := acquireGHSlot(ctx)
|
|
if err != nil {
|
|
t.Fatalf("acquire A: %v", err)
|
|
}
|
|
releaseB, err := acquireGHSlot(ctx)
|
|
if err != nil {
|
|
t.Fatalf("acquire B: %v", err)
|
|
}
|
|
|
|
// Caller A releases twice — buggy code path. With sync.Once the second
|
|
// call is a no-op; without it the second call would steal B's slot.
|
|
releaseA()
|
|
releaseA()
|
|
|
|
// Pool should still have exactly one slot held (by B). A new acquire
|
|
// must succeed (slot vacated by A) and a second concurrent acquire
|
|
// must block (B still holds the other slot).
|
|
releaseC, err := acquireGHSlot(ctx)
|
|
if err != nil {
|
|
t.Fatalf("acquire C after double release: %v", err)
|
|
}
|
|
defer releaseC()
|
|
|
|
blocked := make(chan struct{})
|
|
go func() {
|
|
release, err := acquireGHSlot(ctx)
|
|
if err == nil {
|
|
release()
|
|
}
|
|
close(blocked)
|
|
}()
|
|
select {
|
|
case <-blocked:
|
|
t.Errorf("third acquire returned without waiting — double release leaked a slot")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
releaseB()
|
|
<-blocked
|
|
}
|
|
|
|
// TestAcquireGHSlot_PreCancelledContextReturnsImmediately asserts the
|
|
// fast path: when ctx is already done at entry, we don't race the
|
|
// select against a free slot. Without the precheck the select would
|
|
// pick randomly between sem<- and ctx.Done(), letting a cancelled
|
|
// caller still acquire then fail downstream.
|
|
func TestAcquireGHSlot_PreCancelledContextReturnsImmediately(t *testing.T) {
|
|
restore := setGHSemaphoreForTest(8)
|
|
defer restore()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
for i := 0; i < 100; i++ {
|
|
release, err := acquireGHSlot(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("iter %d: err = %v, want context.Canceled", i, err)
|
|
}
|
|
release() // must be safe even on the error path
|
|
}
|
|
}
|
|
|
|
// Cap parsing tests live in internal/common/subproc/shared_test.go now
|
|
// that the env var, default, and resolver are owned by that package.
|