package turngate import ( "sync" "sync/atomic" "testing" "time" ) func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) { g := New() if !g.Acquire("s1", 0) { t.Fatal("first non-blocking Acquire should succeed on a free session") } // A second non-blocking Acquire (a background resume) must skip, not queue. if g.Acquire("s1", 0) { t.Fatal("second non-blocking Acquire should fail while a turn is active") } // A different session is independent. if !g.Acquire("s2", 0) { t.Fatal("Acquire on a different session should succeed") } g.Release("s2") g.Release("s1") // After Release, the session is free again. if !g.Acquire("s1", 0) { t.Fatal("Acquire should succeed again after Release") } g.Release("s1") } func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) { g := New() if !g.Acquire("s1", 0) { t.Fatal("first Acquire should succeed") } got := make(chan bool, 1) go func() { got <- g.Acquire("s1", 2*time.Second) }() select { case <-got: t.Fatal("blocking Acquire should wait, not return before Release") case <-time.After(50 * time.Millisecond): // expected: still waiting } g.Release("s1") select { case ok := <-got: if !ok { t.Fatal("blocking Acquire should succeed after Release") } case <-time.After(time.Second): t.Fatal("blocking Acquire did not return after Release") } g.Release("s1") } func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) { g := New() g.Acquire("s1", 0) // hold the permit start := time.Now() if g.Acquire("s1", 60*time.Millisecond) { t.Fatal("Acquire should time out while permit is held") } if elapsed := time.Since(start); elapsed < 50*time.Millisecond { t.Fatalf("Acquire returned too fast (%v); expected to wait ~60ms", elapsed) } g.Release("s1") } // TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent // background acquirers on the SAME session, exactly one runs at a time. This is // the property that prevents two turns interleaving tool calls. func TestTurnGate_SingleFlightConcurrent(t *testing.T) { g := New() const n = 50 var inFlight, maxInFlight int64 var runs int64 var wg sync.WaitGroup wg.Add(n) start := make(chan struct{}) for i := 0; i < n; i++ { go func() { defer wg.Done() <-start if !g.Acquire("shared", 0) { // background-style: skip if busy return } defer g.Release("shared") cur := atomic.AddInt64(&inFlight, 1) for { m := atomic.LoadInt64(&maxInFlight) if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) { break } } atomic.AddInt64(&runs, 1) time.Sleep(2 * time.Millisecond) atomic.AddInt64(&inFlight, -1) }() } close(start) wg.Wait() if maxInFlight != 1 { t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight) } if runs == 0 { t.Fatal("expected at least one turn to run") } }