1//go:build linux
2
3package mcp
4
5import (
6 "os"
7 "strconv"
8 "sync"
9 "syscall"
10 "testing"
11 "time"
12)
13
14func after5s() <-chan time.Time { return time.After(5 * time.Second) }
15
16func TestParseProcStat(t *testing.T) {
17 cases := []struct {
18 name, in string
19 state byte
20 pgrp int
21 ok bool
22 }{
23 {"plain", "123 (bash) S 1 123 123 0 -1 4194560", 'S', 123, true},
24 {"zombie", "5 (fake-server) Z 1 77 77 0 -1 4", 'Z', 77, true},
25 {"spaces and parens", "9 (a) b (c)) R 2 42 42 0", 'R', 42, true},
26 {"truncated", "9 (x) S 1", 0, 0, false},
27 {"no comm", "garbage", 0, 0, false},
28 }
29 for _, c := range cases {
30 state, pg, ok := parseProcStat(c.in)
31 if state != c.state || pg != c.pgrp || ok != c.ok {
32 t.Errorf("%s: got (%c,%d,%v), want (%c,%d,%v)", c.name, state, pg, ok, c.state, c.pgrp, c.ok)
33 }
34 }
35}
36
37func TestLiveGroupMembersSeesSelfOnly(t *testing.T) {
38 // Our own process group has live members (us); a made-up one does not.
39 live, known := liveGroupMembers(os.Getpid())
40 if !known {
41 t.Skip("/proc not readable")
42 }
43 _ = live // our pgid is usually not our pid; only the empty case is stable
44 if live, known := liveGroupMembers(1 << 30); !known || live {
45 t.Errorf("unused group reported live=%v known=%v", live, known)
46 }
47}
48
49// ---- GO-020: group cleanup happens while the leader is still a zombie ----
50
51func TestStdioGroupCleanedBeforeLeaderIsReaped(t *testing.T) {
52 c, _ := connectFake(t, nil)
53 st := c.t.(*stdioTransport)
54 leader := st.cmd.Process.Pid
55
56 childPID, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
57 if err != nil {
58 t.Fatal(err)
59 }
60 t.Cleanup(func() { killPID(childPID) })
61 if !processAlive(childPID) {
62 t.Fatal("descendant not running")
63 }
64
65 type observation struct {
66 state byte
67 childAlive bool
68 pgidKept int
69 }
70 obs := make(chan observation, 1)
71 hook := func(pid int) {
72 if pid != leader {
73 return
74 }
75 var o observation
76 if raw, err := os.ReadFile("/proc/" + strconv.Itoa(leader) + "/stat"); err == nil {
77 o.state, _, _ = parseProcStat(string(raw))
78 }
79 o.childAlive = processAlive(childPID)
80 st.guard.mu.Lock()
81 o.pgidKept = st.guard.pgid
82 st.guard.mu.Unlock()
83 select {
84 case obs <- o:
85 default:
86 }
87 }
88 beforeReapHook.Store(&hook)
89 t.Cleanup(func() { beforeReapHook.Store(nil) })
90
91 // The server exits on its own.
92 _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
93 var o observation
94 select {
95 case o = <-obs:
96 case <-after5s():
97 t.Fatal("cleanup hook never ran: the leader was reaped before group cleanup")
98 }
99 if o.state != 'Z' {
100 t.Errorf("leader state at cleanup = %q, want Z (exited but not reaped, so its pid/pgid cannot be reused)", o.state)
101 }
102 if o.childAlive {
103 t.Error("descendant still alive when the leader was about to be reaped")
104 }
105 if o.pgidKept != 0 {
106 t.Errorf("process-group id %d retained after cleanup", o.pgidKept)
107 }
108 // And the leader is reaped afterwards.
109 eventually(t, "leader to be reaped", func() bool {
110 _, err := os.Stat("/proc/" + strconv.Itoa(leader))
111 return os.IsNotExist(err)
112 })
113}
114
115// While the leader is an unreaped zombie the pgid is reserved, so cleanup must
116// end with a group SIGKILL even when the (non-atomic) /proc scan sees no live
117// member: a descendant may have forked between the scan and its end.
118func TestReservedGroupIsKilledDespiteEmptyScan(t *testing.T) {
119 var mu sync.Mutex
120 var sigs []syscall.Signal
121 g := &processGuard{pgid: 1 << 30, leader: 1 << 30, zombie: true,
122 kill: func(pid int, sig syscall.Signal) error {
123 mu.Lock()
124 defer mu.Unlock()
125 if pid != -(1 << 30) {
126 t.Errorf("signal to %d, want group", pid)
127 }
128 sigs = append(sigs, sig)
129 return nil
130 }}
131 g.cleanup()
132 mu.Lock()
133 defer mu.Unlock()
134 if len(sigs) == 0 || sigs[len(sigs)-1] != syscall.SIGKILL {
135 t.Fatalf("signals = %v, want a final group SIGKILL", sigs)
136 }
137 if g.pgid != 0 {
138 t.Error("pgid retained after cleanup")
139 }
140}