返回 DeepSeek-Reasonix
filelock.go
根目录 / internal / filelock / filelock.go
1 // Package filelock provides bounded, cross-process advisory file locks.
2 package filelock
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "path/filepath"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13 )
14
15 const retryInterval = 20 * time.Millisecond
16
17 // ErrHeld reports that another file descriptor currently owns the lock.
18 // Callers normally see their context error after Acquire's bounded retry loop.
19 var ErrHeld = errors.New("file lock held")
20
21 // localLock is a process-local reader-writer lock for one canonical path.
22 // refs counts acquirers currently between registry entry and release/timeout
23 // so the registry can reclaim entries when no one is waiting or holding —
24 // important for short-lived paths such as session-temp owner locks.
25 type localLock struct {
26 mu sync.Mutex
27 cond *sync.Cond
28 exclusive bool
29 readers int
30 waitingWriters int
31 refs int
32 }
33
34 var localRegistry = struct {
35 sync.Mutex
36 locks map[string]*localLock
37 }{locks: map[string]*localLock{}}
38
39 // Acquire obtains an exclusive lock on path until the returned release
40 // function is called. It serializes both goroutines in this process and other
41 // Reasonix processes, and never waits past ctx's deadline.
42 func Acquire(ctx context.Context, path string) (func(), error) {
43 return acquire(ctx, path, 0, ModeExclusive)
44 }
45
46 // AcquireMode obtains a lock in exclusive or shared mode.
47 func AcquireMode(ctx context.Context, path string, mode Mode) (func(), error) {
48 return acquire(ctx, path, 0, mode)
49 }
50
51 // AcquireModeWithKey uses localKey for the process-local queue while opening
52 // path for the cross-process lock. Callers with filesystem identity knowledge
53 // use this to make aliases share a queue without using a comparison key for IO.
54 func AcquireModeWithKey(ctx context.Context, path, localKey string, mode Mode) (func(), error) {
55 if localKey == "" {
56 return nil, errors.New("file lock local key is empty")
57 }
58 return acquireWithKey(ctx, path, localKey, 0, mode)
59 }
60
61 // AcquireWithExternalTimeout obtains an exclusive lock while keeping the
62 // in-process queue and cross-process file-lock budgets separate. ctx bounds
63 // only the wait for another goroutine in this process; externalTimeout starts
64 // after that queue is acquired and bounds retries against other processes.
65 func AcquireWithExternalTimeout(ctx context.Context, path string, externalTimeout time.Duration) (func(), error) {
66 if externalTimeout <= 0 {
67 return nil, errors.New("external file lock timeout must be positive")
68 }
69 return acquire(ctx, path, externalTimeout, ModeExclusive)
70 }
71
72 // AcquireWithExternalTimeoutAndKey is AcquireWithExternalTimeout with an
73 // explicit process-local identity key.
74 func AcquireWithExternalTimeoutAndKey(ctx context.Context, path, localKey string, externalTimeout time.Duration) (func(), error) {
75 if externalTimeout <= 0 {
76 return nil, errors.New("external file lock timeout must be positive")
77 }
78 if localKey == "" {
79 return nil, errors.New("file lock local key is empty")
80 }
81 return acquireWithKey(ctx, path, localKey, externalTimeout, ModeExclusive)
82 }
83
84 func acquire(ctx context.Context, path string, externalTimeout time.Duration, mode Mode) (func(), error) {
85 return acquireWithKey(ctx, path, "", externalTimeout, mode)
86 }
87
88 func acquireWithKey(ctx context.Context, path, localKey string, externalTimeout time.Duration, mode Mode) (func(), error) {
89 if ctx == nil {
90 ctx = context.Background()
91 }
92 lockPath, err := canonicalLockPath(path)
93 if err != nil {
94 return nil, err
95 }
96 key := localKey
97 if key == "" {
98 key = lockPath
99 }
100 releaseLocal, err := acquireLocal(ctx, key, mode)
101 if err != nil {
102 return nil, err
103 }
104 fileCtx := ctx
105 cancel := func() {}
106 if externalTimeout > 0 {
107 fileCtx, cancel = context.WithTimeout(context.Background(), externalTimeout)
108 }
109 defer cancel()
110
111 for {
112 releaseFile, err := tryLockFileMode(lockPath, mode)
113 if err == nil {
114 var once sync.Once
115 return func() {
116 once.Do(func() {
117 releaseFile()
118 releaseLocal()
119 })
120 }, nil
121 }
122 if !errors.Is(err, ErrHeld) {
123 releaseLocal()
124 return nil, fmt.Errorf("acquire file lock: %w", err)
125 }
126 timer := time.NewTimer(retryInterval)
127 select {
128 case <-timer.C:
129 case <-fileCtx.Done():
130 if !timer.Stop() {
131 select {
132 case <-timer.C:
133 default:
134 }
135 }
136 releaseLocal()
137 return nil, fmt.Errorf("acquire file lock: %w", fileCtx.Err())
138 }
139 }
140 }
141
142 // TryAcquire attempts a non-blocking exclusive lock. It returns ErrHeld when
143 // another holder (in this process or another) currently owns the lock.
144 func TryAcquire(path string) (func(), error) {
145 return TryAcquireMode(path, ModeExclusive)
146 }
147
148 // TryAcquireMode attempts a non-blocking lock in exclusive or shared mode.
149 func TryAcquireMode(path string, mode Mode) (func(), error) {
150 return tryAcquireModeWithKey(path, "", mode)
151 }
152
153 // TryAcquireModeWithKey is the non-blocking form of AcquireModeWithKey.
154 func TryAcquireModeWithKey(path, localKey string, mode Mode) (func(), error) {
155 if localKey == "" {
156 return nil, errors.New("file lock local key is empty")
157 }
158 return tryAcquireModeWithKey(path, localKey, mode)
159 }
160
161 func tryAcquireModeWithKey(path, localKey string, mode Mode) (func(), error) {
162 lockPath, err := canonicalLockPath(path)
163 if err != nil {
164 return nil, err
165 }
166 key := localKey
167 if key == "" {
168 key = lockPath
169 }
170 releaseLocal, ok := tryAcquireLocal(key, mode)
171 if !ok {
172 return nil, ErrHeld
173 }
174
175 releaseFile, err := tryLockFileMode(lockPath, mode)
176 if err != nil {
177 releaseLocal()
178 if errors.Is(err, ErrHeld) {
179 return nil, ErrHeld
180 }
181 return nil, fmt.Errorf("try acquire file lock: %w", err)
182 }
183 var once sync.Once
184 return func() {
185 once.Do(func() {
186 releaseFile()
187 releaseLocal()
188 })
189 }, nil
190 }
191
192 func lookupLocal(key string) *localLock {
193 local := localRegistry.locks[key]
194 if local == nil {
195 local = &localLock{}
196 local.cond = sync.NewCond(&local.mu)
197 localRegistry.locks[key] = local
198 }
199 local.refs++
200 return local
201 }
202
203 func acquireLocal(ctx context.Context, key string, mode Mode) (func(), error) {
204 localRegistry.Lock()
205 local := lookupLocal(key)
206 localRegistry.Unlock()
207
208 stop := context.AfterFunc(ctx, func() {
209 local.mu.Lock()
210 local.cond.Broadcast()
211 local.mu.Unlock()
212 })
213 defer stop()
214
215 local.mu.Lock()
216 writer := mode == ModeExclusive
217 if writer {
218 local.waitingWriters++
219 }
220 for {
221 if ctx.Err() != nil {
222 if writer {
223 local.waitingWriters--
224 local.cond.Broadcast()
225 }
226 local.mu.Unlock()
227 dropLocalRef(key, local)
228 return nil, fmt.Errorf("acquire file lock: %w", ctx.Err())
229 }
230 if mode == ModeShared {
231 if !local.exclusive && local.waitingWriters == 0 {
232 local.readers++
233 local.mu.Unlock()
234 return releaseLocalFunc(key, local, mode), nil
235 }
236 } else if !local.exclusive && local.readers == 0 {
237 local.waitingWriters--
238 local.exclusive = true
239 local.mu.Unlock()
240 return releaseLocalFunc(key, local, mode), nil
241 }
242 local.cond.Wait()
243 }
244 }
245
246 func tryAcquireLocal(key string, mode Mode) (func(), bool) {
247 localRegistry.Lock()
248 local := lookupLocal(key)
249 localRegistry.Unlock()
250
251 local.mu.Lock()
252 if mode == ModeShared {
253 if local.exclusive || local.waitingWriters > 0 {
254 local.mu.Unlock()
255 dropLocalRef(key, local)
256 return nil, false
257 }
258 local.readers++
259 local.mu.Unlock()
260 return releaseLocalFunc(key, local, mode), true
261 }
262 if local.exclusive || local.readers > 0 {
263 local.mu.Unlock()
264 dropLocalRef(key, local)
265 return nil, false
266 }
267 local.exclusive = true
268 local.mu.Unlock()
269 return releaseLocalFunc(key, local, mode), true
270 }
271
272 func dropLocalRef(key string, local *localLock) {
273 localRegistry.Lock()
274 local.refs--
275 if local.refs <= 0 {
276 local.refs = 0
277 delete(localRegistry.locks, key)
278 }
279 localRegistry.Unlock()
280 }
281
282 func releaseLocalFunc(key string, local *localLock, mode Mode) func() {
283 var once sync.Once
284 return func() {
285 once.Do(func() {
286 local.mu.Lock()
287 if mode == ModeShared {
288 if local.readers > 0 {
289 local.readers--
290 }
291 } else {
292 local.exclusive = false
293 }
294 local.cond.Broadcast()
295 local.mu.Unlock()
296 dropLocalRef(key, local)
297 })
298 }
299 }
300
301 // RegistrySizeForTest returns the number of live local-lock entries (tests).
302 func RegistrySizeForTest() int {
303 localRegistry.Lock()
304 defer localRegistry.Unlock()
305 return len(localRegistry.locks)
306 }
307
308 // HeldPathsForTest returns the canonical paths currently holding a lock. A
309 // package TestMain uses it to turn a leaked lease into a failure everywhere:
310 // Windows cannot remove a directory containing an open lock file, so a leak
311 // that only breaks t.TempDir cleanup there is otherwise invisible on POSIX.
312 func HeldPathsForTest() []string {
313 localRegistry.Lock()
314 defer localRegistry.Unlock()
315 held := make([]string, 0, len(localRegistry.locks))
316 for path := range localRegistry.locks {
317 held = append(held, path)
318 }
319 sort.Strings(held)
320 return held
321 }
322
323 func canonicalLockPath(path string) (string, error) {
324 path = strings.TrimSpace(path)
325 if path == "" {
326 return "", errors.New("file lock path is empty")
327 }
328 abs, err := filepath.Abs(path)
329 if err != nil {
330 return "", fmt.Errorf("resolve file lock path: %w", err)
331 }
332 return filepath.Clean(abs), nil
333 }
334
334 lines GO