v0.2.0 80c61e3

spawn_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close