Files
yetone 1831ac4b63 proc: a CLI probe or agent run that is killed leaves nothing of itself running on Mac and Linux, even a launcher that keeps calling itself: its process group is signalled until it is empty, not once (#1278, JJ-Lin)
An old ~/.local/bin/cursor-agent that runs `cursor agent`, which (Cursor's
own bin/cursor) first runs ~/.local/bin/cursor-agent --min-version=... status,
calls itself without end. magpie asks cursor-agent who is signed in (about,
10s timeout) and kills the probe's session on the timeout with one SIGKILL
to the group. On macOS that one signal can miss a process being forked as it
goes out; the miss lives on, in the same group, adopted by init, and keeps
calling itself until the user can start no process (JJ-Lin: ~4800 bash
processes in one chain whose root's parent is PID 1). group.kill now sends
SIGKILL to the group again every 5ms, behind the caller, until the group is
gone (ESRCH) or 10s pass. The group keeps its id while any of it lives, so
nothing else is signalled. It covers ProbeContext's timeout and EndProbes,
and StartTree's Kill and sweep.

Verified:
- red without the change: TestProbeKillEndsWhatTheSignalMissed (the first
  group signal is made to miss, as the race does) fails with
  "what the probe started (pid 95576) outlived a kill that missed it once";
  passes with it (-count=5).
- reproduced the leak with the real Cursor 3.12.6 launcher script
  (/Applications/Cursor.app/.../bin/cursor, read only) and JJ-Lin's 87-byte
  wrapper in a sandbox HOME, under ulimit -u: ProbeContext(cursor-agent about)
  with a 10s timeout peaked at 311-700 processes; before the change 1 of 3
  runs left a chain behind (root ppid 1, same pgid, args
  "agent --min-version=2025.10.01 status"), after it 0 of 6. A fake
  launcher (60 parallel self-calling chains, killed at 500ms) left survivors
  4 of 30 runs before, 0 of 30 after.
- the same through provider.askCursorStatus with the real launcher, 5 runs:
  no cursor process left after each.
- go test -tags nogui ./internal/proc/ in a sandbox HOME; go vet -tags nogui
  of proc for darwin, linux and windows.
2026-10-08 18:18:15 +08:00

166 lines
4.6 KiB
Go

//go:build !windows
package proc
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/yetone/magpie/internal/testenv"
)
// probeScript is a CLI that is a script starting another program and
// waiting on it, as a version manager's wrapper does; it writes down the
// pid of what it started.
func probeScript(t *testing.T) (sh, pidFile string) {
dir := t.TempDir()
sh, pidFile = filepath.Join(dir, "cli"), filepath.Join(dir, "child.pid")
testenv.Program(t, sh, "#!/bin/sh\nsleep 30 &\necho $! > "+pidFile+"\nwait\n")
old := waitDelay
waitDelay = 200 * time.Millisecond
t.Cleanup(func() { waitDelay = old })
return sh, pidFile
}
// runProbe runs the probe in the background; the channel gives what its
// run ended with.
func runProbe(cmd *exec.Cmd) chan error {
done := make(chan error, 1)
go func() {
_, err := cmd.Output()
done <- err
}()
return done
}
// childPid waits for the script to have started its child. A script just
// written can take seconds to start on a busy Mac, so the wait is long; a
// probe that ended before is said with its error.
func childPid(t *testing.T, pidFile string, done chan error) int {
t.Helper()
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
end := time.After(30 * time.Second)
for {
if b, err := os.ReadFile(pidFile); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })
return pid
}
}
select {
case err := <-done:
t.Fatalf("the probe ended (%v) before the script started anything", err)
case <-end:
t.Fatal("the script started nothing in 30s")
case <-tick.C:
}
}
}
// a probe whose context ends (its timeout) ends with what it started:
// killing the script alone left its child running. The context is ended
// once the script has started its child, not on a timer that a slow start
// could beat.
func TestProbeTimeoutEndsWhatItStarted(t *testing.T) {
sh, pidFile := probeScript(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
done := runProbe(ProbeContext(ctx, sh))
pid := childPid(t, pidFile, done)
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the probe went on after its context ended")
}
if !gone(pid) {
t.Fatalf("what the probe started (pid %d) outlived its context", pid)
}
}
// a probe still asking when magpie exits is ended with it, rather than
// left to init
func TestEndProbes(t *testing.T) {
sh, pidFile := probeScript(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
done := runProbe(ProbeContext(ctx, sh))
pid := childPid(t, pidFile, done)
start := time.Now()
EndProbes()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the probe went on after EndProbes")
}
if !gone(pid) {
t.Fatalf("what the probe started (pid %d) outlived EndProbes", pid)
}
if d := time.Since(start); d > endWait+time.Second {
t.Fatalf("EndProbes took %v", d)
}
}
// a probe that answered is forgotten once its context ends, so EndProbes
// doesn't wait on it
func TestProbeForgotten(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
if _, err := ProbeContext(ctx, "true").Output(); err != nil {
t.Fatal(err)
}
cancel()
for end := time.Now().Add(time.Second); time.Now().Before(end); time.Sleep(10 * time.Millisecond) {
probes.Lock()
n := len(probes.m)
probes.Unlock()
if n == 0 {
return
}
}
t.Fatal("a probe that answered is still listed")
}
// a process the group's kill missed — forked as the signal went out, as
// one of a launcher calling itself was (#1278) — is ended too: the group is
// signalled again while any of it lives. The first signal here misses all
// of it, as the race misses one.
func TestProbeKillEndsWhatTheSignalMissed(t *testing.T) {
sh, pidFile := probeScript(t)
old := signalGroup
var mu sync.Mutex
missed := false
signalGroup = func(pgid int) error {
mu.Lock()
first := !missed
missed = true
mu.Unlock()
if first {
return nil // sent, and reached nobody
}
return old(pgid)
}
t.Cleanup(func() { signalGroup = old })
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
done := runProbe(ProbeContext(ctx, sh))
pid := childPid(t, pidFile, done)
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the probe went on after its context ended")
}
if !gone(pid) {
t.Fatalf("what the probe started (pid %d) outlived a kill that missed it once", pid)
}
}