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}