Skip to content

Commit be62d5e

Browse files
author
gitlayzer
committed
fix: close daemon lifecycle races
1 parent 8f80b34 commit be62d5e

3 files changed

Lines changed: 180 additions & 14 deletions

File tree

cmd/daemon.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,9 @@ var daemonCmd = &cobra.Command{
3939
if err := daemonstate.SaveState(os.Getpid()); err != nil {
4040
return fmt.Errorf("save daemon state: %w", err)
4141
}
42-
heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
43-
defer stopHeartbeat()
44-
go runDaemonHeartbeat(heartbeatCtx)
42+
stopHeartbeat := startDaemonHeartbeat(ctx, runDaemonHeartbeat)
4543
defer func() {
44+
stopHeartbeat()
4645
_ = daemonstate.DeleteStateForPID(os.Getpid())
4746
}()
4847

@@ -220,6 +219,23 @@ func runDaemonHeartbeat(ctx context.Context) {
220219
}
221220
}
222221

222+
func startDaemonHeartbeat(parent context.Context, run func(context.Context)) func() {
223+
ctx, cancel := context.WithCancel(parent)
224+
done := make(chan struct{})
225+
go func() {
226+
defer close(done)
227+
run(ctx)
228+
}()
229+
230+
var once sync.Once
231+
return func() {
232+
once.Do(func() {
233+
cancel()
234+
<-done
235+
})
236+
}
237+
}
238+
223239
func runDaemonTunnel(ctx context.Context, sess session.TunnelSession) {
224240
reconnectTimer := time.NewTimer(time.Hour)
225241
if !reconnectTimer.Stop() {

cmd/daemon_launch.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,25 +69,34 @@ func ensureDaemonRunning() error {
6969
return fmt.Errorf("start daemon: %w", err)
7070
}
7171
_ = logFile.Close()
72+
return waitForDaemonStartup(cmd, 8*time.Second, 250*time.Millisecond, daemonstate.Alive)
73+
}
74+
75+
func waitForDaemonStartup(cmd *exec.Cmd, timeout, pollInterval time.Duration, alive func() bool) error {
76+
waitResult := make(chan error, 1)
77+
go func() {
78+
waitResult <- cmd.Wait()
79+
}()
7280

73-
timer := time.NewTimer(8 * time.Second)
81+
timer := time.NewTimer(timeout)
7482
defer timer.Stop()
75-
ticker := time.NewTicker(250 * time.Millisecond)
83+
ticker := time.NewTicker(pollInterval)
7684
defer ticker.Stop()
7785
for {
78-
if daemonstate.Alive() {
79-
_ = cmd.Process.Release()
86+
if alive() {
8087
return nil
8188
}
82-
83-
if !session.ProcessAlive(cmd.Process.Pid) {
84-
_ = cmd.Process.Release()
85-
return fmt.Errorf("daemon exited before publishing state")
86-
}
8789
select {
90+
case err := <-waitResult:
91+
if alive() {
92+
return nil
93+
}
94+
if err != nil {
95+
return fmt.Errorf("daemon exited before publishing state: %w", err)
96+
}
97+
return fmt.Errorf("daemon exited before publishing state")
8898
case <-timer.C:
89-
_ = cmd.Process.Release()
90-
return fmt.Errorf("daemon did not publish liveness within 8s")
99+
return fmt.Errorf("daemon did not publish liveness within %s", timeout)
91100
case <-ticker.C:
92101
}
93102
}

cmd/daemon_lifecycle_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"os/exec"
8+
"strconv"
9+
"strings"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
daemonstate "github.com/labring/sealtun/pkg/daemon"
15+
)
16+
17+
func TestDaemonHeartbeatCleanupWaitsBeforeDeletingState(t *testing.T) {
18+
home := t.TempDir()
19+
t.Setenv("HOME", home)
20+
21+
const pid = 4242
22+
if err := daemonstate.SaveState(pid); err != nil {
23+
t.Fatalf("save daemon state: %v", err)
24+
}
25+
26+
heartbeatStarted := make(chan struct{})
27+
releaseHeartbeat := make(chan struct{})
28+
stopAndWait := startDaemonHeartbeat(context.Background(), func(context.Context) {
29+
close(heartbeatStarted)
30+
<-releaseHeartbeat
31+
if err := daemonstate.TouchStateForPID(pid); err != nil {
32+
t.Errorf("touch daemon state: %v", err)
33+
}
34+
})
35+
<-heartbeatStarted
36+
37+
cleanupDone := make(chan struct{})
38+
go func() {
39+
stopAndWait()
40+
if err := daemonstate.DeleteStateForPID(pid); err != nil {
41+
t.Errorf("delete daemon state: %v", err)
42+
}
43+
close(cleanupDone)
44+
}()
45+
46+
select {
47+
case <-cleanupDone:
48+
t.Fatal("cleanup returned before the active heartbeat exited")
49+
case <-time.After(50 * time.Millisecond):
50+
}
51+
if _, err := daemonstate.LoadState(); err != nil {
52+
t.Fatalf("state must remain until heartbeat exits: %v", err)
53+
}
54+
55+
close(releaseHeartbeat)
56+
select {
57+
case <-cleanupDone:
58+
case <-time.After(time.Second):
59+
t.Fatal("cleanup did not finish after heartbeat exited")
60+
}
61+
if _, err := daemonstate.LoadState(); !os.IsNotExist(err) {
62+
t.Fatalf("daemon state should be deleted after heartbeat join, got %v", err)
63+
}
64+
}
65+
66+
func TestWaitForDaemonStartupReportsEarlyExitAndReapsChild(t *testing.T) {
67+
cmd := daemonLaunchHelperCommand(t, 0, 23)
68+
if err := cmd.Start(); err != nil {
69+
t.Fatalf("start helper: %v", err)
70+
}
71+
72+
started := time.Now()
73+
err := waitForDaemonStartup(cmd, 2*time.Second, 10*time.Millisecond, func() bool { return false })
74+
if err == nil || !strings.Contains(err.Error(), "exited before publishing state") {
75+
t.Fatalf("expected early-exit error, got %v", err)
76+
}
77+
if elapsed := time.Since(started); elapsed >= time.Second {
78+
t.Fatalf("early exit took too long to detect: %s", elapsed)
79+
}
80+
if err := cmd.Process.Signal(os.Interrupt); !errors.Is(err, os.ErrProcessDone) {
81+
t.Fatalf("child should be reaped, signal returned %v", err)
82+
}
83+
}
84+
85+
func TestWaitForDaemonStartupReturnsWithoutWaitingForLiveChildAndReapsLater(t *testing.T) {
86+
cmd := daemonLaunchHelperCommand(t, 500*time.Millisecond, 0)
87+
if err := cmd.Start(); err != nil {
88+
t.Fatalf("start helper: %v", err)
89+
}
90+
91+
var checks atomic.Int32
92+
started := time.Now()
93+
err := waitForDaemonStartup(cmd, 2*time.Second, 10*time.Millisecond, func() bool {
94+
return checks.Add(1) >= 1
95+
})
96+
if err != nil {
97+
t.Fatalf("waitForDaemonStartup returned error: %v", err)
98+
}
99+
if elapsed := time.Since(started); elapsed >= 250*time.Millisecond {
100+
t.Fatalf("successful launch waited for child exit: %s", elapsed)
101+
}
102+
103+
deadline := time.Now().Add(2 * time.Second)
104+
for {
105+
err := cmd.Process.Signal(os.Interrupt)
106+
if errors.Is(err, os.ErrProcessDone) {
107+
break
108+
}
109+
if time.Now().After(deadline) {
110+
t.Fatalf("child was not reaped after exit, last signal error: %v", err)
111+
}
112+
time.Sleep(10 * time.Millisecond)
113+
}
114+
}
115+
116+
func daemonLaunchHelperCommand(t *testing.T, delay time.Duration, exitCode int) *exec.Cmd {
117+
t.Helper()
118+
cmd := exec.Command(os.Args[0], "-test.run=^TestDaemonLaunchHelperProcess$")
119+
cmd.Env = append(os.Environ(),
120+
"GO_WANT_DAEMON_LAUNCH_HELPER=1",
121+
"DAEMON_LAUNCH_HELPER_DELAY="+delay.String(),
122+
"DAEMON_LAUNCH_HELPER_EXIT="+strconv.Itoa(exitCode),
123+
)
124+
return cmd
125+
}
126+
127+
func TestDaemonLaunchHelperProcess(t *testing.T) {
128+
if os.Getenv("GO_WANT_DAEMON_LAUNCH_HELPER") != "1" {
129+
return
130+
}
131+
delay, err := time.ParseDuration(os.Getenv("DAEMON_LAUNCH_HELPER_DELAY"))
132+
if err != nil {
133+
os.Exit(98)
134+
}
135+
time.Sleep(delay)
136+
exitCode, err := strconv.Atoi(os.Getenv("DAEMON_LAUNCH_HELPER_EXIT"))
137+
if err != nil {
138+
os.Exit(99)
139+
}
140+
os.Exit(exitCode)
141+
}

0 commit comments

Comments
 (0)