1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "sync"
11 "sync/atomic"
12 "syscall"
13 "testing"
14 "time"
15
16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18)
19
20var errDialFailed = errors.New("dial failed")
21
22const (
23 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25 spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26 spawnTestTraceFileEnv = "VEV_SPAWN_TEST_TRACE_FILE"
27)
28
29func TestMain(m *testing.M) {
30 if len(os.Args) == 2 {
31 switch os.Args[1] {
32 case "--daemon-launcher":
33 launcherFile := os.Getenv(spawnTestLauncherFileEnv)
34 if launcherFile != "" {
35 if err := writeProcessRecord(launcherFile); err != nil {
36 os.Exit(2)
37 }
38 }
39 if err := Run(os.Args[1:]); err != nil {
40 os.Exit(2)
41 }
42 if releaseFile := os.Getenv(spawnTestReleaseFileEnv); releaseFile != "" {
43 deadline := time.Now().Add(5 * time.Second)
44 for {
45 if _, err := os.Stat(releaseFile); err == nil {
46 break
47 } else if !errors.Is(err, os.ErrNotExist) || !time.Now().Before(deadline) {
48 os.Exit(2)
49 }
50 time.Sleep(10 * time.Millisecond)
51 }
52 }
53 os.Exit(0)
54 case "--daemon":
55 path := os.Getenv(spawnTestChildFileEnv)
56 if path == "" {
57 os.Exit(2)
58 }
59 if tracePath := os.Getenv(spawnTestTraceFileEnv); tracePath != "" {
60 if err := os.WriteFile(tracePath, []byte(os.Getenv("VEV_PERF_TRACE")), 0o600); err != nil {
61 os.Exit(2)
62 }
63 }
64 if err := writeProcessRecord(path); err != nil {
65 os.Exit(2)
66 }
67 for {
68 time.Sleep(time.Hour)
69 }
70 }
71 }
72 os.Exit(m.Run())
73}
74
75func TestRealSpawnWaitsForLauncherExitAndDoesNotPropagateTrace(t *testing.T) {
76 dir := t.TempDir()
77 childFile := filepath.Join(dir, "daemon.pid")
78 launcherFile := filepath.Join(dir, "launcher.pid")
79 releaseFile := filepath.Join(dir, "release-launcher")
80 traceFile := filepath.Join(dir, "daemon.trace-env")
81 t.Setenv(spawnTestChildFileEnv, childFile)
82 t.Setenv(spawnTestLauncherFileEnv, launcherFile)
83 t.Setenv(spawnTestReleaseFileEnv, releaseFile)
84 t.Setenv(spawnTestTraceFileEnv, traceFile)
85 t.Setenv("VEV_PERF_TRACE", "parent-trace.jsonl")
86
87 // Register cleanup before starting either subprocess. The release marker
88 // unblocks a launcher even when setup or an assertion fails, and PID files
89 // let cleanup terminate every helper that reached its acknowledgement.
90 t.Cleanup(func() {
91 _ = os.WriteFile(releaseFile, nil, 0o600)
92 for _, path := range []string{launcherFile, childFile} {
93 if err := terminateProcessFromFile(path); err != nil {
94 t.Errorf("clean up %s: %v", filepath.Base(path), err)
95 }
96 }
97 })
98
99 spawnResult := make(chan error, 1)
100 go func() {
101 spawnResult <- realSpawn()
102 }()
103
104 launcherPID := waitForProcessRecord(t, launcherFile)
105 daemonPID := waitForProcessRecord(t, childFile)
106
107 select {
108 case err := <-spawnResult:
109 t.Fatalf("realSpawn returned while launcher %d was still running: %v", launcherPID, err)
110 default:
111 }
112 if err := syscall.Kill(launcherPID, 0); err != nil {
113 t.Fatalf("launcher exited before release acknowledgement: %v", err)
114 }
115
116 if err := os.WriteFile(releaseFile, nil, 0o600); err != nil {
117 t.Fatalf("release launcher: %v", err)
118 }
119 select {
120 case err := <-spawnResult:
121 if err != nil {
122 t.Fatalf("realSpawn: %v", err)
123 }
124 case <-time.After(2 * time.Second):
125 t.Fatal("realSpawn did not return after launcher release")
126 }
127
128 if err := waitForProcessExit(launcherPID, 2*time.Second); err != nil {
129 t.Fatalf("launcher still exists after realSpawn returned: %v", err)
130 }
131 if err := syscall.Kill(daemonPID, 0); err != nil {
132 t.Fatalf("daemon exited with its launcher: %v", err)
133 }
134 traceEnv, err := os.ReadFile(traceFile)
135 if err != nil {
136 t.Fatalf("read daemon trace environment: %v", err)
137 }
138 if got := string(traceEnv); got != "" {
139 t.Fatalf("daemon inherited VEV_PERF_TRACE=%q", got)
140 }
141}
142
143func waitForProcessRecord(t *testing.T, path string) int {
144 t.Helper()
145 deadline := time.Now().Add(2 * time.Second)
146 for time.Now().Before(deadline) {
147 data, err := os.ReadFile(path)
148 if err == nil {
149 pid, err := strconv.Atoi(string(data))
150 if err != nil {
151 t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
152 }
153 return pid
154 }
155 if !errors.Is(err, os.ErrNotExist) {
156 t.Fatalf("read process record %s: %v", filepath.Base(path), err)
157 }
158 time.Sleep(10 * time.Millisecond)
159 }
160 t.Fatalf("process did not write %s", filepath.Base(path))
161 return 0
162}
163
164func waitForProcessExit(pid int, timeout time.Duration) error {
165 deadline := time.Now().Add(timeout)
166 for time.Now().Before(deadline) {
167 err := syscall.Kill(pid, 0)
168 if errors.Is(err, syscall.ESRCH) {
169 return nil
170 }
171 if err != nil && !errors.Is(err, syscall.EPERM) {
172 return err
173 }
174 time.Sleep(10 * time.Millisecond)
175 }
176 return fmt.Errorf("process %d did not exit within %s", pid, timeout)
177}
178
179func writeProcessRecord(path string) error {
180 tmp := path + ".tmp"
181 if err := os.WriteFile(tmp, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
182 return err
183 }
184 return os.Rename(tmp, path)
185}
186
187func terminateProcessFromFile(path string) error {
188 data, err := os.ReadFile(path)
189 if errors.Is(err, os.ErrNotExist) {
190 return nil
191 }
192 if err != nil {
193 return err
194 }
195 pid, err := strconv.Atoi(string(data))
196 if err != nil {
197 return err
198 }
199 if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
200 return err
201 }
202 return waitForProcessExit(pid, 2*time.Second)
203}
204
205// fastBackoff keeps the retry loop snappy for tests.
206var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
207
208func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
209 dir := filepath.Join(t.TempDir(), "vev")
210 const racers = 16
211
212 var wins atomic.Int32
213 var wg sync.WaitGroup
214 start := make(chan struct{})
215 releases := make(chan func(), racers)
216
217 for range racers {
218 wg.Go(func() {
219 <-start
220 release, acquired, err := acquireSpawnLock(dir)
221 if err != nil {
222 t.Errorf("acquireSpawnLock: %v", err)
223 return
224 }
225 if acquired {
226 wins.Add(1)
227 releases <- release
228 }
229 })
230 }
231
232 close(start)
233 wg.Wait()
234 close(releases)
235
236 if got := wins.Load(); got != 1 {
237 t.Fatalf("expected exactly one winner, got %d", got)
238 }
239 for release := range releases {
240 release()
241 }
242 // After release, the lock directory is gone and can be re-acquired.
243 release, acquired, err := acquireSpawnLock(dir)
244 if err != nil || !acquired {
245 t.Fatalf("re-acquire after release: acquired=%v err=%v", acquired, err)
246 }
247 release()
248}
249
250func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
251 dir := filepath.Join(t.TempDir(), "vev")
252 if err := os.Mkdir(dir, 0o700); err != nil {
253 t.Fatalf("seeding socket dir: %v", err)
254 }
255 lockPath := filepath.Join(dir, spawnLockName)
256 if err := os.Mkdir(lockPath, 0o700); err != nil {
257 t.Fatalf("seeding lock: %v", err)
258 }
259
260 // A fresh lock must NOT be taken over.
261 if _, acquired, err := acquireSpawnLock(dir); err != nil || acquired {
262 t.Fatalf("fresh lock taken over: acquired=%v err=%v", acquired, err)
263 }
264
265 // Backdate the lock beyond the stale threshold; now it is taken over.
266 old := time.Now().Add(-2 * staleLockAge)
267 if err := os.Chtimes(lockPath, old, old); err != nil {
268 t.Fatalf("backdating lock: %v", err)
269 }
270 release, acquired, err := acquireSpawnLock(dir)
271 if err != nil || !acquired {
272 t.Fatalf("stale lock not taken over: acquired=%v err=%v", acquired, err)
273 }
274 release()
275}
276
277func TestRetryAttemptsChecksOperationErrorBeforeDeadline(t *testing.T) {
278 attemptErr := errors.New("attempt failed")
279 calls := 0
280 _, err := retryAttempts(context.Background(), backoffConfig{}, func() (struct{}, bool, error) {
281 calls++
282 return struct{}{}, false, attemptErr
283 })
284 if !errors.Is(err, attemptErr) {
285 t.Fatalf("retryAttempts error = %v, want %v", err, attemptErr)
286 }
287 if calls != 1 {
288 t.Fatalf("attempt called %d times, want 1", calls)
289 }
290}
291
292func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) {
293 started := time.Now()
294 calls := 0
295 _, err := retryAttempts(context.Background(), backoffConfig{total: time.Second, max: time.Second}, func() (struct{}, bool, error) {
296 calls++
297 return struct{}{}, calls == 2, nil
298 })
299 if err != nil {
300 t.Fatalf("retryAttempts: %v", err)
301 }
302 if elapsed := time.Since(started); elapsed < 500*time.Microsecond {
303 t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed)
304 }
305}
306
307func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
308 want := portsmocks.NewMockTransport(t)
309 var calls atomic.Int32
310 dial := func(context.Context, string) (ports.Transport, error) {
311 if calls.Add(1) <= 3 {
312 return nil, errDialFailed
313 }
314 return want, nil
315 }
316
317 got, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
318 if err != nil {
319 t.Fatalf("retryDial: %v", err)
320 }
321 if got != want {
322 t.Fatalf("retryDial returned unexpected transport")
323 }
324 if calls.Load() != 4 {
325 t.Fatalf("dial called %d times, want 4", calls.Load())
326 }
327}
328
329func TestRetryDialPermanentFailure(t *testing.T) {
330 dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
331 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
332 if !errors.Is(err, ErrDaemonUnreachable) {
333 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
334 }
335}
336
337func TestRetryDialContextCancelled(t *testing.T) {
338 ctx, cancel := context.WithCancel(context.Background())
339 cancel()
340 var calls atomic.Int32
341 dial := func(context.Context, string) (ports.Transport, error) {
342 calls.Add(1)
343 return nil, errDialFailed
344 }
345 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)
346 if !errors.Is(err, context.Canceled) {
347 t.Fatalf("retryDial error = %v, want context.Canceled", err)
348 }
349 if calls.Load() != 0 {
350 t.Fatalf("dial called %d times for canceled context, want 0", calls.Load())
351 }
352}
353
354func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
355 ctx, cancel := context.WithCancel(context.Background())
356 cancel()
357 dial := func(context.Context, string) (ports.Transport, error) {
358 t.Fatal("ensureDaemon dialed with a canceled context")
359 return nil, nil
360 }
361 spawn := func() error {
362 t.Fatal("ensureDaemon spawned with a canceled context")
363 return nil
364 }
365
366 _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff)
367 if !errors.Is(err, context.Canceled) {
368 t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
369 }
370}
371
372func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T) {
373 ctx, cancel := context.WithCancel(context.Background())
374 defer cancel()
375 var spawnCalls atomic.Int32
376 dial := func(context.Context, string) (ports.Transport, error) {
377 cancel()
378 return nil, errDialFailed
379 }
380 spawn := func() error {
381 spawnCalls.Add(1)
382 return nil
383 }
384
385 _, err := ensureDaemon(ctx, filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
386 if !errors.Is(err, context.Canceled) {
387 t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
388 }
389 if got := spawnCalls.Load(); got != 0 {
390 t.Fatalf("spawn called %d times after canceled failed dial, want 0", got)
391 }
392}
393
394func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
395 dir := filepath.Join(t.TempDir(), "vev")
396 want := portsmocks.NewMockTransport(t)
397 var dialCalls, spawnCalls atomic.Int32
398 dial := func(context.Context, string) (ports.Transport, error) {
399 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
400 if dialCalls.Add(1) == 1 {
401 return nil, errDialFailed
402 }
403 return want, nil
404 }
405 spawn := func() error {
406 spawnCalls.Add(1)
407 return nil
408 }
409
410 got, err := ensureDaemon(context.Background(), dir, dial, spawn, fastBackoff)
411 if err != nil {
412 t.Fatalf("ensureDaemon: %v", err)
413 }
414 if got != want {
415 t.Fatalf("ensureDaemon returned unexpected transport")
416 }
417 if spawnCalls.Load() != 1 {
418 t.Fatalf("spawn called %d times, want 1", spawnCalls.Load())
419 }
420 // The spawn lock must be released once ensureDaemon returns.
421 if _, err := os.Stat(filepath.Join(dir, spawnLockName)); !os.IsNotExist(err) {
422 t.Fatalf("spawn lock not released: stat err=%v", err)
423 }
424}
425
426func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
427 want := portsmocks.NewMockTransport(t)
428 var spawnCalls atomic.Int32
429 dial := func(context.Context, string) (ports.Transport, error) { return want, nil }
430 spawn := func() error { spawnCalls.Add(1); return nil }
431
432 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
433 if err != nil {
434 t.Fatalf("ensureDaemon: %v", err)
435 }
436 if got != want {
437 t.Fatalf("ensureDaemon returned unexpected transport")
438 }
439 if spawnCalls.Load() != 0 {
440 t.Fatalf("spawn should not be called when daemon already up, got %d", spawnCalls.Load())
441 }
442}
443
444func TestEnsureDaemonSpawnFailure(t *testing.T) {
445 spawnErr := errors.New("exec boom")
446 dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
447 spawn := func() error { return spawnErr }
448
449 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
450 if !errors.Is(err, spawnErr) {
451 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)
452 }
453}