1package lock
2
3import (
4 "context"
5 "errors"
6 "path/filepath"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/require"
12)
13
14func TestTryFile_AcquiresWhenFree(t *testing.T) {
15 t.Parallel()
16 path := filepath.Join(t.TempDir(), "test.lock")
17
18 release, err := TryFile(path)
19 require.NoError(t, err)
20 require.NotNil(t, release)
21 release()
22}
23
24func TestTryFile_ReturnsErrContendedWhenHeld(t *testing.T) {
25 t.Parallel()
26 path := filepath.Join(t.TempDir(), "test.lock")
27
28 release, err := TryFile(path)
29 require.NoError(t, err)
30 t.Cleanup(release)
31
32 _, err = TryFile(path)
33 require.ErrorIs(t, err, ErrContended)
34}
35
36func TestTryFile_ReacquireAfterRelease(t *testing.T) {
37 t.Parallel()
38 path := filepath.Join(t.TempDir(), "test.lock")
39
40 release, err := TryFile(path)
41 require.NoError(t, err)
42 release()
43
44 release2, err := TryFile(path)
45 require.NoError(t, err)
46 t.Cleanup(release2)
47}
48
49func TestFile_AcquiresWhenFree(t *testing.T) {
50 t.Parallel()
51 path := filepath.Join(t.TempDir(), "test.lock")
52
53 release, err := File(context.Background(), path)
54 require.NoError(t, err)
55 t.Cleanup(release)
56}
57
58func TestFile_BlocksThenSucceeds(t *testing.T) {
59 t.Parallel()
60 path := filepath.Join(t.TempDir(), "test.lock")
61
62 release, err := TryFile(path)
63 require.NoError(t, err)
64
65 // Release the lock after a short delay so the blocking acquirer
66 // can complete within the test timeout.
67 go func() {
68 time.Sleep(150 * time.Millisecond)
69 release()
70 }()
71
72 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
73 defer cancel()
74 release2, err := File(ctx, path)
75 require.NoError(t, err, "should acquire after first releases")
76 release2()
77}
78
79func TestFile_RespectsContextDeadline(t *testing.T) {
80 t.Parallel()
81 path := filepath.Join(t.TempDir(), "test.lock")
82
83 release, err := TryFile(path)
84 require.NoError(t, err)
85 t.Cleanup(release)
86
87 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
88 defer cancel()
89 start := time.Now()
90 _, err = File(ctx, path)
91 elapsed := time.Since(start)
92 require.Error(t, err)
93 require.True(t, errors.Is(err, context.DeadlineExceeded), "expected deadline exceeded, got %v", err)
94 require.Less(t, elapsed, 1*time.Second, "should return promptly after deadline")
95}
96
97func TestFile_RespectsContextCancellation(t *testing.T) {
98 t.Parallel()
99 path := filepath.Join(t.TempDir(), "test.lock")
100
101 release, err := TryFile(path)
102 require.NoError(t, err)
103 t.Cleanup(release)
104
105 ctx, cancel := context.WithCancel(context.Background())
106 done := make(chan error, 1)
107 go func() {
108 _, err := File(ctx, path)
109 done <- err
110 }()
111
112 time.Sleep(50 * time.Millisecond)
113 cancel()
114
115 select {
116 case err := <-done:
117 require.ErrorIs(t, err, context.Canceled)
118 case <-time.After(2 * time.Second):
119 t.Fatal("File did not return after context cancellation")
120 }
121}
122
123// TestFile_ConcurrentAcquirers verifies that multiple blocking acquirers
124// queue up correctly: each gets the lock in turn, exactly one at a time.
125func TestFile_ConcurrentAcquirers(t *testing.T) {
126 t.Parallel()
127 path := filepath.Join(t.TempDir(), "test.lock")
128
129 const n = 5
130 var (
131 mu sync.Mutex
132 inside int
133 maxSeen int
134 finished int
135 )
136 var wg sync.WaitGroup
137 wg.Add(n)
138 for range n {
139 go func() {
140 defer wg.Done()
141 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
142 defer cancel()
143 release, err := File(ctx, path)
144 if err != nil {
145 t.Errorf("acquire failed: %v", err)
146 return
147 }
148 mu.Lock()
149 inside++
150 if inside > maxSeen {
151 maxSeen = inside
152 }
153 mu.Unlock()
154
155 time.Sleep(20 * time.Millisecond)
156
157 mu.Lock()
158 inside--
159 finished++
160 mu.Unlock()
161 release()
162 }()
163 }
164 wg.Wait()
165
166 require.Equal(t, n, finished)
167 require.Equal(t, 1, maxSeen, "lock must be mutually exclusive")
168}