detached 4c5416f

fix(app): detach daemon with double fork

commit
4c5416faa2e59ba9b8492e47d7e4e292946e3f9a
author
brice · · 2026-07-24 16:09 UTC
parent
253fc6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +111 -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 +71 -0
@@ -3,10 +3,14 @@ package app
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "os"
78 "path/filepath"
9+ "strconv"
10+ "strings"
811 "sync"
912 "sync/atomic"
13+ "syscall"
1014 "testing"
1115 "time"
1216
@@ -16,6 +20,73 @@ import (
1620
1721 var errDialFailed = errors.New("dial failed")
1822
23+const spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+
25+func TestMain(m *testing.M) {
26+ if len(os.Args) == 2 {
27+ switch os.Args[1] {
28+ case "--daemon-launcher":
29+ if err := Run(os.Args[1:]); err != nil {
30+ os.Exit(2)
31+ }
32+ os.Exit(0)
33+ case "--daemon":
34+ path := os.Getenv(spawnTestChildFileEnv)
35+ if path == "" {
36+ os.Exit(2)
37+ }
38+ if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
39+ os.Exit(2)
40+ }
41+ for {
42+ time.Sleep(time.Hour)
43+ }
44+ }
45+ }
46+ os.Exit(m.Run())
47+}
48+
49+func TestRealSpawnReparentsDaemonBeforeReturning(t *testing.T) {
50+ childFile := filepath.Join(t.TempDir(), "child")
51+ t.Setenv(spawnTestChildFileEnv, childFile)
52+
53+ if err := realSpawn(); err != nil {
54+ t.Fatalf("realSpawn: %v", err)
55+ }
56+
57+ var pid int
58+ deadline := time.Now().Add(2 * time.Second)
59+ for time.Now().Before(deadline) {
60+ data, err := os.ReadFile(childFile)
61+ if err == nil {
62+ pid, err = strconv.Atoi(string(data))
63+ if err != nil {
64+ t.Fatalf("parse child PID: %v", err)
65+ }
66+ break
67+ }
68+ if !errors.Is(err, os.ErrNotExist) {
69+ t.Fatalf("read child process record: %v", err)
70+ }
71+ time.Sleep(10 * time.Millisecond)
72+ }
73+ if pid == 0 {
74+ t.Fatal("daemon child did not report its ancestry")
75+ }
76+ t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })
77+
78+ status, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
79+ if err != nil {
80+ t.Fatalf("read daemon status: %v", err)
81+ }
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())
84+ }
85+ if err := syscall.Kill(pid, 0); err != nil {
86+ t.Fatalf("daemon exited with its intermediate parent: %v", err)
87+ }
88+}
89+
1990 // fastBackoff keeps the retry loop snappy for tests.
2091 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
2192
@@ -3,10 +3,14 @@ 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"
10+ "strings"
8 "sync"11 "sync"
9 "sync/atomic"12 "sync/atomic"
13+ "syscall"
10 "testing"14 "testing"
11 "time"15 "time"
12 16
@@ -16,6 +20,73 @@ import (
16 20
17 var errDialFailed = errors.New("dial failed")21 var errDialFailed = errors.New("dial failed")
18 22
23+const spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24+
25+func TestMain(m *testing.M) {
26+ if len(os.Args) == 2 {
27+ switch os.Args[1] {
28+ case "--daemon-launcher":
29+ if err := Run(os.Args[1:]); err != nil {
30+ os.Exit(2)
31+ }
32+ os.Exit(0)
33+ case "--daemon":
34+ path := os.Getenv(spawnTestChildFileEnv)
35+ if path == "" {
36+ os.Exit(2)
37+ }
38+ if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
39+ os.Exit(2)
40+ }
41+ for {
42+ time.Sleep(time.Hour)
43+ }
44+ }
45+ }
46+ os.Exit(m.Run())
47+}
48+
49+func TestRealSpawnReparentsDaemonBeforeReturning(t *testing.T) {
50+ childFile := filepath.Join(t.TempDir(), "child")
51+ t.Setenv(spawnTestChildFileEnv, childFile)
52+
53+ if err := realSpawn(); err != nil {
54+ t.Fatalf("realSpawn: %v", err)
55+ }
56+
57+ var pid int
58+ deadline := time.Now().Add(2 * time.Second)
59+ for time.Now().Before(deadline) {
60+ data, err := os.ReadFile(childFile)
61+ if err == nil {
62+ pid, err = strconv.Atoi(string(data))
63+ if err != nil {
64+ t.Fatalf("parse child PID: %v", err)
65+ }
66+ break
67+ }
68+ if !errors.Is(err, os.ErrNotExist) {
69+ t.Fatalf("read child process record: %v", err)
70+ }
71+ time.Sleep(10 * time.Millisecond)
72+ }
73+ if pid == 0 {
74+ t.Fatal("daemon child did not report its ancestry")
75+ }
76+ t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })
77+
78+ status, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
79+ if err != nil {
80+ t.Fatalf("read daemon status: %v", err)
81+ }
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())
84+ }
85+ if err := syscall.Kill(pid, 0); err != nil {
86+ t.Fatalf("daemon exited with its intermediate parent: %v", err)
87+ }
88+}
89+
19 // fastBackoff keeps the retry loop snappy for tests.90 // fastBackoff keeps the retry loop snappy for tests.
20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}91 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
21 92

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