返回 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 "runtime"
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 serializes goroutines in this process 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 token chan struct{}
27 refs int
28 }
29
30 var localRegistry = struct {
31 sync.Mutex
32 locks map[string]*localLock
33 }{locks: map[string]*localLock{}}
34
35 // Acquire obtains an exclusive lock on path until the returned release
36 // function is called. It serializes both goroutines in this process and other
37 // Reasonix processes, and never waits past ctx's deadline.
38 func Acquire(ctx context.Context, path string) (func(), error) {
39 if ctx == nil {
40 ctx = context.Background()
41 }
42 key, err := canonicalLockPath(path)
43 if err != nil {
44 return nil, err
45 }
46 local, releaseLocal, err := acquireLocal(ctx, key)
47 if err != nil {
48 return nil, err
49 }
50 _ = local
51
52 for {
53 releaseFile, err := tryLockFile(key)
54 if err == nil {
55 var once sync.Once
56 return func() {
57 once.Do(func() {
58 releaseFile()
59 releaseLocal()
60 })
61 }, nil
62 }
63 if !errors.Is(err, ErrHeld) {
64 releaseLocal()
65 return nil, fmt.Errorf("acquire file lock: %w", err)
66 }
67 timer := time.NewTimer(retryInterval)
68 select {
69 case <-timer.C:
70 case <-ctx.Done():
71 if !timer.Stop() {
72 select {
73 case <-timer.C:
74 default:
75 }
76 }
77 releaseLocal()
78 return nil, fmt.Errorf("acquire file lock: %w", ctx.Err())
79 }
80 }
81 }
82
83 // TryAcquire attempts a non-blocking exclusive lock. It returns ErrHeld when
84 // another holder (in this process or another) currently owns the lock.
85 func TryAcquire(path string) (func(), error) {
86 key, err := canonicalLockPath(path)
87 if err != nil {
88 return nil, err
89 }
90 local, releaseLocal, ok := tryAcquireLocal(key)
91 if !ok {
92 return nil, ErrHeld
93 }
94 _ = local
95
96 releaseFile, err := tryLockFile(key)
97 if err != nil {
98 releaseLocal()
99 if errors.Is(err, ErrHeld) {
100 return nil, ErrHeld
101 }
102 return nil, fmt.Errorf("try acquire file lock: %w", err)
103 }
104 var once sync.Once
105 return func() {
106 once.Do(func() {
107 releaseFile()
108 releaseLocal()
109 })
110 }, nil
111 }
112
113 func acquireLocal(ctx context.Context, key string) (*localLock, func(), error) {
114 localRegistry.Lock()
115 local := localRegistry.locks[key]
116 if local == nil {
117 local = &localLock{token: make(chan struct{}, 1)}
118 local.token <- struct{}{}
119 localRegistry.locks[key] = local
120 }
121 local.refs++
122 localRegistry.Unlock()
123
124 select {
125 case <-local.token:
126 return local, releaseLocalFunc(key, local), nil
127 case <-ctx.Done():
128 localRegistry.Lock()
129 local.refs--
130 if local.refs == 0 {
131 delete(localRegistry.locks, key)
132 }
133 localRegistry.Unlock()
134 return nil, nil, fmt.Errorf("acquire file lock: %w", ctx.Err())
135 }
136 }
137
138 func tryAcquireLocal(key string) (*localLock, func(), bool) {
139 localRegistry.Lock()
140 defer localRegistry.Unlock()
141
142 local := localRegistry.locks[key]
143 if local == nil {
144 local = &localLock{token: make(chan struct{}, 1)}
145 local.token <- struct{}{}
146 localRegistry.locks[key] = local
147 }
148 select {
149 case <-local.token:
150 local.refs++
151 return local, releaseLocalFunc(key, local), true
152 default:
153 // A newly created entry always succeeds above while the registry lock
154 // is held, so this is an existing lock held by another goroutine.
155 return nil, nil, false
156 }
157 }
158
159 func releaseLocalFunc(key string, local *localLock) func() {
160 var once sync.Once
161 return func() {
162 once.Do(func() {
163 localRegistry.Lock()
164 select {
165 case local.token <- struct{}{}:
166 default:
167 }
168 local.refs--
169 if local.refs <= 0 {
170 local.refs = 0
171 delete(localRegistry.locks, key)
172 }
173 localRegistry.Unlock()
174 })
175 }
176 }
177
178 // RegistrySizeForTest returns the number of live local-lock entries (tests).
179 func RegistrySizeForTest() int {
180 localRegistry.Lock()
181 defer localRegistry.Unlock()
182 return len(localRegistry.locks)
183 }
184
185 func canonicalLockPath(path string) (string, error) {
186 path = strings.TrimSpace(path)
187 if path == "" {
188 return "", errors.New("file lock path is empty")
189 }
190 abs, err := filepath.Abs(path)
191 if err != nil {
192 return "", fmt.Errorf("resolve file lock path: %w", err)
193 }
194 abs = filepath.Clean(abs)
195 if runtime.GOOS == "windows" {
196 abs = strings.ToLower(filepath.ToSlash(abs))
197 }
198 return abs, nil
199 }
200
200 lines GO