package repolock import "sync" import "sync/atomic" import "testing" import "time" // TestLockUnlock verifies the basic lock/unlock cycle completes without error. func TestLockUnlock(t *testing.T) { var m *Manager var unlock func() m = NewManager() unlock = m.Lock("repo-a") unlock() } // TestLockIsExclusive verifies that a second goroutine blocks until the first // releases the lock, and that the critical section is never entered concurrently. func TestLockIsExclusive(t *testing.T) { var m *Manager var inside int32 var wg sync.WaitGroup var violation int32 m = NewManager() var run func() run = func() { defer wg.Done() var unlock func() unlock = m.Lock("repo-x") defer unlock() if atomic.AddInt32(&inside, 1) > 1 { atomic.StoreInt32(&violation, 1) } time.Sleep(2 * time.Millisecond) atomic.AddInt32(&inside, -1) } wg.Add(10) var i int for i = 0; i < 10; i++ { go run() } wg.Wait() if violation != 0 { t.Fatal("lock was not exclusive: multiple goroutines inside critical section simultaneously") } } // TestLockDifferentKeysDoNotBlock verifies that locks on distinct repo IDs are // independent and do not block each other. func TestLockDifferentKeysDoNotBlock(t *testing.T) { var m *Manager var ch chan struct{} var unlock func() m = NewManager() ch = make(chan struct{}) unlock = m.Lock("repo-a") go func() { // locking a different key must not block var u func() u = m.Lock("repo-b") close(ch) u() }() select { case <-ch: // good case <-time.After(500 * time.Millisecond): t.Fatal("Lock on different key blocked unexpectedly") } unlock() } // TestTryLockSucceedsWhenFree verifies TryLock acquires the lock when available. func TestTryLockSucceedsWhenFree(t *testing.T) { var m *Manager var unlock func() var ok bool m = NewManager() unlock, ok = m.TryLock("repo-a") if !ok { t.Fatal("TryLock should succeed on a free lock") } unlock() } // TestTryLockFailsWhenHeld verifies TryLock returns false when another goroutine // holds the lock. func TestTryLockFailsWhenHeld(t *testing.T) { var m *Manager var unlock func() var unlock2 func() var ok bool m = NewManager() unlock = m.Lock("repo-a") defer unlock() unlock2, ok = m.TryLock("repo-a") if ok { unlock2() t.Fatal("TryLock should fail when lock is already held") } } // TestTryLockSucceedsAfterRelease verifies TryLock succeeds once the prior // holder releases the lock. func TestTryLockSucceedsAfterRelease(t *testing.T) { var m *Manager var unlock func() var ok bool m = NewManager() unlock = m.Lock("repo-a") unlock() // release immediately unlock, ok = m.TryLock("repo-a") if !ok { t.Fatal("TryLock should succeed after the lock is released") } unlock() } // TestLockManyAcquiresAll verifies LockMany acquires all listed locks. func TestLockManyAcquiresAll(t *testing.T) { var m *Manager var unlock func() var ok bool var u func() m = NewManager() unlock = m.LockMany([]string{"repo-a", "repo-b", "repo-c"}) defer unlock() // all three should be held — TryLock should fail for each u, ok = m.TryLock("repo-a") if ok { u(); t.Error("repo-a should be locked") } u, ok = m.TryLock("repo-b") if ok { u(); t.Error("repo-b should be locked") } u, ok = m.TryLock("repo-c") if ok { u(); t.Error("repo-c should be locked") } } // TestLockManyReleasesAll verifies LockMany's unlock function releases all locks. func TestLockManyReleasesAll(t *testing.T) { var m *Manager var unlock func() var ok bool var u func() m = NewManager() unlock = m.LockMany([]string{"repo-a", "repo-b"}) unlock() u, ok = m.TryLock("repo-a") if !ok { t.Error("repo-a should be free after unlock") } else { u() } u, ok = m.TryLock("repo-b") if !ok { t.Error("repo-b should be free after unlock") } else { u() } } // TestLockManyDeadlockFree verifies that two goroutines acquiring overlapping // sets in opposite order do not deadlock. LockMany sorts keys before acquiring, // so both goroutines always acquire in the same order. func TestLockManyDeadlockFree(t *testing.T) { var m *Manager var wg sync.WaitGroup var done int32 m = NewManager() var runAB func() runAB = func() { defer wg.Done() var unlock func() unlock = m.LockMany([]string{"repo-a", "repo-b"}) time.Sleep(1 * time.Millisecond) unlock() atomic.AddInt32(&done, 1) } var runBA func() runBA = func() { defer wg.Done() var unlock func() unlock = m.LockMany([]string{"repo-b", "repo-a"}) // reverse order — must still sort time.Sleep(1 * time.Millisecond) unlock() atomic.AddInt32(&done, 1) } wg.Add(2) go runAB() go runBA() var finished bool var ch chan struct{} ch = make(chan struct{}) go func() { wg.Wait() close(ch) }() select { case <-ch: finished = true case <-time.After(2 * time.Second): finished = false } if !finished { t.Fatal("deadlock detected: LockMany([A,B]) vs LockMany([B,A]) did not complete") } if done != 2 { t.Fatalf("expected both goroutines to finish, got %d", done) } } // TestTryLockManySucceedsWhenAllFree verifies TryLockMany acquires all locks // when none are held. func TestTryLockManySucceedsWhenAllFree(t *testing.T) { var m *Manager var unlock func() var ok bool m = NewManager() unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"}) if !ok { t.Fatal("TryLockMany should succeed when all locks are free") } unlock() } // TestTryLockManyFailsAndReleasesWhenOneLockHeld verifies that TryLockMany // releases any already-acquired locks when it fails to acquire one, leaving // no locks held on failure. func TestTryLockManyFailsAndReleasesWhenOneLockHeld(t *testing.T) { var m *Manager var holdUnlock func() var unlock func() var ok bool var u func() m = NewManager() // hold repo-b holdUnlock = m.Lock("repo-b") defer holdUnlock() // TryLockMany([repo-a, repo-b, repo-c]) should fail because repo-b is held. // repo-a must be released as a result (no partial hold). unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"}) if ok { unlock() t.Fatal("TryLockMany should fail when one of the locks is held") } // repo-a must be free now — TryLock should succeed u, ok = m.TryLock("repo-a") if !ok { t.Fatal("repo-a should have been released by the failed TryLockMany") } u() // repo-c should also be free u, ok = m.TryLock("repo-c") if !ok { t.Fatal("repo-c should have been released by the failed TryLockMany") } u() } // TestLockManyDeduplicatesIDs verifies that passing duplicate IDs to LockMany // does not cause a self-deadlock. func TestLockManyDeduplicatesIDs(t *testing.T) { var m *Manager var unlock func() var done chan struct{} m = NewManager() done = make(chan struct{}) go func() { unlock = m.LockMany([]string{"repo-a", "repo-a", "repo-a"}) unlock() close(done) }() select { case <-done: // good case <-time.After(500 * time.Millisecond): t.Fatal("LockMany with duplicate IDs deadlocked") } } // TestLockEmptyKeyDoesNotPanic verifies that empty string keys are handled // safely and treated as a single canonical key. func TestLockEmptyKeyDoesNotPanic(t *testing.T) { var m *Manager var unlock func() m = NewManager() unlock = m.Lock("") unlock() } // TestTryLockManyEmptySlice verifies TryLockMany with no IDs succeeds and // the returned unlock is safe to call. func TestTryLockManyEmptySlice(t *testing.T) { var m *Manager var unlock func() var ok bool m = NewManager() unlock, ok = m.TryLockMany([]string{}) if !ok { t.Fatal("TryLockMany on empty slice should succeed") } if unlock != nil { unlock() } } // TestNilManagerIsNoop verifies all Manager methods on a nil receiver return // safely without panicking. func TestNilManagerIsNoop(t *testing.T) { var m *Manager // nil var unlock func() var ok bool unlock = m.Lock("repo-a") unlock() // should not panic unlock, ok = m.TryLock("repo-a") if !ok { t.Error("nil TryLock should report success (noop)") } unlock() unlock = m.LockMany([]string{"repo-a", "repo-b"}) unlock() unlock, ok = m.TryLockMany([]string{"repo-a"}) if !ok { t.Error("nil TryLockMany should report success (noop)") } if unlock != nil { unlock() } } // TestLockHighConcurrency stress-tests Lock under high goroutine count to // surface any data races (run with -race). func TestLockHighConcurrency(t *testing.T) { var m *Manager var wg sync.WaitGroup var counter int64 const goroutines = 100 m = NewManager() var i int for i = 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() var unlock func() unlock = m.Lock("shared-repo") defer unlock() atomic.AddInt64(&counter, 1) }() } wg.Wait() if counter != goroutines { t.Fatalf("expected counter=%d, got %d", goroutines, counter) } } // TestTryLockManyHighConcurrency stress-tests TryLockMany under contention. // Some attempts will fail (lock held); all must either fully succeed or fully // fail with no partial holds. func TestTryLockManyHighConcurrency(t *testing.T) { var m *Manager var wg sync.WaitGroup var successes int64 const goroutines = 50 m = NewManager() var i int for i = 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() var unlock func() var ok bool unlock, ok = m.TryLockMany([]string{"repo-x", "repo-y"}) if ok { atomic.AddInt64(&successes, 1) time.Sleep(1 * time.Millisecond) unlock() } }() } wg.Wait() if successes == 0 { t.Fatal("expected at least one TryLockMany to succeed") } }