1//go:build !windows
2
3package mcp
4
5import (
6 "strconv"
7 "sync"
8 "syscall"
9 "testing"
10)
11
12// fakeKill records signals and answers from a script.
13type fakeKill struct {
14 mu sync.Mutex
15 calls []int // pids passed, in order (negative = group)
16 err error
17}
18
19func (f *fakeKill) kill(pid int, _ syscall.Signal) error {
20 f.mu.Lock()
21 defer f.mu.Unlock()
22 f.calls = append(f.calls, pid)
23 return f.err
24}
25
26func (f *fakeKill) count() int {
27 f.mu.Lock()
28 defer f.mu.Unlock()
29 return len(f.calls)
30}
31
32func newGuardForTest(pgid int, k *fakeKill) *processGuard {
33 return &processGuard{pgid: pgid, kill: k.kill}
34}
35
36func TestGuardForgetsGroupOnceGone(t *testing.T) {
37 k := &fakeKill{err: syscall.ESRCH}
38 g := newGuardForTest(4242, k)
39 if g.alive() {
40 t.Fatal("ESRCH means the group is gone")
41 }
42 before := k.count()
43 // A gone group is never probed or signalled again: its number may belong
44 // to somebody else by now.
45 g.terminate(false)
46 g.terminate(true)
47 if g.alive() {
48 t.Error("alive after gone")
49 }
50 g.cleanup()
51 if k.count() != before {
52 t.Errorf("signals sent to a group already observed gone: %v", k.calls)
53 }
54}
55
56func TestGuardCleanupIsOneShot(t *testing.T) {
57 k := &fakeKill{} // every probe/signal "succeeds": the group never empties
58 g := newGuardForTest(4243, k)
59 g.cleanup() // escalates SIGTERM then SIGKILL, then gives up
60 after := k.count()
61 if after == 0 {
62 t.Fatal("cleanup sent nothing to a live group")
63 }
64 for i := 0; i < 3; i++ {
65 g.cleanup()
66 g.terminate(true)
67 g.alive()
68 }
69 if k.count() != after {
70 t.Errorf("signals after cleanup: %d more", k.count()-after)
71 }
72 if g.pgid != 0 {
73 t.Error("process group id retained after cleanup")
74 }
75}
76
77func TestGuardNoSignalWhenAlreadyEmpty(t *testing.T) {
78 k := &fakeKill{err: syscall.ESRCH}
79 g := newGuardForTest(4244, k)
80 g.cleanup()
81 for _, pid := range k.calls {
82 if pid != -4244 {
83 t.Errorf("unexpected target %d", pid)
84 }
85 }
86 // Only the liveness probe ran, and nothing after it.
87 if k.count() != 1 {
88 t.Errorf("calls = %v, want a single probe", k.calls)
89 }
90}
91
92func TestStdioRepeatedCloseDoesNotSignalAgain(t *testing.T) {
93 cfg, _ := fakeStdioConfig(t, nil)
94 st, err := startStdio("x", cfg, "")
95 if err != nil {
96 t.Fatal(err)
97 }
98 if err := st.close(); err != nil {
99 t.Fatal(err)
100 }
101 k := &fakeKill{}
102 st.guard.mu.Lock()
103 st.guard.kill = k.kill
104 st.guard.mu.Unlock()
105 for i := 0; i < 3; i++ {
106 if err := st.close(); err != nil {
107 t.Fatal(err)
108 }
109 }
110 if k.count() != 0 {
111 t.Errorf("late Close signalled a saved process-group id: %v", k.calls)
112 }
113}
114
115func TestStdioNaturalExitCleansDescendants(t *testing.T) {
116 c, _ := connectFake(t, nil)
117 pid, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
118 if err != nil {
119 t.Fatal(err)
120 }
121 t.Cleanup(func() { killPID(pid) })
122 if !processAlive(pid) {
123 t.Fatal("descendant not running")
124 }
125 // The server dies on its own (no Close): the descendant must not be left
126 // running, and the transport must have released the group by itself.
127 _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
128 eventually(t, "descendant to be cleaned after the leader exited", func() bool { return !processAlive(pid) })
129 st := c.t.(*stdioTransport)
130 eventually(t, "group id to be released", func() bool {
131 st.guard.mu.Lock()
132 defer st.guard.mu.Unlock()
133 return st.guard.pgid == 0
134 })
135}