detached cd0e8bd

Merge pull request #126 from bnema/fix/daemon-double-fork

fix(app): detach daemon with double fork
commit
cd0e8bd837e3a965c80ad4c49d9725098a5bc461
author
bnema · · 2026-07-24 17:02 UTC
committer
GitHub · · 2026-07-24 17:02 UTC
merge of
07b5366 + dbcb2e8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +210 -6
M internal/app/run.go +5 -0
@@ -61,6 +61,7 @@ const (
6161 kindKill
6262 kindCmd
6363 kindDaemon
64+ kindDaemonLauncher
6465 kindStdio
6566 kindUDPBootstrap
6667 kindUDPProxy
@@ -141,6 +142,8 @@ func parseArgs(args []string) (command, error) {
141142 switch args[0] {
142143 case "--daemon":
143144 return command{kind: kindDaemon}, nil
145+ case "--daemon-launcher":
146+ return command{kind: kindDaemonLauncher}, nil
144147 case "_stdio":
145148 if len(args) > 2 {
146149 return command{}, usagef("`_stdio` accepts at most one session name")
@@ -244,6 +247,8 @@ func dispatch(ctx context.Context, cmd command) error {
244247 return nil
245248 case kindDaemon:
246249 return runDaemon()
250+ case kindDaemonLauncher:
251+ return runDaemonLauncher()
247252 case kindStdio:
248253 return runStdio(ctx)
249254 case kindUDPBootstrap:
@@ -61,6 +61,7 @@ const (
61 kindKill61 kindKill
62 kindCmd62 kindCmd
63 kindDaemon63 kindDaemon
64+ kindDaemonLauncher
64 kindStdio65 kindStdio
65 kindUDPBootstrap66 kindUDPBootstrap
66 kindUDPProxy67 kindUDPProxy
@@ -141,6 +142,8 @@ func parseArgs(args []string) (command, error) {
141 switch args[0] {142 switch args[0] {
142 case "--daemon":143 case "--daemon":
143 return command{kind: kindDaemon}, nil144 return command{kind: kindDaemon}, nil
145+ case "--daemon-launcher":
146+ return command{kind: kindDaemonLauncher}, nil
144 case "_stdio":147 case "_stdio":
145 if len(args) > 2 {148 if len(args) > 2 {
146 return command{}, usagef("`_stdio` accepts at most one session name")149 return command{}, usagef("`_stdio` accepts at most one session name")
@@ -244,6 +247,8 @@ func dispatch(ctx context.Context, cmd command) error {
244 return nil247 return nil
245 case kindDaemon:248 case kindDaemon:
246 return runDaemon()249 return runDaemon()
250+ case kindDaemonLauncher:
251+ return runDaemonLauncher()
247 case kindStdio:252 case kindStdio:
248 return runStdio(ctx)253 return runStdio(ctx)
249 case kindUDPBootstrap:254 case kindUDPBootstrap:
M internal/app/spawn.go +35 -6
@@ -1,6 +1,7 @@
11 package app
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "fmt"
@@ -152,10 +153,9 @@ func realDial(ctx context.Context, dir string) (ports.Transport, error) {
152153 return ipc.DialContext(ctx, dir)
153154 }
154155
155-// realSpawn re-execs this binary as a detached daemon: a new session
156-// (Setsid) with stdio bound to /dev/null so it survives the client's exit
157-// and never writes to the client's terminal. It logs via slog to the shared
158-// log file instead.
156+// realSpawn re-execs this binary through a short-lived launcher. Waiting for
157+// that launcher to exit ensures the daemon has been re-parented before the
158+// client continues, keeping process-tree termination from reaching it.
159159 func realSpawn() error {
160160 exePath, err := selfExePath()
161161 if err != nil {
@@ -168,6 +168,37 @@ func realSpawn() error {
168168 }
169169 defer func() { _ = devNull.Close() }()
170170
171+ var stderr bytes.Buffer
172+ cmd := exec.Command(exePath, "--daemon-launcher")
173+ cmd.Dir = platform.DirOrHome("")
174+ cmd.Stdin = devNull
175+ cmd.Stdout = devNull
176+ cmd.Stderr = &stderr
177+ if err := cmd.Run(); err != nil {
178+ if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 {
179+ return fmt.Errorf("%w: %s", err, detail)
180+ }
181+ return err
182+ }
183+ slog.Info("daemon process detached")
184+ return nil
185+}
186+
187+// runDaemonLauncher is the intermediate half of the double-fork. It starts
188+// the long-lived daemon in a new session and exits immediately; realSpawn
189+// waits for this exit so the daemon is no longer descended from the client.
190+func runDaemonLauncher() error {
191+ exePath, err := selfExePath()
192+ if err != nil {
193+ return fmt.Errorf("resolving executable path: %w", err)
194+ }
195+
196+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
197+ if err != nil {
198+ return fmt.Errorf("opening %s: %w", os.DevNull, err)
199+ }
200+ defer func() { _ = devNull.Close() }()
201+
171202 cmd := exec.Command(exePath, "--daemon")
172203 cmd.Dir = platform.DirOrHome("")
173204 cmd.Stdin = devNull
@@ -177,7 +208,5 @@ func realSpawn() error {
177208 if err := cmd.Start(); err != nil {
178209 return err
179210 }
180- slog.Info("daemon process started", "pid", cmd.Process.Pid)
181- // Detach: we neither wait for nor signal the daemon after launch.
182211 return cmd.Process.Release()
183212 }
@@ -1,6 +1,7 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
@@ -152,10 +153,9 @@ func realDial(ctx context.Context, dir string) (ports.Transport, error) {
152 return ipc.DialContext(ctx, dir)153 return ipc.DialContext(ctx, dir)
153 }154 }
154 155
155-// realSpawn re-execs this binary as a detached daemon: a new session156+// realSpawn re-execs this binary through a short-lived launcher. Waiting for
156-// (Setsid) with stdio bound to /dev/null so it survives the client's exit157+// that launcher to exit ensures the daemon has been re-parented before the
157-// and never writes to the client's terminal. It logs via slog to the shared158+// client continues, keeping process-tree termination from reaching it.
158-// log file instead.
159 func realSpawn() error {159 func realSpawn() error {
160 exePath, err := selfExePath()160 exePath, err := selfExePath()
161 if err != nil {161 if err != nil {
@@ -168,6 +168,37 @@ func realSpawn() error {
168 }168 }
169 defer func() { _ = devNull.Close() }()169 defer func() { _ = devNull.Close() }()
170 170
171+ var stderr bytes.Buffer
172+ cmd := exec.Command(exePath, "--daemon-launcher")
173+ cmd.Dir = platform.DirOrHome("")
174+ cmd.Stdin = devNull
175+ cmd.Stdout = devNull
176+ cmd.Stderr = &stderr
177+ if err := cmd.Run(); err != nil {
178+ if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 {
179+ return fmt.Errorf("%w: %s", err, detail)
180+ }
181+ return err
182+ }
183+ slog.Info("daemon process detached")
184+ return nil
185+}
186+
187+// runDaemonLauncher is the intermediate half of the double-fork. It starts
188+// the long-lived daemon in a new session and exits immediately; realSpawn
189+// waits for this exit so the daemon is no longer descended from the client.
190+func runDaemonLauncher() error {
191+ exePath, err := selfExePath()
192+ if err != nil {
193+ return fmt.Errorf("resolving executable path: %w", err)
194+ }
195+
196+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
197+ if err != nil {
198+ return fmt.Errorf("opening %s: %w", os.DevNull, err)
199+ }
200+ defer func() { _ = devNull.Close() }()
201+
171 cmd := exec.Command(exePath, "--daemon")202 cmd := exec.Command(exePath, "--daemon")
172 cmd.Dir = platform.DirOrHome("")203 cmd.Dir = platform.DirOrHome("")
173 cmd.Stdin = devNull204 cmd.Stdin = devNull
@@ -177,7 +208,5 @@ func realSpawn() error {
177 if err := cmd.Start(); err != nil {208 if err := cmd.Start(); err != nil {
178 return err209 return err
179 }210 }
180- slog.Info("daemon process started", "pid", cmd.Process.Pid)
181- // Detach: we neither wait for nor signal the daemon after launch.
182 return cmd.Process.Release()211 return cmd.Process.Release()
183 }212 }
M internal/app/spawn_test.go +170 -0
@@ -3,10 +3,13 @@ package app
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "os"
78 "path/filepath"
9+ "strconv"
810 "sync"
911 "sync/atomic"
12+ "syscall"
1013 "testing"
1114 "time"
1215
@@ -16,6 +19,173 @@ import (
1619
1720 var errDialFailed = errors.New("dial failed")
1821
22+const (
23+ spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+ spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25+ spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+)
27+
28+func TestMain(m *testing.M) {
29+ if len(os.Args) == 2 {
30+ switch os.Args[1] {
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+ }
38+ if err := Run(os.Args[1:]); err != nil {
39+ os.Exit(2)
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+ }
52+ os.Exit(0)
53+ case "--daemon":
54+ path := os.Getenv(spawnTestChildFileEnv)
55+ if path == "" {
56+ os.Exit(2)
57+ }
58+ if err := writeProcessRecord(path); err != nil {
59+ os.Exit(2)
60+ }
61+ for {
62+ time.Sleep(time.Hour)
63+ }
64+ }
65+ }
66+ os.Exit(m.Run())
67+}
68+
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")
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+ }()
94+
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)
105+ }
106+
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()
129+ deadline := time.Now().Add(2 * time.Second)
130+ for time.Now().Before(deadline) {
131+ data, err := os.ReadFile(path)
132+ if err == nil {
133+ pid, err := strconv.Atoi(string(data))
134+ if err != nil {
135+ t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
136+ }
137+ return pid
138+ }
139+ if !errors.Is(err, os.ErrNotExist) {
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
157+ }
158+ time.Sleep(10 * time.Millisecond)
159+ }
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
167+ }
168+ return os.Rename(tmp, path)
169+}
170+
171+func terminateProcessFromFile(path string) error {
172+ data, err := os.ReadFile(path)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
176+ if err != nil {
177+ return err
178+ }
179+ pid, err := strconv.Atoi(string(data))
180+ if err != nil {
181+ return err
182+ }
183+ if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
184+ return err
185+ }
186+ return waitForProcessExit(pid, 2*time.Second)
187+}
188+
19189 // fastBackoff keeps the retry loop snappy for tests.
20190 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
21191
@@ -3,10 +3,13 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
9+ "strconv"
8 "sync"10 "sync"
9 "sync/atomic"11 "sync/atomic"
12+ "syscall"
10 "testing"13 "testing"
11 "time"14 "time"
12 15
@@ -16,6 +19,173 @@ import (
16 19
17 var errDialFailed = errors.New("dial failed")20 var errDialFailed = errors.New("dial failed")
18 21
22+const (
23+ spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+ spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25+ spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+)
27+
28+func TestMain(m *testing.M) {
29+ if len(os.Args) == 2 {
30+ switch os.Args[1] {
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+ }
38+ if err := Run(os.Args[1:]); err != nil {
39+ os.Exit(2)
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+ }
52+ os.Exit(0)
53+ case "--daemon":
54+ path := os.Getenv(spawnTestChildFileEnv)
55+ if path == "" {
56+ os.Exit(2)
57+ }
58+ if err := writeProcessRecord(path); err != nil {
59+ os.Exit(2)
60+ }
61+ for {
62+ time.Sleep(time.Hour)
63+ }
64+ }
65+ }
66+ os.Exit(m.Run())
67+}
68+
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")
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+ }()
94+
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)
105+ }
106+
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()
129+ deadline := time.Now().Add(2 * time.Second)
130+ for time.Now().Before(deadline) {
131+ data, err := os.ReadFile(path)
132+ if err == nil {
133+ pid, err := strconv.Atoi(string(data))
134+ if err != nil {
135+ t.Fatalf("parse process record %s: %v", filepath.Base(path), err)
136+ }
137+ return pid
138+ }
139+ if !errors.Is(err, os.ErrNotExist) {
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
157+ }
158+ time.Sleep(10 * time.Millisecond)
159+ }
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
167+ }
168+ return os.Rename(tmp, path)
169+}
170+
171+func terminateProcessFromFile(path string) error {
172+ data, err := os.ReadFile(path)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
176+ if err != nil {
177+ return err
178+ }
179+ pid, err := strconv.Atoi(string(data))
180+ if err != nil {
181+ return err
182+ }
183+ if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
184+ return err
185+ }
186+ return waitForProcessExit(pid, 2*time.Second)
187+}
188+
19 // fastBackoff keeps the retry loop snappy for tests.189 // fastBackoff keeps the retry loop snappy for tests.
20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}190 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
21 191

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