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.
166 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|