返回 DeepSeek-Reasonix
scope.go
1 package workspacelease
2
3 import (
4 "context"
5 "crypto/sha256"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12
13 "reasonix/internal/filelock"
14 "reasonix/internal/pathidentity"
15 )
16
17 // All workspaces share a fixed set of hashed path-lock files. Hash collisions
18 // conservatively serialize unrelated files without allowing inode growth to
19 // track every path ever written.
20 const pathLockStripes = 4096
21
22 // Hierarchy locks make overlapping workspace roots intersect without making a
23 // whole-workspace writer take every path stripe. They are bounded separately so
24 // historical directory names cannot create an unbounded set of lock files.
25 const treeLockStripes = 4096
26
27 type pathSpec struct {
28 key string
29 compatibility string
30 display string
31 slot string
32 access string
33 info os.FileInfo
34 exists bool
35 }
36
37 // AcquireWriteForPath takes a legacy file-scoped hold released by ReleaseWrite
38 // or EndRun. New call sites should prefer HoldWriteForPath(s).
39 func (o *Owner) AcquireWriteForPath(ctx context.Context, abs string) error {
40 release, err := o.HoldWriteForPath(ctx, abs)
41 if err == nil && o != nil {
42 o.mu.Lock()
43 o.lease.legacy = append(o.lease.legacy, release)
44 o.mu.Unlock()
45 }
46 return err
47 }
48
49 // HoldWriteForPath acquires a file-scoped write hold.
50 func (o *Owner) HoldWriteForPath(ctx context.Context, abs string) (func(), error) {
51 return o.HoldWriteForPaths(ctx, []string{abs})
52 }
53
54 // HoldWriteForPaths acquires one atomic, stably ordered file-scoped hold. All
55 // paths are canonicalized and de-duplicated before any system lock is taken.
56 func (o *Owner) HoldWriteForPaths(ctx context.Context, paths []string) (func(), error) {
57 if o == nil {
58 return func() {}, nil
59 }
60 if ctx == nil {
61 ctx = context.Background()
62 }
63 specs, err := o.pathSpecs(paths)
64 if err != nil || len(specs) == 0 {
65 return o.HoldWrite(ctx)
66 }
67 keys, slots := specKeys(specs), specSlots(specs)
68 compatibilityRoots := o.compatibilityRoots(specs)
69 treeSlots := o.pathTreeSlots(specs)
70 scope, label := pathScope(specs)
71 for {
72 o.mu.Lock()
73 if id, hold := o.exclusiveHoldLocked(); hold != nil {
74 hold.refs++
75 o.cancelGraceLocked()
76 o.mu.Unlock()
77 return o.releaseHoldFunc(id), nil
78 }
79 if id, hold := o.coveringPathHoldLocked(keys); hold != nil {
80 hold.refs++
81 o.cancelGraceLocked()
82 o.mu.Unlock()
83 return o.releaseHoldFunc(id), nil
84 }
85 if o.lease.acquiring {
86 done := o.lease.acquireDone
87 o.mu.Unlock()
88 if err := waitForSignal(ctx, done); err != nil {
89 return func() {}, err
90 }
91 continue
92 }
93 o.beginAcquisitionLocked(scope, label, keys)
94 for !o.pathOrderAllowedLocked(slots) {
95 if o.activity.background > 0 {
96 o.armGraceLocked()
97 }
98 changed := o.lease.changed
99 o.mu.Unlock()
100 if err := waitForSignal(ctx, changed); err != nil {
101 o.mu.Lock()
102 o.finishAcquisitionLocked()
103 o.mu.Unlock()
104 return func() {}, err
105 }
106 o.mu.Lock()
107 }
108 o.mu.Unlock()
109
110 notified := false
111 release, err := o.acquirePathSystem(ctx, compatibilityRoots, treeSlots, slots, &notified)
112 if err == nil {
113 err = revalidatePathSpecs(specs)
114 if err != nil {
115 release()
116 }
117 }
118 o.mu.Lock()
119 var id uint64
120 if err == nil {
121 id = o.addHoldLocked(&systemHold{
122 refs: 1, scope: scope, keys: keys, slots: slots, release: release,
123 })
124 }
125 o.finishAcquisitionLocked()
126 releases := o.collectInactiveLocked()
127 o.mu.Unlock()
128 runReleases(releases)
129 if err != nil {
130 return func() {}, err
131 }
132 return o.releaseHoldFunc(id), nil
133 }
134 }
135
136 func (o *Owner) pathSpecs(paths []string) ([]pathSpec, error) {
137 seen := map[string]bool{}
138 specs := make([]pathSpec, 0, len(paths))
139 for _, path := range paths {
140 compatibility, display, err := canonicalFilePath(path)
141 key := normalizeIdentityPath(compatibility)
142 if err != nil || key == "" || !canonicalContains(o.canonical, key) {
143 if err == nil {
144 err = errors.New("path is outside the workspace")
145 }
146 return nil, err
147 }
148 if seen[key] {
149 continue
150 }
151 access := strings.TrimSpace(path)
152 if !filepath.IsAbs(access) {
153 access, err = filepath.Abs(access)
154 if err != nil {
155 return nil, err
156 }
157 }
158 access = filepath.Clean(access)
159 info, statErr := os.Stat(access)
160 exists := statErr == nil
161 if statErr != nil && !os.IsNotExist(statErr) {
162 return nil, statErr
163 }
164 seen[key] = true
165 specs = append(specs, pathSpec{
166 key: key, compatibility: compatibility,
167 display: display, slot: o.pathLockPath(key), access: access, info: info, exists: exists,
168 })
169 }
170 sort.Slice(specs, func(i, j int) bool {
171 if specs[i].slot == specs[j].slot {
172 return specs[i].key < specs[j].key
173 }
174 return specs[i].slot < specs[j].slot
175 })
176 return specs, nil
177 }
178
179 func revalidatePathSpecs(specs []pathSpec) error {
180 for _, spec := range specs {
181 key, _, err := canonicalFileKey(spec.access)
182 if err != nil {
183 return fmt.Errorf("revalidate workspace path: %w", err)
184 }
185 currentInfo, statErr := os.Stat(spec.access)
186 currentExists := statErr == nil
187 if statErr != nil && !os.IsNotExist(statErr) {
188 return fmt.Errorf("revalidate workspace path: %w", statErr)
189 }
190 if key != spec.key || currentExists != spec.exists || (currentExists && !os.SameFile(spec.info, currentInfo)) {
191 return errors.New("workspace path identity changed while waiting")
192 }
193 }
194 return nil
195 }
196
197 func specKeys(specs []pathSpec) []string {
198 keys := make([]string, 0, len(specs))
199 for _, spec := range specs {
200 keys = append(keys, spec.key)
201 }
202 return keys
203 }
204
205 func specSlots(specs []pathSpec) []string {
206 var slots []string
207 for _, spec := range specs {
208 if len(slots) == 0 || slots[len(slots)-1] != spec.slot {
209 slots = append(slots, spec.slot)
210 }
211 }
212 return slots
213 }
214
215 func pathScope(specs []pathSpec) (string, string) {
216 if len(specs) == 1 {
217 return "file", specs[0].display
218 }
219 return "files", fmt.Sprintf("%d files", len(specs))
220 }
221
222 func (o *Owner) coveringPathHoldLocked(keys []string) (uint64, *systemHold) {
223 for id, hold := range o.lease.holds {
224 if hold.scope == "workspace" || len(hold.keys) < len(keys) {
225 continue
226 }
227 held := make(map[string]bool, len(hold.keys))
228 for _, key := range hold.keys {
229 held[key] = true
230 }
231 covered := true
232 for _, key := range keys {
233 if !held[key] {
234 covered = false
235 break
236 }
237 }
238 if covered {
239 return id, hold
240 }
241 }
242 return 0, nil
243 }
244
245 func (o *Owner) pathOrderAllowedLocked(slots []string) bool {
246 if len(slots) == 0 {
247 return true
248 }
249 var maxHeld string
250 for _, hold := range o.lease.holds {
251 for _, slot := range hold.slots {
252 if slot > maxHeld {
253 maxHeld = slot
254 }
255 }
256 }
257 return maxHeld == "" || slots[0] > maxHeld
258 }
259
260 func (o *Owner) acquirePathSystem(
261 ctx context.Context,
262 compatibilityRoots, treeSlots, slots []string,
263 notified *bool,
264 ) (func(), error) {
265 parentRelease, err := o.acquireCompatibilityRoots(ctx, compatibilityRoots, filelock.ModeShared, notified)
266 if err != nil {
267 return nil, err
268 }
269 releases := []func(){parentRelease}
270 for _, slot := range treeSlots {
271 release, acquireErr := o.acquireQueuedMode(ctx, slot, filelock.ModeShared, notified)
272 if acquireErr != nil {
273 runReleases(releases)
274 return nil, acquireErr
275 }
276 releases = append(releases, release)
277 }
278 for _, slot := range slots {
279 release, acquireErr := o.acquireMode(ctx, slot, filelock.ModeExclusive, notified)
280 if acquireErr != nil {
281 runReleases(releases)
282 return nil, acquireErr
283 }
284 releases = append(releases, release)
285 }
286 return func() { runReleases(releases) }, nil
287 }
288
289 func (o *Owner) pathLockPath(key string) string {
290 return stripedLockPath(o.lockDir, "path", key, pathLockStripes)
291 }
292
293 func (o *Owner) treeLockPath(key string) string {
294 return stripedLockPath(o.lockDir, "tree", key, treeLockStripes)
295 }
296
297 func stripedLockPath(lockDir, prefix, key string, stripes int) string {
298 sum := sha256.Sum256([]byte(key))
299 stripe := (int(sum[0])<<8 | int(sum[1])) % stripes
300 return filepath.Join(lockDir, fmt.Sprintf("%s-%03x.lock", prefix, stripe))
301 }
302
303 func (o *Owner) compatibilityRoots(specs []pathSpec) []string {
304 roots := append(ancestorDirectories(o.canonical), ancestorDirectories(o.compatibility)...)
305 for _, spec := range specs {
306 for _, dir := range compatibilityPathChain(o.compatibility, filepath.Dir(spec.compatibility)) {
307 if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
308 roots = append(roots, dir, normalizeIdentityPath(dir))
309 }
310 }
311 }
312 return orderedWorkspaceRoots(roots)
313 }
314
315 func compatibilityPathChain(root, target string) []string {
316 root = compatibilityIdentityPath(root)
317 target = compatibilityIdentityPath(target)
318 if !canonicalContains(root, target) {
319 return []string{root}
320 }
321 out := []string{root}
322 rel, err := filepath.Rel(root, target)
323 if err != nil || rel == "." {
324 return out
325 }
326 current := root
327 for part := range strings.SplitSeq(filepath.ToSlash(rel), "/") {
328 if part == "" || part == "." {
329 continue
330 }
331 current = compatibilityIdentityPath(filepath.Join(current, part))
332 out = append(out, current)
333 }
334 return out
335 }
336
337 func (o *Owner) pathTreeSlots(specs []pathSpec) []string {
338 seen := map[string]bool{}
339 var slots []string
340 for _, spec := range specs {
341 for _, identity := range pathChain(o.canonical, spec.key) {
342 slot := o.treeLockPath(identity)
343 if seen[slot] {
344 continue
345 }
346 seen[slot] = true
347 slots = append(slots, slot)
348 }
349 }
350 sort.Strings(slots)
351 return slots
352 }
353
354 func pathChain(root, target string) []string {
355 root, target = normalizeIdentityPath(root), normalizeIdentityPath(target)
356 if !canonicalContains(root, target) {
357 return []string{root}
358 }
359 out := []string{root}
360 rel, err := filepath.Rel(root, target)
361 if err != nil || rel == "." {
362 return out
363 }
364 current := root
365 for part := range strings.SplitSeq(filepath.ToSlash(rel), "/") {
366 if part == "" || part == "." {
367 continue
368 }
369 current = normalizeIdentityPath(filepath.Join(current, part))
370 out = append(out, current)
371 }
372 return out
373 }
374
375 func canonicalFileKey(abs string) (key, display string, err error) {
376 abs, display, err = canonicalFilePath(abs)
377 if err != nil {
378 return "", "", err
379 }
380 return normalizeIdentityPath(abs), display, nil
381 }
382
383 func canonicalFilePath(abs string) (canonical, display string, err error) {
384 abs = strings.TrimSpace(abs)
385 if abs == "" {
386 return "", "", errors.New("path is empty")
387 }
388 baseDir := ""
389 if !filepath.IsAbs(abs) {
390 baseDir, err = os.Getwd()
391 if err != nil {
392 return "", "", err
393 }
394 }
395 identity, err := pathidentity.Resolve(abs, pathidentity.Options{BaseDir: baseDir, FollowLeaf: true})
396 if err != nil {
397 return "", "", err
398 }
399 display = filepath.Base(identity.AccessPath)
400 return compatibilityIdentityPath(identity.PhysicalPath), display, nil
401 }
402
403 func canonicalContains(root, path string) bool {
404 root, path = normalizeIdentityPath(root), normalizeIdentityPath(path)
405 if root == "" || path == "" {
406 return false
407 }
408 if root == path {
409 return true
410 }
411 rel, err := filepath.Rel(root, path)
412 if err != nil {
413 return false
414 }
415 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
416 }
417
418 // LeaseStatesOverlap reports whether a waiting process-local lease can be held
419 // by the candidate. File keys are authoritative even when the two tabs opened
420 // different, overlapping workspace roots; workspace scopes are matched against
421 // the concrete keys when they are available.
422 func LeaseStatesOverlap(waitingRoot string, waiting State, holderRoot string, holder State) bool {
423 holderScope := holder.HeldScope
424 if holderScope == "" {
425 holderScope = holder.Scope
426 }
427 waitingKeys, holderKeys := waiting.WaitingKeys, holder.HeldKeys
428 if waiting.Scope == "workspace" {
429 if holderScope == "workspace" || len(holderKeys) == 0 {
430 return workspaceRootsOverlap(waitingRoot, holderRoot)
431 }
432 return anyKeyWithin(waitingRoot, holderKeys)
433 }
434 if holderScope == "workspace" {
435 if len(waitingKeys) == 0 {
436 return workspaceRootsOverlap(waitingRoot, holderRoot)
437 }
438 return anyKeyWithin(holderRoot, waitingKeys)
439 }
440 if len(waitingKeys) > 0 && len(holderKeys) > 0 {
441 return keysIntersect(waitingKeys, holderKeys)
442 }
443 // Older process-local reporters do not carry keys. Root containment keeps
444 // their conservative behavior for overlapping workspaces.
445 return workspaceRootsOverlap(waitingRoot, holderRoot)
446 }
447
448 func keysIntersect(left, right []string) bool {
449 seen := make(map[string]bool, len(left))
450 for _, key := range left {
451 if key != "" {
452 seen[key] = true
453 }
454 }
455 for _, key := range right {
456 if key != "" && seen[key] {
457 return true
458 }
459 }
460 return false
461 }
462
463 func anyKeyWithin(root string, keys []string) bool {
464 for _, key := range keys {
465 if canonicalContains(root, key) {
466 return true
467 }
468 }
469 return false
470 }
471
472 func workspaceRootsOverlap(left, right string) bool {
473 return canonicalContains(left, right) || canonicalContains(right, left)
474 }
475
475 lines GO