detached dbcb2e8

fix(app): make daemon spawn test portable

commit
dbcb2e8f567c9b130da5cfc169e499ab2d82a442
author
brice · · 2026-07-24 16:29 UTC
parent
4c5416f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +121 -22
M internal/app/spawn_test.go +121 -22
@@ -7,7 +7,6 @@ import (
77 "os"
88 "path/filepath"
99 "strconv"
10- "strings"
1110 "sync"
1211 "sync/atomic"
1312 "syscall"
@@ -20,22 +19,43 @@ import (
2019
2120 var errDialFailed = errors.New("dial failed")
2221
23-const spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
22+const (
23+ spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+ spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25+ spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+)
2427
2528 func TestMain(m *testing.M) {
2629 if len(os.Args) == 2 {
2730 switch os.Args[1] {
2831 case "--daemon-launcher":
32+ launcherFile := os.Getenv(spawnTestLauncherFileEnv)
33+ if launcherFile != "" {
34+ if err := writeProcessRecord(launcherFile); err != nil {
35+ os.Exit(2)
36+ }
37+ }
2938 if err := Run(os.Args[1:]); err != nil {
3039 os.Exit(2)
3140 }
41+ if releaseFile := os.Getenv(spawnTestReleaseFileEnv); releaseFile != "" {
42+ deadline := time.Now().Add(5 * time.Second)
43+ for {
44+ if _, err := os.Stat(releaseFile); err == nil {
45+ break
46+ } else if !errors.Is(err, os.ErrNotExist) || !time.Now().Before(deadline) {
47+ os.Exit(2)
48+ }
49+ time.Sleep(10 * time.Millisecond)
50+ }
51+ }
3252 os.Exit(0)
3353 case "--daemon":
3454 path := os.Getenv(spawnTestChildFileEnv)
3555 if path == "" {
3656 os.Exit(2)
3757 }
38- if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
58+ if err := writeProcessRecord(path); err != nil {
3959 os.Exit(2)
4060 }
4161 for {
@@ -46,45 +66,124 @@ func TestMain(m *testing.M) {
4666 os.Exit(m.Run())
4767 }
4868
49-func TestRealSpawnReparentsDaemonBeforeReturning(t *testing.T) {
50- childFile := filepath.Join(t.TempDir(), "child")
69+func TestRealSpawnWaitsForLauncherExit(t *testing.T) {
70+ dir := t.TempDir()
71+ childFile := filepath.Join(dir, "daemon.pid")
72+ launcherFile := filepath.Join(dir, "launcher.pid")
73+ releaseFile := filepath.Join(dir, "release-launcher")
5174 t.Setenv(spawnTestChildFileEnv, childFile)
75+ t.Setenv(spawnTestLauncherFileEnv, launcherFile)
76+ t.Setenv(spawnTestReleaseFileEnv, releaseFile)
77+
78+ // Register cleanup before starting either subprocess. The release marker
79+ // unblocks a launcher even when setup or an assertion fails, and PID files
80+ // let cleanup terminate every helper that reached its acknowledgement.
81+ t.Cleanup(func() {
82+ _ = os.WriteFile(releaseFile, nil, 0o600)
83+ for _, path := range []string{launcherFile, childFile} {
84+ if err := terminateProcessFromFile(path); err != nil {
85+ t.Errorf("clean up %s: %v", filepath.Base(path), err)
86+ }
87+ }
88+ })
89+
90+ spawnResult := make(chan error, 1)
91+ go func() {
92+ spawnResult <- realSpawn()
93+ }()
5294
53- if err := realSpawn(); err != nil {
54- t.Fatalf("realSpawn: %v", err)
95+ launcherPID := waitForProcessRecord(t, launcherFile)
96+ daemonPID := waitForProcessRecord(t, childFile)
97+
98+ select {
99+ case err := <-spawnResult:
100+ t.Fatalf("realSpawn returned while launcher %d was still running: %v", launcherPID, err)
101+ default:
102+ }
103+ if err := syscall.Kill(launcherPID, 0); err != nil {
104+ t.Fatalf("launcher exited before release acknowledgement: %v", err)
55105 }
56106
57- var pid int
107+ if err := os.WriteFile(releaseFile, nil, 0o600); err != nil {
108+ t.Fatalf("release launcher: %v", err)
109+ }
110+ select {
111+ case err := <-spawnResult:
112+ if err != nil {
113+ t.Fatalf("realSpawn: %v", err)
114+ }
115+ case <-time.After(2 * time.Second):
116+ t.Fatal("realSpawn did not return after launcher release")
117+ }
118+
119+ if err := waitForProcessExit(launcherPID, 2*time.Second); err != nil {
120+ t.Fatalf("launcher still exists after realSpawn returned: %v", err)
121+ }
122+ if err := syscall.Kill(daemonPID, 0); err != nil {
123+ t.Fatalf("daemon exited with its launcher: %v", err)
124+ }
125+}
126+
127+func waitForProcessRecord(t *testing.T, path string) int {
128+ t.Helper()
58129 deadline := time.Now().Add(2 * time.Second)
59130 for time.Now().Before(deadline) {
60- data, err := os.ReadFile(childFile)
131+ data, err := os.ReadFile(path)
61132 if err == nil {
62- pid, err = strconv.Atoi(string(data))
133+ pid, err := strconv.Atoi(string(data))
63134 if err != nil {
64- t.Fatalf("parse child PID: %v", err)
135+ t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
65136 }
66- break
137+ return pid
67138 }
68139 if !errors.Is(err, os.ErrNotExist) {
69- t.Fatalf("read child process record: %v", err)
140+ t.Fatalf("read process record %s: %v", filepath.Base(path), err)
141+ }
142+ time.Sleep(10 * time.Millisecond)
143+ }
144+ t.Fatalf("process did not write %s", filepath.Base(path))
145+ return 0
146+}
147+
148+func waitForProcessExit(pid int, timeout time.Duration) error {
149+ deadline := time.Now().Add(timeout)
150+ for time.Now().Before(deadline) {
151+ err := syscall.Kill(pid, 0)
152+ if errors.Is(err, syscall.ESRCH) {
153+ return nil
154+ }
155+ if err != nil && !errors.Is(err, syscall.EPERM) {
156+ return err
70157 }
71158 time.Sleep(10 * time.Millisecond)
72159 }
73- if pid == 0 {
74- t.Fatal("daemon child did not report its ancestry")
160+ return fmt.Errorf("process %d did not exit within %s", pid, timeout)
161+}
162+
163+func writeProcessRecord(path string) error {
164+ tmp := path + ".tmp"
165+ if err := os.WriteFile(tmp, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
166+ return err
75167 }
76- t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })
168+ return os.Rename(tmp, path)
169+}
77170
78- status, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
171+func terminateProcessFromFile(path string) error {
172+ data, err := os.ReadFile(path)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
79176 if err != nil {
80- t.Fatalf("read daemon status: %v", err)
177+ return err
81178 }
82- if strings.Contains(string(status), fmt.Sprintf("PPid:\t%d\n", os.Getpid())) {
83- t.Fatalf("daemon PPID = client PID %d; daemon remains in the client process tree", os.Getpid())
179+ pid, err := strconv.Atoi(string(data))
180+ if err != nil {
181+ return err
84182 }
85- if err := syscall.Kill(pid, 0); err != nil {
86- t.Fatalf("daemon exited with its intermediate parent: %v", err)
183+ if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
184+ return err
87185 }
186+ return waitForProcessExit(pid, 2*time.Second)
88187 }
89188
90189 // fastBackoff keeps the retry loop snappy for tests.
@@ -7,7 +7,6 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "strconv"9 "strconv"
10- "strings"
11 "sync"10 "sync"
12 "sync/atomic"11 "sync/atomic"
13 "syscall"12 "syscall"
@@ -20,22 +19,43 @@ import (
20 19
21 var errDialFailed = errors.New("dial failed")20 var errDialFailed = errors.New("dial failed")
22 21
23-const spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"22+const (
23+ spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+ spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25+ spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+)
24 27
25 func TestMain(m *testing.M) {28 func TestMain(m *testing.M) {
26 if len(os.Args) == 2 {29 if len(os.Args) == 2 {
27 switch os.Args[1] {30 switch os.Args[1] {
28 case "--daemon-launcher":31 case "--daemon-launcher":
32+ launcherFile := os.Getenv(spawnTestLauncherFileEnv)
33+ if launcherFile != "" {
34+ if err := writeProcessRecord(launcherFile); err != nil {
35+ os.Exit(2)
36+ }
37+ }
29 if err := Run(os.Args[1:]); err != nil {38 if err := Run(os.Args[1:]); err != nil {
30 os.Exit(2)39 os.Exit(2)
31 }40 }
41+ if releaseFile := os.Getenv(spawnTestReleaseFileEnv); releaseFile != "" {
42+ deadline := time.Now().Add(5 * time.Second)
43+ for {
44+ if _, err := os.Stat(releaseFile); err == nil {
45+ break
46+ } else if !errors.Is(err, os.ErrNotExist) || !time.Now().Before(deadline) {
47+ os.Exit(2)
48+ }
49+ time.Sleep(10 * time.Millisecond)
50+ }
51+ }
32 os.Exit(0)52 os.Exit(0)
33 case "--daemon":53 case "--daemon":
34 path := os.Getenv(spawnTestChildFileEnv)54 path := os.Getenv(spawnTestChildFileEnv)
35 if path == "" {55 if path == "" {
36 os.Exit(2)56 os.Exit(2)
37 }57 }
38- if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {58+ if err := writeProcessRecord(path); err != nil {
39 os.Exit(2)59 os.Exit(2)
40 }60 }
41 for {61 for {
@@ -46,45 +66,124 @@ func TestMain(m *testing.M) {
46 os.Exit(m.Run())66 os.Exit(m.Run())
47 }67 }
48 68
49-func TestRealSpawnReparentsDaemonBeforeReturning(t *testing.T) {69+func TestRealSpawnWaitsForLauncherExit(t *testing.T) {
50- childFile := filepath.Join(t.TempDir(), "child")70+ dir := t.TempDir()
71+ childFile := filepath.Join(dir, "daemon.pid")
72+ launcherFile := filepath.Join(dir, "launcher.pid")
73+ releaseFile := filepath.Join(dir, "release-launcher")
51 t.Setenv(spawnTestChildFileEnv, childFile)74 t.Setenv(spawnTestChildFileEnv, childFile)
75+ t.Setenv(spawnTestLauncherFileEnv, launcherFile)
76+ t.Setenv(spawnTestReleaseFileEnv, releaseFile)
77+
78+ // Register cleanup before starting either subprocess. The release marker
79+ // unblocks a launcher even when setup or an assertion fails, and PID files
80+ // let cleanup terminate every helper that reached its acknowledgement.
81+ t.Cleanup(func() {
82+ _ = os.WriteFile(releaseFile, nil, 0o600)
83+ for _, path := range []string{launcherFile, childFile} {
84+ if err := terminateProcessFromFile(path); err != nil {
85+ t.Errorf("clean up %s: %v", filepath.Base(path), err)
86+ }
87+ }
88+ })
89+
90+ spawnResult := make(chan error, 1)
91+ go func() {
92+ spawnResult <- realSpawn()
93+ }()
52 94
53- if err := realSpawn(); err != nil {95+ launcherPID := waitForProcessRecord(t, launcherFile)
54- t.Fatalf("realSpawn: %v", err)96+ daemonPID := waitForProcessRecord(t, childFile)
97+
98+ select {
99+ case err := <-spawnResult:
100+ t.Fatalf("realSpawn returned while launcher %d was still running: %v", launcherPID, err)
101+ default:
102+ }
103+ if err := syscall.Kill(launcherPID, 0); err != nil {
104+ t.Fatalf("launcher exited before release acknowledgement: %v", err)
55 }105 }
56 106
57- var pid int107+ if err := os.WriteFile(releaseFile, nil, 0o600); err != nil {
108+ t.Fatalf("release launcher: %v", err)
109+ }
110+ select {
111+ case err := <-spawnResult:
112+ if err != nil {
113+ t.Fatalf("realSpawn: %v", err)
114+ }
115+ case <-time.After(2 * time.Second):
116+ t.Fatal("realSpawn did not return after launcher release")
117+ }
118+
119+ if err := waitForProcessExit(launcherPID, 2*time.Second); err != nil {
120+ t.Fatalf("launcher still exists after realSpawn returned: %v", err)
121+ }
122+ if err := syscall.Kill(daemonPID, 0); err != nil {
123+ t.Fatalf("daemon exited with its launcher: %v", err)
124+ }
125+}
126+
127+func waitForProcessRecord(t *testing.T, path string) int {
128+ t.Helper()
58 deadline := time.Now().Add(2 * time.Second)129 deadline := time.Now().Add(2 * time.Second)
59 for time.Now().Before(deadline) {130 for time.Now().Before(deadline) {
60- data, err := os.ReadFile(childFile)131+ data, err := os.ReadFile(path)
61 if err == nil {132 if err == nil {
62- pid, err = strconv.Atoi(string(data))133+ pid, err := strconv.Atoi(string(data))
63 if err != nil {134 if err != nil {
64- t.Fatalf("parse child PID: %v", err)135+ t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
65 }136 }
66- break137+ return pid
67 }138 }
68 if !errors.Is(err, os.ErrNotExist) {139 if !errors.Is(err, os.ErrNotExist) {
69- t.Fatalf("read child process record: %v", err)140+ t.Fatalf("read process record %s: %v", filepath.Base(path), err)
141+ }
142+ time.Sleep(10 * time.Millisecond)
143+ }
144+ t.Fatalf("process did not write %s", filepath.Base(path))
145+ return 0
146+}
147+
148+func waitForProcessExit(pid int, timeout time.Duration) error {
149+ deadline := time.Now().Add(timeout)
150+ for time.Now().Before(deadline) {
151+ err := syscall.Kill(pid, 0)
152+ if errors.Is(err, syscall.ESRCH) {
153+ return nil
154+ }
155+ if err != nil && !errors.Is(err, syscall.EPERM) {
156+ return err
70 }157 }
71 time.Sleep(10 * time.Millisecond)158 time.Sleep(10 * time.Millisecond)
72 }159 }
73- if pid == 0 {160+ return fmt.Errorf("process %d did not exit within %s", pid, timeout)
74- t.Fatal("daemon child did not report its ancestry")161+}
162+
163+func writeProcessRecord(path string) error {
164+ tmp := path + ".tmp"
165+ if err := os.WriteFile(tmp, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
166+ return err
75 }167 }
76- t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })168+ return os.Rename(tmp, path)
169+}
77 170
78- status, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))171+func terminateProcessFromFile(path string) error {
172+ data, err := os.ReadFile(path)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
79 if err != nil {176 if err != nil {
80- t.Fatalf("read daemon status: %v", err)177+ return err
81 }178 }
82- if strings.Contains(string(status), fmt.Sprintf("PPid:\t%d\n", os.Getpid())) {179+ pid, err := strconv.Atoi(string(data))
83- t.Fatalf("daemon PPID = client PID %d; daemon remains in the client process tree", os.Getpid())180+ if err != nil {
181+ return err
84 }182 }
85- if err := syscall.Kill(pid, 0); err != nil {183+ if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
86- t.Fatalf("daemon exited with its intermediate parent: %v", err)184+ return err
87 }185 }
186+ return waitForProcessExit(pid, 2*time.Second)
88 }187 }
89 188
90 // fastBackoff keeps the retry loop snappy for tests.189 // fastBackoff keeps the retry loop snappy for tests.

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