main ece7682

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

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