Files

420 lines
9.3 KiB
Go

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")
}
}