summaryrefslogtreecommitdiff
path: root/internal/askcli/runlock_test.go
blob: c8266f095a0a954800c1b25f49e7bdfb1e5fdc57 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package askcli

import (
	"context"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"codeberg.org/snonux/hexai/internal/filelock"
)

type lockResult struct {
	unlock func() error
	err    error
}

func TestAcquireAskRepoLock_SerializesConcurrentHolders(t *testing.T) {
	tmp := t.TempDir()
	if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	var maxHeld int32
	var cur int32
	var wg sync.WaitGroup
	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			unlock, err := acquireAskRepoLock(context.Background(), tmp)
			if err != nil {
				t.Errorf("lock: %v", err)
				return
			}
			defer func() { _ = unlock() }()
			n := atomic.AddInt32(&cur, 1)
			for {
				old := atomic.LoadInt32(&maxHeld)
				if n <= old || atomic.CompareAndSwapInt32(&maxHeld, old, n) {
					break
				}
			}
			time.Sleep(25 * time.Millisecond)
			atomic.AddInt32(&cur, -1)
		}()
	}
	wg.Wait()
	if got := atomic.LoadInt32(&maxHeld); got != 1 {
		t.Fatalf("max concurrent lock holders = %d, want 1", got)
	}
}

func TestAcquireAskRepoLock_StaleMetadataDoesNotRotateContendedLockFile(t *testing.T) {
	tmp := t.TempDir()
	holder, lockPath, origInfo := prepareContendedStaleLock(t, tmp)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	resultCh := acquireLockAsync(ctx, tmp)

	select {
	case result := <-resultCh:
		if result.unlock != nil {
			_ = result.unlock()
		}
		t.Fatalf("lock acquired while holder still held lock: %v", result.err)
	case <-time.After(40 * time.Millisecond):
	}

	curInfo, err := os.Stat(lockPath)
	if err != nil {
		t.Fatalf("stat contended lock: %v", err)
	}
	if !os.SameFile(origInfo, curInfo) {
		t.Fatal("contended lock file was replaced while locked")
	}

	releaseContendedLock(t, holder)

	result := <-resultCh
	if result.err != nil {
		t.Fatalf("contender lock: %v", result.err)
	}
	if result.unlock == nil {
		t.Fatal("contender returned nil unlock")
	}
	if err := result.unlock(); err != nil {
		t.Fatalf("contender unlock: %v", err)
	}
}

// TestAcquireAskRepoLock_ContextCancelledWhileBlocked verifies the retry loop
// honors context cancellation while it is parked on the per-iteration timer.
// This guards the time.After-based wait that replaced the unsafe timer reuse:
// cancellation must win the select and return ctx.Err() promptly.
func TestAcquireAskRepoLock_ContextCancelledWhileBlocked(t *testing.T) {
	tmp := t.TempDir()
	holder, _, _ := prepareContendedStaleLock(t, tmp)
	defer releaseContendedLock(t, holder)

	ctx, cancel := context.WithCancel(context.Background())
	resultCh := acquireLockAsync(ctx, tmp)

	// Let the contender enter the retry loop, then cancel while it waits.
	time.Sleep(20 * time.Millisecond)
	cancel()

	select {
	case result := <-resultCh:
		if result.unlock != nil {
			_ = result.unlock()
			t.Fatal("lock acquired despite cancellation")
		}
		if result.err != context.Canceled {
			t.Fatalf("err = %v, want context.Canceled", result.err)
		}
	case <-time.After(time.Second):
		t.Fatal("acquireAskRepoLock did not return after cancellation")
	}
}

func prepareContendedStaleLock(t *testing.T, gitRoot string) (*os.File, string, os.FileInfo) {
	t.Helper()
	lockDir := filepath.Join(gitRoot, ".git")
	if err := os.MkdirAll(lockDir, 0o755); err != nil {
		t.Fatal(err)
	}
	lockPath := filepath.Join(lockDir, askRepoLockFile)
	holder, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		t.Fatal(err)
	}
	if err := filelock.TryExclusive(holder); err != nil {
		t.Fatalf("holder lock: %v", err)
	}
	if err := writeLockMetadata(holder, 999999, "ask"); err != nil {
		t.Fatalf("write stale metadata: %v", err)
	}
	origInfo, err := os.Stat(lockPath)
	if err != nil {
		t.Fatalf("stat original lock: %v", err)
	}
	return holder, lockPath, origInfo
}

func acquireLockAsync(ctx context.Context, gitRoot string) <-chan lockResult {
	resultCh := make(chan lockResult, 1)
	go func() {
		unlock, err := acquireAskRepoLock(ctx, gitRoot)
		resultCh <- lockResult{unlock: unlock, err: err}
	}()
	return resultCh
}

func releaseContendedLock(t *testing.T, holder *os.File) {
	t.Helper()
	if err := filelock.UnlockExclusive(holder); err != nil {
		t.Fatalf("release holder lock: %v", err)
	}
	if err := holder.Close(); err != nil {
		t.Fatalf("close holder file: %v", err)
	}
}