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
17const (
18	spawnTestChildFileEnv    = "VEV_SPAWN_TEST_CHILD_FILE"
19	spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
20	spawnTestReleaseFileEnv  = "VEV_SPAWN_TEST_RELEASE_FILE"
21	spawnTestTraceFileEnv    = "VEV_SPAWN_TEST_TRACE_FILE"
22)
23
24func TestMain(m *testing.M) {
25	// The hidden offline broker commands are re-executed through this test
26	// binary, so a broker test can exercise the real dispatch path in a real
27	// subprocess. The helper env var keeps that interception out of ordinary
28	// runs.
29	if len(os.Args) >= 2 && os.Getenv(brokerHelperEnv) == "1" {
30		switch os.Args[1] {
31		case brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand,
32			brokerReadyCommand,
33			productionBrokerServeCommand, productionBrokerLauncherCommand, "--daemon":
34			recordBrokerHelperProcess(os.Args[1])
35			if os.Args[1] == productionBrokerLauncherCommand {
36				if record := os.Getenv(brokerLauncherBlockRecordEnv); record != "" {
37					blockBrokerLauncherHelper(record, os.Getenv(brokerLauncherBlockReleaseEnv))
38				}
39			}
40			if err := Run(os.Args[1:]); err != nil {
41				fmt.Fprintln(os.Stderr, err)
42				os.Exit(ExitCode(err))
43			}
44			os.Exit(0)
45		}
46	}
47	if len(os.Args) == 2 {
48		switch os.Args[1] {
49		case "--daemon-launcher":
50			launcherFile := os.Getenv(spawnTestLauncherFileEnv)
51			if launcherFile != "" {
52				if err := writeProcessRecord(launcherFile); err != nil {
53					os.Exit(2)
54				}
55			}
56			if err := Run(os.Args[1:]); err != nil {
57				os.Exit(2)
58			}
59			if releaseFile := os.Getenv(spawnTestReleaseFileEnv); releaseFile != "" {
60				deadline := time.Now().Add(5 * time.Second)
61				for {
62					if _, err := os.Stat(releaseFile); err == nil {
63						break
64					} else if !errors.Is(err, os.ErrNotExist) || !time.Now().Before(deadline) {
65						os.Exit(2)
66					}
67					time.Sleep(10 * time.Millisecond)
68				}
69			}
70			os.Exit(0)
71		case "--daemon":
72			path := os.Getenv(spawnTestChildFileEnv)
73			if path == "" {
74				os.Exit(2)
75			}
76			if tracePath := os.Getenv(spawnTestTraceFileEnv); tracePath != "" {
77				if err := os.WriteFile(tracePath, []byte(os.Getenv("VEV_PERF_TRACE")), 0o600); err != nil {
78					os.Exit(2)
79				}
80			}
81			if err := writeProcessRecord(path); err != nil {
82				os.Exit(2)
83			}
84			for {
85				time.Sleep(time.Hour)
86			}
87		}
88	}
89	os.Exit(m.Run())
90}
91
92func TestRealSpawnWaitsForLauncherExitAndDoesNotPropagateTrace(t *testing.T) {
93	dir := t.TempDir()
94	childFile := filepath.Join(dir, "daemon.pid")
95	launcherFile := filepath.Join(dir, "launcher.pid")
96	releaseFile := filepath.Join(dir, "release-launcher")
97	traceFile := filepath.Join(dir, "daemon.trace-env")
98	t.Setenv(spawnTestChildFileEnv, childFile)
99	t.Setenv(spawnTestLauncherFileEnv, launcherFile)
100	t.Setenv(spawnTestReleaseFileEnv, releaseFile)
101	t.Setenv(spawnTestTraceFileEnv, traceFile)
102	t.Setenv("VEV_PERF_TRACE", "parent-trace.jsonl")
103
104	// Register cleanup before starting either subprocess. The release marker
105	// unblocks a launcher even when setup or an assertion fails, and PID files
106	// let cleanup terminate every helper that reached its acknowledgement.
107	t.Cleanup(func() {
108		_ = os.WriteFile(releaseFile, nil, 0o600)
109		for _, path := range []string{launcherFile, childFile} {
110			if err := terminateProcessFromFile(path); err != nil {
111				t.Errorf("clean up %s: %v", filepath.Base(path), err)
112			}
113		}
114	})
115
116	spawnResult := make(chan error, 1)
117	go func() {
118		spawnResult <- realSpawn()
119	}()
120
121	launcherPID := waitForProcessRecord(t, launcherFile)
122	daemonPID := waitForProcessRecord(t, childFile)
123
124	select {
125	case err := <-spawnResult:
126		t.Fatalf("realSpawn returned while launcher %d was still running: %v", launcherPID, err)
127	default:
128	}
129	if err := syscall.Kill(launcherPID, 0); err != nil {
130		t.Fatalf("launcher exited before release acknowledgement: %v", err)
131	}
132
133	if err := os.WriteFile(releaseFile, nil, 0o600); err != nil {
134		t.Fatalf("release launcher: %v", err)
135	}
136	select {
137	case err := <-spawnResult:
138		if err != nil {
139			t.Fatalf("realSpawn: %v", err)
140		}
141	case <-time.After(2 * time.Second):
142		t.Fatal("realSpawn did not return after launcher release")
143	}
144
145	if err := waitForProcessExit(launcherPID, 2*time.Second); err != nil {
146		t.Fatalf("launcher still exists after realSpawn returned: %v", err)
147	}
148	if err := syscall.Kill(daemonPID, 0); err != nil {
149		t.Fatalf("daemon exited with its launcher: %v", err)
150	}
151	traceEnv, err := os.ReadFile(traceFile)
152	if err != nil {
153		t.Fatalf("read daemon trace environment: %v", err)
154	}
155	if got := string(traceEnv); got != "" {
156		t.Fatalf("daemon inherited VEV_PERF_TRACE=%q", got)
157	}
158}
159
160func waitForProcessRecord(t *testing.T, path string) int {
161	t.Helper()
162	deadline := time.Now().Add(2 * time.Second)
163	for time.Now().Before(deadline) {
164		data, err := os.ReadFile(path)
165		if err == nil {
166			pid, err := strconv.Atoi(string(data))
167			if err != nil {
168				t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
169			}
170			return pid
171		}
172		if !errors.Is(err, os.ErrNotExist) {
173			t.Fatalf("read process record %s: %v", filepath.Base(path), err)
174		}
175		time.Sleep(10 * time.Millisecond)
176	}
177	t.Fatalf("process did not write %s", filepath.Base(path))
178	return 0
179}
180
181func waitForProcessExit(pid int, timeout time.Duration) error {
182	deadline := time.Now().Add(timeout)
183	for time.Now().Before(deadline) {
184		err := syscall.Kill(pid, 0)
185		if errors.Is(err, syscall.ESRCH) {
186			return nil
187		}
188		if err != nil && !errors.Is(err, syscall.EPERM) {
189			return err
190		}
191		time.Sleep(10 * time.Millisecond)
192	}
193	return fmt.Errorf("process %d did not exit within %s", pid, timeout)
194}
195
196func writeProcessRecord(path string) error {
197	tmp := path + ".tmp"
198	if err := os.WriteFile(tmp, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
199		return err
200	}
201	return os.Rename(tmp, path)
202}
203
204// blockBrokerLauncherHelper is a controlled, blocking launcher: it records its
205// PID and waits for the release marker (or a bounded fallback) so a test can
206// hold a launcher open and prove the spawn wait is bounded by its context.
207func blockBrokerLauncherHelper(record, release string) {
208	if err := writeProcessRecord(record); err != nil {
209		os.Exit(2)
210	}
211	deadline := time.Now().Add(30 * time.Second)
212	for time.Now().Before(deadline) {
213		_, statErr := os.Stat(release)
214		if statErr == nil {
215			break
216		}
217		if !errors.Is(statErr, os.ErrNotExist) {
218			os.Exit(2)
219		}
220		time.Sleep(10 * time.Millisecond)
221	}
222	os.Exit(0)
223}
224
225func terminateProcessFromFile(path string) error {
226	data, err := os.ReadFile(path)
227	if errors.Is(err, os.ErrNotExist) {
228		return nil
229	}
230	if err != nil {
231		return err
232	}
233	pid, err := strconv.Atoi(string(data))
234	if err != nil {
235		return err
236	}
237	if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
238		return err
239	}
240	return waitForProcessExit(pid, 2*time.Second)
241}
242
243func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
244	dir := filepath.Join(t.TempDir(), "vev")
245	const racers = 16
246
247	var wins atomic.Int32
248	var wg sync.WaitGroup
249	start := make(chan struct{})
250	releases := make(chan func(), racers)
251
252	for range racers {
253		wg.Go(func() {
254			<-start
255			release, acquired, err := acquireSpawnLock(dir)
256			if err != nil {
257				t.Errorf("acquireSpawnLock: %v", err)
258				return
259			}
260			if acquired {
261				wins.Add(1)
262				releases <- release
263			}
264		})
265	}
266
267	close(start)
268	wg.Wait()
269	close(releases)
270
271	if got := wins.Load(); got != 1 {
272		t.Fatalf("expected exactly one winner, got %d", got)
273	}
274	for release := range releases {
275		release()
276	}
277	// After release, the lock directory is gone and can be re-acquired.
278	release, acquired, err := acquireSpawnLock(dir)
279	if err != nil || !acquired {
280		t.Fatalf("re-acquire after release: acquired=%v err=%v", acquired, err)
281	}
282	release()
283}
284
285func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
286	dir := filepath.Join(t.TempDir(), "vev")
287	if err := os.Mkdir(dir, 0o700); err != nil {
288		t.Fatalf("seeding socket dir: %v", err)
289	}
290	lockPath := filepath.Join(dir, spawnLockName)
291	if err := os.Mkdir(lockPath, 0o700); err != nil {
292		t.Fatalf("seeding lock: %v", err)
293	}
294
295	// A fresh lock must NOT be taken over.
296	if _, acquired, err := acquireSpawnLock(dir); err != nil || acquired {
297		t.Fatalf("fresh lock taken over: acquired=%v err=%v", acquired, err)
298	}
299
300	// Backdate the lock beyond the stale threshold; now it is taken over.
301	old := time.Now().Add(-2 * staleLockAge)
302	if err := os.Chtimes(lockPath, old, old); err != nil {
303		t.Fatalf("backdating lock: %v", err)
304	}
305	release, acquired, err := acquireSpawnLock(dir)
306	if err != nil || !acquired {
307		t.Fatalf("stale lock not taken over: acquired=%v err=%v", acquired, err)
308	}
309	release()
310}
311
312func TestRetryAttemptsChecksOperationErrorBeforeDeadline(t *testing.T) {
313	attemptErr := errors.New("attempt failed")
314	calls := 0
315	_, err := retryAttempts(context.Background(), backoffConfig{}, func() (struct{}, bool, error) {
316		calls++
317		return struct{}{}, false, attemptErr
318	})
319	if !errors.Is(err, attemptErr) {
320		t.Fatalf("retryAttempts error = %v, want %v", err, attemptErr)
321	}
322	if calls != 1 {
323		t.Fatalf("attempt called %d times, want 1", calls)
324	}
325}
326
327func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) {
328	started := time.Now()
329	calls := 0
330	_, err := retryAttempts(context.Background(), backoffConfig{total: time.Second, max: time.Second}, func() (struct{}, bool, error) {
331		calls++
332		return struct{}{}, calls == 2, nil
333	})
334	if err != nil {
335		t.Fatalf("retryAttempts: %v", err)
336	}
337	if elapsed := time.Since(started); elapsed < 500*time.Microsecond {
338		t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed)
339	}
340}

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