Skip to content

Commit 7b49ac6

Browse files
committed
fix: stop a forking service by its PIDFile daemon, not the exited launcher
1 parent a81c1a3 commit 7b49ac6

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

internal/service/forking_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/mirkobrombin/atom/internal/reaper"
9+
)
10+
11+
// TestForkingStopKillsDaemonNotLauncher covers R-SINIT6: a Type=forking service's
12+
// launcher fork()s the real daemon and exits, recording the daemon's pid in
13+
// PIDFile=. Before the fix, Stop signalled the (already dead) launcher and left the
14+
// daemon running unsupervised; now Stop reads PIDFile and stops the daemon itself.
15+
func TestForkingStopKillsDaemonNotLauncher(t *testing.T) {
16+
requireBins(t, "/bin/sh", "/bin/sleep")
17+
reg := reaper.NewRegistry()
18+
rp := reaper.NewReaper(reg)
19+
go rp.Run()
20+
defer rp.Stop()
21+
22+
old := ReaperWait
23+
ReaperWait = reg.Wait
24+
defer func() { ReaperWait = old }()
25+
26+
pidfile := filepath.Join(t.TempDir(), "daemon.pid")
27+
// The launcher backgrounds a long sleep (the "daemon"), writes its pid, and exits.
28+
line := "/bin/sh -c 'sleep 100 & echo $! > " + pidfile + "'"
29+
s := New(Config{
30+
Name: "forky.service",
31+
Type: TypeForking,
32+
PIDFile: pidfile,
33+
ExecStart: []ExecCommand{cmd(t, line)},
34+
})
35+
w := &sinkWriter{}
36+
s.Stdout = w
37+
s.Stderr = w
38+
39+
if err := s.Start(context.Background()); err != nil {
40+
t.Fatalf("Start (forking): %v", err)
41+
}
42+
s.mu.Lock()
43+
daemon := s.forkedPID
44+
s.mu.Unlock()
45+
if daemon <= 0 {
46+
t.Fatalf("forkedPID not learned from PIDFile (got %d)", daemon)
47+
}
48+
if !pidAlive(daemon) {
49+
t.Fatalf("daemon pid %d should be alive after start", daemon)
50+
}
51+
52+
if err := s.Stop(context.Background()); err != nil {
53+
t.Fatalf("Stop: %v", err)
54+
}
55+
if pidAlive(daemon) {
56+
t.Fatalf("daemon pid %d still alive after Stop: Stop hit the dead launcher, not the daemon (R-SINIT6)", daemon)
57+
}
58+
}

internal/service/runtime.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os/user"
1010
"path/filepath"
1111
"strconv"
12+
"strings"
1213
"sync"
1314
"syscall"
1415
"time"
@@ -89,6 +90,11 @@ type Service struct {
8990

9091
cred *syscall.Credential // resolved User=/Group=, applied to spawned procs
9192

93+
// forkedPID is the daemon a Type=forking launcher fork()ed, learned from
94+
// PIDFile= after the launcher exits. Stop/kill target this, not the dead
95+
// launcher (R-SINIT6).
96+
forkedPID int
97+
9298
// Socket activation: if set, the main process is spawned via the sd-exec
9399
// trampoline with these listening fds handed over as LISTEN_FDS.
94100
Listeners []*socketact.Listener
@@ -233,6 +239,14 @@ func (s *Service) startLongRunning(ctx context.Context) error {
233239
s.setState(Failed)
234240
return fmt.Errorf("%s: forking launcher failed", s.cfg.Name)
235241
}
242+
// The launcher has exited; the real daemon is whatever it fork()ed and wrote
243+
// to PIDFile=. Learn that pid so Stop/kill hit the daemon, not the dead
244+
// launcher (R-SINIT6). No PIDFile -> we cannot track it (best effort).
245+
if pid := s.readPIDFile(ctx); pid > 0 {
246+
s.mu.Lock()
247+
s.forkedPID = pid
248+
s.mu.Unlock()
249+
}
236250
s.setState(Active)
237251
s.runPost(ctx)
238252
return nil
@@ -290,12 +304,53 @@ func (s *Service) startNotify(ctx context.Context) error {
290304
func (s *Service) killMain() {
291305
s.mu.Lock()
292306
main := s.main
307+
forked := s.forkedPID
293308
s.mu.Unlock()
309+
if forked > 0 {
310+
_ = syscall.Kill(forked, syscall.SIGKILL) // Type=forking: the daemon, not the launcher
311+
return
312+
}
294313
if main != nil && main.Process != nil {
295314
_ = main.Process.Kill()
296315
}
297316
}
298317

318+
// readPIDFile reads PIDFile= for a Type=forking service. The daemon may write it a
319+
// moment after the launcher exits, so we retry briefly (bounded by ctx).
320+
func (s *Service) readPIDFile(ctx context.Context) int {
321+
if s.cfg.PIDFile == "" {
322+
return 0
323+
}
324+
for i := 0; i < 20; i++ {
325+
if b, err := os.ReadFile(s.cfg.PIDFile); err == nil {
326+
if pid, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && pid > 0 {
327+
return pid
328+
}
329+
}
330+
select {
331+
case <-ctx.Done():
332+
return 0
333+
case <-time.After(50 * time.Millisecond):
334+
}
335+
}
336+
return 0
337+
}
338+
339+
// pidAlive reports whether pid still exists (signal 0 probes without delivering).
340+
func pidAlive(pid int) bool { return pid > 0 && syscall.Kill(pid, 0) == nil }
341+
342+
// waitPIDGone polls until pid is reaped or the timeout elapses; returns true if gone.
343+
func waitPIDGone(pid int, timeout time.Duration) bool {
344+
deadline := time.Now().Add(timeout)
345+
for time.Now().Before(deadline) {
346+
if !pidAlive(pid) {
347+
return true
348+
}
349+
time.Sleep(20 * time.Millisecond)
350+
}
351+
return !pidAlive(pid)
352+
}
353+
299354
// spawnMain starts ExecStart[0], records the process, and launches a waiter
300355
// that captures the exit and closes the per-run done channel. For Type=notify
301356
// it always passes NOTIFY_SOCKET, so a restarted instance can signal readiness.
@@ -515,14 +570,24 @@ func (s *Service) Stop(ctx context.Context) error {
515570
s.stopping = true
516571
main := s.main
517572
exited := s.exited
573+
forked := s.forkedPID
518574
s.mu.Unlock()
519575

520576
s.setState(Deactivating)
521577
for _, ec := range s.cfg.ExecStop {
522578
_ = s.runToCompletion(ctx, ec)
523579
}
524580

525-
if main != nil && main.Process != nil && exited != nil {
581+
if forked > 0 {
582+
// Type=forking: the launcher already exited; stop the daemon it fork()ed
583+
// (from PIDFile), not the dead launcher (R-SINIT6). There is no exit channel
584+
// for a process we did not spawn, so poll for it to be reaped after each signal.
585+
_ = syscall.Kill(forked, syscall.SIGTERM)
586+
if !waitPIDGone(forked, DefaultStopTimeout) {
587+
_ = syscall.Kill(forked, syscall.SIGKILL)
588+
waitPIDGone(forked, DefaultStopTimeout)
589+
}
590+
} else if main != nil && main.Process != nil && exited != nil {
526591
_ = main.Process.Signal(syscall.SIGTERM)
527592
select {
528593
case <-exited:

0 commit comments

Comments
 (0)