@@ -23,21 +23,23 @@ import (
2323 "testing/synctest"
2424 "time"
2525
26+ "github.com/stretchr/testify/assert"
2627 "github.com/stretchr/testify/require"
2728)
2829
29- func withTimeout (t * testing.T , f func ()) {
30+ // Helper to create a locked locker for testing.
31+ func newLockedLocker (t * testing.T , key string ) * Locker {
3032 t .Helper ()
31- done := make ( chan struct {} )
32- go func () {
33- f ()
34- close ( done )
35- }()
36- select {
37- case <- time . After ( 1 * time . Second ):
38- t . Fatal ( "timed out" )
39- case <- done :
40- }
33+ l := New ( )
34+ require . NoError ( t , l . Lock ( context . Background (), key ))
35+ return l
36+ }
37+
38+ // Helper to verify lock exists with expected waiters.
39+ func requireLockState ( t * testing. T , l * Locker , key string , expectedWaiters int32 ) {
40+ t . Helper ( )
41+ require . Contains ( t , l . locks , key , "lock %q should exist" , key )
42+ require . EqualValues ( t , expectedWaiters , l . locks [ key ]. count (), "lock %q should have %d waiters" , key , expectedWaiters )
4143}
4244
4345func TestNew (t * testing.T ) {
@@ -58,129 +60,268 @@ func TestLockWithCounter(t *testing.T) {
5860}
5961
6062func TestLockerLock (t * testing.T ) {
61- l := New ()
62- require .NoError (t , l .Lock (context .Background (), "test" ))
63- lwc := l .locks ["test" ]
64-
65- require .EqualValues (t , 0 , lwc .count ())
66-
67- var wg sync.WaitGroup
68- chDone := make (chan struct {})
69- wg .Go (func () {
63+ synctest .Test (t , func (t * testing.T ) {
64+ l := New ()
7065 require .NoError (t , l .Lock (context .Background (), "test" ))
71- close (chDone )
72- })
66+ lockState := l .locks ["test" ]
7367
74- chWaiting := make (chan struct {})
75- go func () {
76- for range time .Tick (1 * time .Millisecond ) {
77- if lwc .count () == 1 {
78- close (chWaiting )
79- break
80- }
81- }
82- }()
68+ // Verify initial state: lock is held, no waiters
69+ require .EqualValues (t , 0 , lockState .count ())
8370
84- withTimeout (t , func () {
85- <- chWaiting
86- })
71+ // Start a goroutine that will wait for the lock
72+ lockAcquired := make (chan struct {})
73+ go func (t * testing.T ) {
74+ assert .NoError (t , l .Lock (context .Background (), "test" ))
75+ close (lockAcquired )
76+ }(t )
8777
88- select {
89- case <- chDone :
90- t .Fatal ("lock should not have returned while it was still held" )
91- default :
92- }
78+ // Wait for goroutine to enter waiting state (deterministic with synctest)
79+ synctest .Wait ()
80+ require .EqualValues (t , 1 , lockState .count (), "should have one waiter" )
9381
94- l .Unlock ("test" )
82+ // Verify the waiting goroutine hasn't acquired the lock yet
83+ select {
84+ case <- lockAcquired :
85+ t .Fatal ("lock should not have been acquired while still held" )
86+ default :
87+ }
9588
96- withTimeout (t , func () {
97- <- chDone
98- })
89+ // Release the lock - waiting goroutine should now acquire it
90+ l .Unlock ("test" )
91+ <- lockAcquired
92+ synctest .Wait ()
9993
100- require .EqualValues (t , 0 , lwc .count ())
94+ // Verify final state: no waiters
95+ require .EqualValues (t , 0 , lockState .count ())
96+ })
10197}
10298
10399func TestLockerUnlock (t * testing.T ) {
104- l := New ()
105-
106- require .NoError (t , l .Lock (context .Background (), "test" ))
107- l .Unlock ("test" )
100+ synctest .Test (t , func (t * testing.T ) {
101+ l := New ()
108102
109- require .PanicsWithValue (t , "no such lock: test", func () {
103+ require .NoError (t , l . Lock ( context . Background (), " test"))
110104 l .Unlock ("test" )
111- })
112105
113- withTimeout (t , func () {
114- require .NoError (t , l .Lock (context .Background (), "test" ))
106+ require .PanicsWithValue (t , "no such lock: test" , func () {
107+ l .Unlock ("test" )
108+ })
109+
110+ go func () {
111+ assert .NoError (t , l .Lock (context .Background (), "test" ))
112+ }()
113+ synctest .Wait ()
115114 })
116115}
117116
118117func TestLockerConcurrency (t * testing.T ) {
119- l := New ()
118+ synctest .Test (t , func (t * testing.T ) {
119+ l := New ()
120120
121- var wg sync.WaitGroup
122- for range 10_000 {
123- wg .Go (func () {
124- require .NoError (t , l .Lock (context .Background (), "test" ))
125- // If there is a concurrency issue, it will very likely become visible here.
126- l .Unlock ("test" )
127- })
128- }
121+ var wg sync.WaitGroup
122+ for range 10_000 {
123+ wg .Go (func () {
124+ require .NoError (t , l .Lock (context .Background (), "test" ))
125+ // If there is a concurrency issue, it will very likely become visible here.
126+ l .Unlock ("test" )
127+ })
128+ }
129129
130- withTimeout (t , wg .Wait )
130+ wg .Wait ()
131+ synctest .Wait ()
131132
132- // Since everything has unlocked the map should be empty.
133- require .Empty (t , l .locks )
133+ // Since everything has unlocked the map should be empty.
134+ require .Empty (t , l .locks )
135+ })
134136}
135137
136138func TestLockerContextCanceled (t * testing.T ) {
137- ctx , cancel := context .WithCancel (context .Background ())
138- cancel ()
139+ synctest .Test (t , func (t * testing.T ) {
140+ ctx , cancel := context .WithCancel (context .Background ())
141+ cancel ()
139142
140- l := New ()
143+ l := New ()
141144
142- withTimeout (t , func () {
143145 err := l .Lock (ctx , "test" )
144146 require .ErrorIs (t , context .Canceled , err )
147+ synctest .Wait ()
145148 })
146149}
147150
148151func TestLockerContextDeadlineExceeded (t * testing.T ) {
149- ctx , cancel := context .WithTimeout (context .Background (), 1 * time .Millisecond )
150- defer cancel ()
152+ synctest .Test (t , func (t * testing.T ) {
153+ ctx , cancel := context .WithTimeout (context .Background (), 1 * time .Millisecond )
154+ defer cancel ()
151155
152- l := New ()
153- require .NoError (t , l .Lock (ctx , "test" ))
156+ l := New ()
157+ require .NoError (t , l .Lock (ctx , "test" ))
154158
155- withTimeout (t , func () {
156- err := l .Lock (ctx , "test" )
157- require .ErrorIs (t , context .DeadlineExceeded , err )
158- })
159+ lockFailed := make (chan struct {})
160+ go func () {
161+ err := l .Lock (ctx , "test" )
162+ assert .ErrorIs (t , context .DeadlineExceeded , err )
163+ close (lockFailed )
164+ }()
159165
160- require .NotPanics (t , func () { l .Unlock ("test" ) })
166+ <- lockFailed
167+ synctest .Wait ()
168+
169+ require .NotPanics (t , func () { l .Unlock ("test" ) })
170+ })
161171}
162172
163- // TestLockerConcurrencyWithSynctest uses testing/synctest for deterministic concurrent testing .
164- func TestLockerConcurrencyWithSynctest (t * testing.T ) {
173+ // TestLockerMultipleKeysIsolation verifies that locks for different keys don't interfere with each other .
174+ func TestLockerMultipleKeysIsolation (t * testing.T ) {
165175 synctest .Test (t , func (t * testing.T ) {
166176 l := New ()
167177
178+ // Lock key1
179+ require .NoError (t , l .Lock (context .Background (), "key1" ))
180+ requireLockState (t , l , "key1" , 0 )
181+
182+ // Should be able to lock key2 immediately (different key)
183+ key2Acquired := make (chan struct {})
184+ go func () {
185+ assert .NoError (t , l .Lock (context .Background (), "key2" ))
186+ close (key2Acquired )
187+ }()
188+
189+ <- key2Acquired
190+ synctest .Wait ()
191+
192+ // Both keys should be locked
193+ requireLockState (t , l , "key1" , 0 )
194+ requireLockState (t , l , "key2" , 0 )
195+
196+ // Unlock both
197+ l .Unlock ("key1" )
198+ l .Unlock ("key2" )
199+ synctest .Wait ()
200+
201+ // Both locks should be cleaned up
202+ require .Empty (t , l .locks )
203+ })
204+ }
205+
206+ // TestLockerMultipleWaiters verifies that multiple goroutines can wait for the same lock.
207+ func TestLockerMultipleWaiters (t * testing.T ) {
208+ synctest .Test (t , func (t * testing.T ) {
209+ l := newLockedLocker (t , "test" )
210+ const numWaiters = 10
211+
168212 var wg sync.WaitGroup
169- // Use a smaller number for synctest to keep test execution time reasonable
170- for range 100 {
213+ results := make ([]bool , numWaiters )
214+
215+ // Start multiple waiters
216+ for i := range numWaiters {
171217 wg .Go (func () {
172- require .NoError (t , l .Lock (context .Background (), "test" ))
173- // If there is a concurrency issue, it will very likely become visible here.
218+ //nolint:testifylint // Using assert in goroutine per go-require rule
219+ assert .NoError (t , l .Lock (context .Background (), "test" ))
220+ results [i ] = true
174221 l .Unlock ("test" )
175222 })
176223 }
177224
178- // Wait for all goroutines to complete
225+ // Wait for all goroutines to enter waiting state
226+ synctest .Wait ()
227+ require .EqualValues (t , numWaiters , l .locks ["test" ].count (), "should have %d waiters" , numWaiters )
228+
229+ // Release lock - all waiters should acquire it sequentially
230+ l .Unlock ("test" )
179231 wg .Wait ()
180- // Ensure all goroutines in the bubble are durably blocked or finished
181232 synctest .Wait ()
182233
183- // Since everything has unlocked the map should be empty.
234+ // All should have succeeded
235+ for i := range numWaiters {
236+ require .True (t , results [i ], "waiter %d should have acquired lock" , i )
237+ }
238+
239+ // Lock should be cleaned up
240+ require .Empty (t , l .locks )
241+ })
242+ }
243+
244+ // TestLockerContextCanceledWhileWaiting verifies context cancellation that happens while waiting (not before).
245+ func TestLockerContextCanceledWhileWaiting (t * testing.T ) {
246+ synctest .Test (t , func (t * testing.T ) {
247+ l := newLockedLocker (t , "test" )
248+
249+ ctx , cancel := context .WithCancel (context .Background ())
250+
251+ lockCanceled := make (chan struct {})
252+ go func () {
253+ err := l .Lock (ctx , "test" )
254+ assert .ErrorIs (t , err , context .Canceled )
255+ close (lockCanceled )
256+ }()
257+
258+ // Wait for goroutine to enter waiting state
259+ synctest .Wait ()
260+ require .EqualValues (t , 1 , l .locks ["test" ].count (), "should have one waiter" )
261+
262+ // Cancel while waiting
263+ cancel ()
264+ <- lockCanceled
265+ synctest .Wait ()
266+
267+ // Lock should still exist (held by main goroutine)
268+ requireLockState (t , l , "test" , 0 )
269+
270+ // Unlock and verify cleanup
271+ l .Unlock ("test" )
272+ synctest .Wait ()
273+ require .Empty (t , l .locks )
274+ })
275+ }
276+
277+ // TestLockerReacquisition verifies that a lock can be re-acquired after unlock.
278+ func TestLockerReacquisition (t * testing.T ) {
279+ synctest .Test (t , func (t * testing.T ) {
280+ l := New ()
281+
282+ // Lock, unlock, lock again
283+ require .NoError (t , l .Lock (context .Background (), "test" ))
284+ l .Unlock ("test" )
285+
286+ require .NoError (t , l .Lock (context .Background (), "test" ))
287+ l .Unlock ("test" )
288+
289+ // Lock should be cleaned up
290+ require .Empty (t , l .locks )
291+ })
292+ }
293+
294+ // TestLockerCleanupAfterAllWaitersCancel verifies that lock is cleaned up when all waiters cancel.
295+ func TestLockerCleanupAfterAllWaitersCancel (t * testing.T ) {
296+ synctest .Test (t , func (t * testing.T ) {
297+ l := newLockedLocker (t , "test" )
298+
299+ ctx , cancel := context .WithCancel (context .Background ())
300+
301+ const numWaiters = 5
302+ var wg sync.WaitGroup
303+ for range numWaiters {
304+ wg .Go (func () {
305+ err := l .Lock (ctx , "test" )
306+ require .ErrorIs (t , err , context .Canceled )
307+ })
308+ }
309+
310+ // Wait for all goroutines to enter waiting state
311+ synctest .Wait ()
312+ require .EqualValues (t , numWaiters , l .locks ["test" ].count (), "should have %d waiters" , numWaiters )
313+
314+ // Cancel all waiters
315+ cancel ()
316+ wg .Wait ()
317+ synctest .Wait ()
318+
319+ // Lock should still exist (held by main goroutine)
320+ requireLockState (t , l , "test" , 0 )
321+
322+ // Unlock - now lock should be cleaned up
323+ l .Unlock ("test" )
324+ synctest .Wait ()
184325 require .Empty (t , l .locks )
185326 })
186327}
0 commit comments