返回 DeepSeek-Reasonix
scope_regression_test.go
根目录 / internal / workspacelease / scope_regression_test.go
1 package workspacelease
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/filelock"
15 )
16
17 func TestReversePathBatchesSerializeWithoutDeadlock(t *testing.T) {
18 root, locks := t.TempDir(), t.TempDir()
19 first, _ := New(root, locks, nil)
20 second, _ := New(root, locks, nil)
21 a := filepath.Join(root, "a.go")
22 b := filepath.Join(root, "b.go")
23 start := make(chan struct{})
24 type result struct {
25 release func()
26 err error
27 }
28 results := make(chan result, 2)
29 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
30 defer cancel()
31 for owner, paths := range map[*Owner][]string{first: {a, b}, second: {b, a}} {
32 go func() {
33 <-start
34 release, err := owner.HoldWriteForPaths(ctx, paths)
35 results <- result{release: release, err: err}
36 }()
37 }
38 close(start)
39
40 one := <-results
41 if one.err != nil {
42 t.Fatalf("first batch acquire: %v", one.err)
43 }
44 one.release()
45 two := <-results
46 if two.err != nil {
47 t.Fatalf("reverse batch acquire: %v", two.err)
48 }
49 two.release()
50 }
51
52 func TestExclusiveUpgradeKeepsActiveFileProtected(t *testing.T) {
53 root, locks := t.TempDir(), t.TempDir()
54 owner, _ := New(root, locks, nil)
55 contender, _ := New(root, locks, nil)
56 path := filepath.Join(root, "protected.go")
57 releasePath, err := owner.HoldWriteForPath(context.Background(), path)
58 if err != nil {
59 t.Fatal(err)
60 }
61
62 upgrade := make(chan error, 1)
63 go func() {
64 release, err := owner.HoldWrite(context.Background())
65 if err == nil {
66 release()
67 }
68 upgrade <- err
69 }()
70 waitForOwnerAcquisition(t, owner)
71
72 ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
73 _, err = contender.HoldWriteForPath(ctx, path)
74 cancel()
75 if !errors.Is(err, context.DeadlineExceeded) {
76 t.Fatalf("active file lost protection during upgrade: %v", err)
77 }
78 releasePath()
79 select {
80 case err := <-upgrade:
81 if err != nil {
82 t.Fatal(err)
83 }
84 case <-time.After(2 * time.Second):
85 t.Fatal("exclusive upgrade did not continue after the file hold released")
86 }
87 }
88
89 func TestUncontendedPathWriteDoesNotNotifyWait(t *testing.T) {
90 var notices atomic.Int32
91 owner, err := New(t.TempDir(), t.TempDir(), func() { notices.Add(1) })
92 if err != nil {
93 t.Fatal(err)
94 }
95 release, err := owner.HoldWriteForPath(context.Background(), filepath.Join(owner.canonical, "a.go"))
96 if err != nil {
97 t.Fatal(err)
98 }
99 release()
100 if got := notices.Load(); got != 0 {
101 t.Fatalf("uncontended path write emitted %d wait notices", got)
102 }
103 }
104
105 func TestWorkspaceWriterGetsPriorityOverNewPathReader(t *testing.T) {
106 root, locks := t.TempDir(), t.TempDir()
107 reader, _ := New(root, locks, nil)
108 writerWaiting := make(chan struct{}, 1)
109 writer, _ := New(root, locks, func() { writerWaiting <- struct{}{} })
110 lateReader, _ := New(root, locks, nil)
111 releaseReader, err := reader.HoldWriteForPath(context.Background(), filepath.Join(root, "a.go"))
112 if err != nil {
113 t.Fatal(err)
114 }
115 type result struct {
116 release func()
117 err error
118 }
119 writerResult := make(chan result, 1)
120 go func() {
121 release, acquireErr := writer.HoldWrite(context.Background())
122 writerResult <- result{release: release, err: acquireErr}
123 }()
124 select {
125 case <-writerWaiting:
126 case <-time.After(2 * time.Second):
127 t.Fatal("workspace writer did not report contention")
128 }
129 lateResult := make(chan result, 1)
130 go func() {
131 release, acquireErr := lateReader.HoldWriteForPath(context.Background(), filepath.Join(root, "b.go"))
132 lateResult <- result{release: release, err: acquireErr}
133 }()
134 releaseReader()
135 acquiredWriter := <-writerResult
136 if acquiredWriter.err != nil {
137 t.Fatal(acquiredWriter.err)
138 }
139 select {
140 case late := <-lateResult:
141 if late.release != nil {
142 late.release()
143 }
144 t.Fatal("new path reader bypassed the waiting workspace writer")
145 default:
146 }
147 acquiredWriter.release()
148 late := <-lateResult
149 if late.err != nil {
150 t.Fatal(late.err)
151 }
152 late.release()
153 }
154
155 func TestSameOwnerReusesSharedDomainsWithoutLettingOtherReadersBypassWriter(t *testing.T) {
156 root, locks := t.TempDir(), t.TempDir()
157 owner, _ := New(root, locks, nil)
158 firstPath, secondPath := increasingPathSlots(t, owner)
159 releaseFirst, err := owner.HoldWriteForPath(context.Background(), firstPath)
160 if err != nil {
161 t.Fatal(err)
162 }
163
164 writerWaiting := make(chan struct{}, 1)
165 writer, _ := New(root, locks, func() { writerWaiting <- struct{}{} })
166 writerResult := make(chan holdResult, 1)
167 go func() {
168 release, acquireErr := writer.HoldWrite(context.Background())
169 writerResult <- holdResult{release: release, err: acquireErr}
170 }()
171 select {
172 case <-writerWaiting:
173 case <-time.After(2 * time.Second):
174 t.Fatal("workspace writer did not report waiting")
175 }
176
177 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
178 releaseSecond, err := owner.HoldWriteForPath(ctx, secondPath)
179 cancel()
180 if err != nil {
181 releaseFirst()
182 t.Fatalf("same owner deadlocked behind a writer waiting on its first path hold: %v", err)
183 }
184
185 lateWaiting := make(chan struct{}, 1)
186 lateReader, _ := New(root, locks, func() { lateWaiting <- struct{}{} })
187 lateResult := make(chan holdResult, 1)
188 go func() {
189 release, acquireErr := lateReader.HoldWriteForPath(context.Background(), secondPath)
190 lateResult <- holdResult{release: release, err: acquireErr}
191 }()
192 select {
193 case <-lateWaiting:
194 case <-time.After(2 * time.Second):
195 t.Fatal("different owner did not queue behind the waiting workspace writer")
196 }
197
198 releaseSecond()
199 releaseFirst()
200 acquiredWriter := awaitHold(t, writerResult)
201 select {
202 case late := <-lateResult:
203 if late.release != nil {
204 late.release()
205 }
206 t.Fatalf("different owner bypassed the waiting workspace writer: %v", late.err)
207 default:
208 }
209 acquiredWriter.release()
210 acquiredLate := awaitHold(t, lateResult)
211 acquiredLate.release()
212 }
213
214 func increasingPathSlots(t *testing.T, owner *Owner) (string, string) {
215 t.Helper()
216 first := filepath.Join(owner.canonical, "slot-0.go")
217 firstSlot := canonicalPathSlot(t, owner, first)
218 for i := 1; i < pathLockStripes*2; i++ {
219 candidate := filepath.Join(owner.canonical, fmt.Sprintf("slot-%d.go", i))
220 candidateSlot := canonicalPathSlot(t, owner, candidate)
221 if candidateSlot > firstSlot {
222 return first, candidate
223 }
224 if candidateSlot < firstSlot {
225 return candidate, first
226 }
227 }
228 t.Fatal("could not find two distinct path lock slots")
229 return "", ""
230 }
231
232 func canonicalPathSlot(t *testing.T, owner *Owner, path string) string {
233 t.Helper()
234 specs, err := owner.pathSpecs([]string{path})
235 if err != nil || len(specs) != 1 {
236 t.Fatalf("resolve path slot for %q: specs=%v err=%v", path, specs, err)
237 }
238 return specs[0].slot
239 }
240
241 func TestPathLockFilesUseBoundedStripes(t *testing.T) {
242 owner, err := New(t.TempDir(), t.TempDir(), nil)
243 if err != nil {
244 t.Fatal(err)
245 }
246 seen := map[string]bool{}
247 for i := range pathLockStripes * 3 {
248 seen[owner.pathLockPath(fmt.Sprintf("file-%d", i))] = true
249 }
250 if len(seen) > pathLockStripes {
251 t.Fatalf("path lock files = %d, want at most %d", len(seen), pathLockStripes)
252 }
253 }
254
255 func TestHierarchyLockFilesUseBoundedStripes(t *testing.T) {
256 owner, err := New(t.TempDir(), t.TempDir(), nil)
257 if err != nil {
258 t.Fatal(err)
259 }
260 seen := map[string]bool{}
261 for i := range treeLockStripes * 3 {
262 seen[owner.treeLockPath(fmt.Sprintf("directory-%d", i))] = true
263 }
264 if len(seen) > treeLockStripes {
265 t.Fatalf("hierarchy lock files = %d, want at most %d", len(seen), treeLockStripes)
266 }
267 }
268
269 func TestParentPathAndNestedWorkspaceWriterSerializeBothDirections(t *testing.T) {
270 parent, locks := t.TempDir(), t.TempDir()
271 nested := filepath.Join(parent, "nested")
272 if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o700); err != nil {
273 t.Fatal(err)
274 }
275 path := filepath.Join(nested, "shared.go")
276
277 t.Run("parent path blocks nested workspace", func(t *testing.T) {
278 parentOwner, err := New(parent, locks, nil)
279 if err != nil {
280 t.Fatal(err)
281 }
282 waiting := make(chan struct{}, 1)
283 nestedOwner, err := New(nested, locks, func() { waiting <- struct{}{} })
284 if err != nil {
285 t.Fatal(err)
286 }
287 releasePath, err := parentOwner.HoldWriteForPath(context.Background(), path)
288 if err != nil {
289 t.Fatal(err)
290 }
291 result := make(chan holdResult, 1)
292 go func() {
293 release, acquireErr := nestedOwner.HoldWrite(context.Background())
294 result <- holdResult{release: release, err: acquireErr}
295 }()
296 assertHoldWaiting(t, waiting, result)
297 releasePath()
298 acquired := awaitHold(t, result)
299 acquired.release()
300 })
301
302 t.Run("nested workspace blocks parent path", func(t *testing.T) {
303 nestedOwner, err := New(nested, locks, nil)
304 if err != nil {
305 t.Fatal(err)
306 }
307 waiting := make(chan struct{}, 1)
308 parentOwner, err := New(parent, locks, func() { waiting <- struct{}{} })
309 if err != nil {
310 t.Fatal(err)
311 }
312 releaseWorkspace, err := nestedOwner.HoldWrite(context.Background())
313 if err != nil {
314 t.Fatal(err)
315 }
316 result := make(chan holdResult, 1)
317 go func() {
318 release, acquireErr := parentOwner.HoldWriteForPath(context.Background(), path)
319 result <- holdResult{release: release, err: acquireErr}
320 }()
321 assertHoldWaiting(t, waiting, result)
322 releaseWorkspace()
323 acquired := awaitHold(t, result)
324 acquired.release()
325 })
326 }
327
328 func TestHierarchyLocksDoNotSerializeUnrelatedWorkspaces(t *testing.T) {
329 locks := t.TempDir()
330 base := t.TempDir()
331 firstRoot := filepath.Join(base, "workspace-a")
332 if err := os.Mkdir(firstRoot, 0o700); err != nil {
333 t.Fatal(err)
334 }
335 first, err := New(firstRoot, locks, nil)
336 if err != nil {
337 t.Fatal(err)
338 }
339 secondRoot, secondPath := unrelatedTreePath(t, first, base)
340 second, err := New(secondRoot, locks, nil)
341 if err != nil {
342 t.Fatal(err)
343 }
344 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
345 defer cancel()
346 releaseWorkspace, err := first.HoldWrite(ctx)
347 if err != nil {
348 t.Fatal(err)
349 }
350 defer releaseWorkspace()
351 releasePath, err := second.HoldWriteForPath(ctx, secondPath)
352 if err != nil {
353 t.Fatalf("unrelated path write was serialized: %v", err)
354 }
355 releasePath()
356 }
357
358 func TestHierarchyProtocolIntersectsOldExactWorkspaceLocks(t *testing.T) {
359 parent, locks := t.TempDir(), t.TempDir()
360 nested := filepath.Join(parent, "nested")
361 if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o700); err != nil {
362 t.Fatal(err)
363 }
364 path := filepath.Join(nested, "shared.go")
365 parentOwner, _ := New(parent, locks, nil)
366 nestedOwner, _ := New(nested, locks, nil)
367
368 t.Run("old parent blocks new nested workspace", func(t *testing.T) {
369 releaseOld, err := filelock.TryAcquire(parentOwner.lockPath)
370 if err != nil {
371 t.Fatal(err)
372 }
373 waiting := make(chan struct{}, 1)
374 contender, _ := New(nested, locks, func() { waiting <- struct{}{} })
375 result := make(chan holdResult, 1)
376 go func() {
377 release, acquireErr := contender.HoldWrite(context.Background())
378 result <- holdResult{release: release, err: acquireErr}
379 }()
380 assertHoldWaiting(t, waiting, result)
381 releaseOld()
382 acquired := awaitHold(t, result)
383 acquired.release()
384 })
385
386 t.Run("old nested git root blocks new parent path", func(t *testing.T) {
387 releaseOld, err := filelock.TryAcquire(nestedOwner.lockPath)
388 if err != nil {
389 t.Fatal(err)
390 }
391 waiting := make(chan struct{}, 1)
392 contender, _ := New(parent, locks, func() { waiting <- struct{}{} })
393 result := make(chan holdResult, 1)
394 go func() {
395 release, acquireErr := contender.HoldWriteForPath(context.Background(), path)
396 result <- holdResult{release: release, err: acquireErr}
397 }()
398 assertHoldWaiting(t, waiting, result)
399 releaseOld()
400 acquired := awaitHold(t, result)
401 acquired.release()
402 })
403 }
404
405 func TestLeaseStatesOverlapUsesKeysAcrossWorkspaceRoots(t *testing.T) {
406 parent := t.TempDir()
407 nested := filepath.Join(parent, "nested")
408 inside := filepath.Join(nested, "inside.go")
409 outside := filepath.Join(parent, "outside.go")
410 waitingFile := State{Scope: "file", WaitingKeys: []string{inside}}
411 holderFile := State{HeldScope: "file", HeldKeys: []string{inside}}
412 if !LeaseStatesOverlap(parent, waitingFile, nested, holderFile) {
413 t.Fatal("identical file keys must overlap across canonical roots")
414 }
415 if LeaseStatesOverlap(nested, State{Scope: "workspace", WaitingKeys: []string{nested}}, parent, State{
416 HeldScope: "file", HeldKeys: []string{outside},
417 }) {
418 t.Fatal("nested whole workspace must not match a parent path outside it")
419 }
420 if !LeaseStatesOverlap(nested, State{Scope: "workspace", WaitingKeys: []string{nested}}, parent, holderFile) {
421 t.Fatal("nested whole workspace must match a parent path inside it")
422 }
423 if !LeaseStatesOverlap(parent, waitingFile, nested, State{
424 HeldScope: "workspace", HeldKeys: []string{nested},
425 }) {
426 t.Fatal("nested whole holder must match a parent path inside it")
427 }
428 }
429
430 type holdResult struct {
431 release func()
432 err error
433 }
434
435 func assertHoldWaiting(t *testing.T, waiting <-chan struct{}, result <-chan holdResult) {
436 t.Helper()
437 select {
438 case <-waiting:
439 case acquired := <-result:
440 if acquired.release != nil {
441 acquired.release()
442 }
443 t.Fatalf("conflicting hold acquired before release: %v", acquired.err)
444 case <-time.After(2 * time.Second):
445 t.Fatal("conflicting hold did not report waiting")
446 }
447 select {
448 case acquired := <-result:
449 if acquired.release != nil {
450 acquired.release()
451 }
452 t.Fatalf("conflicting hold acquired while blocker remained: %v", acquired.err)
453 default:
454 }
455 }
456
457 func awaitHold(t *testing.T, result <-chan holdResult) holdResult {
458 t.Helper()
459 select {
460 case acquired := <-result:
461 if acquired.err != nil {
462 t.Fatal(acquired.err)
463 }
464 return acquired
465 case <-time.After(2 * time.Second):
466 t.Fatal("hold did not acquire after blocker released")
467 return holdResult{}
468 }
469 }
470
471 func TestNestedWorkspaceRootsSharePathStripes(t *testing.T) {
472 root, locks := t.TempDir(), t.TempDir()
473 child := filepath.Join(root, "child")
474 if err := os.Mkdir(child, 0o700); err != nil {
475 t.Fatal(err)
476 }
477 parentOwner, _ := New(root, locks, nil)
478 childOwner, _ := New(child, locks, nil)
479 path := filepath.Join(child, "shared.go")
480 release, err := parentOwner.HoldWriteForPath(context.Background(), path)
481 if err != nil {
482 t.Fatal(err)
483 }
484 defer release()
485 ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
486 defer cancel()
487 if _, err := childOwner.HoldWriteForPath(ctx, path); !errors.Is(err, context.DeadlineExceeded) {
488 t.Fatalf("nested workspace bypassed the same-file stripe: %v", err)
489 }
490 }
491
492 func TestRetainedPathHoldCanBeReused(t *testing.T) {
493 owner, err := New(t.TempDir(), t.TempDir(), nil)
494 if err != nil {
495 t.Fatal(err)
496 }
497 path := filepath.Join(owner.canonical, "retained.go")
498 release, err := owner.HoldWriteForPath(context.Background(), path)
499 if err != nil {
500 t.Fatal(err)
501 }
502 done := make(chan struct{})
503 owner.RetainUntil(done)
504 release()
505 reused, err := owner.HoldWriteForPath(context.Background(), path)
506 if err != nil {
507 t.Fatal(err)
508 }
509 reused()
510 close(done)
511 }
512
513 func TestDarwinCaseAliasesShareFileKey(t *testing.T) {
514 if runtime.GOOS != "darwin" {
515 t.Skip("macOS case-insensitive alias regression")
516 }
517 root := t.TempDir()
518 actual := filepath.Join(root, "CaseAlias.go")
519 if err := os.WriteFile(actual, []byte("package alias"), 0o600); err != nil {
520 t.Fatal(err)
521 }
522 alias := filepath.Join(root, "casealias.go")
523 actualKey, _, err := canonicalFileKey(actual)
524 if err != nil {
525 t.Fatal(err)
526 }
527 aliasKey, _, err := canonicalFileKey(alias)
528 if err != nil {
529 t.Fatal(err)
530 }
531 if actualKey != aliasKey {
532 t.Fatalf("case aliases produced different keys: %q != %q", actualKey, aliasKey)
533 }
534 }
535
536 func waitForOwnerAcquisition(t *testing.T, owner *Owner) {
537 t.Helper()
538 deadline := time.After(2 * time.Second)
539 for {
540 owner.mu.Lock()
541 acquiring := owner.lease.acquiring
542 changed := owner.lease.changed
543 owner.mu.Unlock()
544 if acquiring {
545 return
546 }
547 select {
548 case <-changed:
549 case <-deadline:
550 t.Fatal("owner did not begin acquisition")
551 }
552 }
553 }
554
554 lines GO