114 lines
3.2 KiB
Go
114 lines
3.2 KiB
Go
//go:build unix
|
|
|
|
package claudecode
|
|
|
|
import (
|
|
"os/exec"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPrepareCmdForKill_SetsSetpgid(t *testing.T) {
|
|
cmd := exec.Command("/bin/true")
|
|
prepareCmdForKill(cmd)
|
|
if cmd.SysProcAttr == nil {
|
|
t.Fatal("SysProcAttr is nil after prepareCmdForKill")
|
|
}
|
|
if !cmd.SysProcAttr.Setpgid {
|
|
t.Fatal("Setpgid not set after prepareCmdForKill")
|
|
}
|
|
}
|
|
|
|
func TestPrepareCmdForKill_PreservesExistingSysProcAttr(t *testing.T) {
|
|
cmd := exec.Command("/bin/true")
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Foreground: false}
|
|
prepareCmdForKill(cmd)
|
|
if !cmd.SysProcAttr.Setpgid {
|
|
t.Fatal("Setpgid not set when SysProcAttr was pre-populated")
|
|
}
|
|
}
|
|
|
|
func TestPrepareCmdForKill_NilCmd(t *testing.T) {
|
|
// Must not panic on a nil *exec.Cmd.
|
|
prepareCmdForKill(nil)
|
|
}
|
|
|
|
func TestForceKillCmd_NoProcess(t *testing.T) {
|
|
cmd := exec.Command("/bin/true")
|
|
// cmd has not been Start()ed, so cmd.Process is nil.
|
|
if err := forceKillCmd(cmd); err != nil {
|
|
t.Errorf("expected no error on un-started cmd, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestForceKillCmd_NilCmd(t *testing.T) {
|
|
if err := forceKillCmd(nil); err != nil {
|
|
t.Errorf("expected no error on nil cmd, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestForceKillCmd_KillsGrandchild is the regression test for the original
|
|
// bug: spawning a shell that backgrounds a long-running grandchild, then
|
|
// proving that forceKillCmd reaps the grandchild along with the direct
|
|
// child via process-group kill. Without prepareCmdForKill setting up the
|
|
// process group, the grandchild would survive and spin.
|
|
func TestForceKillCmd_KillsGrandchild(t *testing.T) {
|
|
// /bin/sh -c 'sleep 60 & echo $! ; wait'
|
|
// The grandchild PID is printed on stdout so we can verify it is reaped.
|
|
cmd := exec.Command("/bin/sh", "-c", "sleep 60 & echo $! ; wait")
|
|
prepareCmdForKill(cmd)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatalf("stdout pipe: %v", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
|
|
// Read the grandchild PID.
|
|
buf := make([]byte, 32)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var grandchildPidStr string
|
|
for time.Now().Before(deadline) {
|
|
n, _ := stdout.Read(buf)
|
|
if n > 0 {
|
|
grandchildPidStr = string(buf[:n])
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if grandchildPidStr == "" {
|
|
_ = forceKillCmd(cmd)
|
|
_ = cmd.Wait()
|
|
t.Fatal("did not receive grandchild PID")
|
|
}
|
|
|
|
if err := forceKillCmd(cmd); err != nil {
|
|
t.Fatalf("forceKillCmd: %v", err)
|
|
}
|
|
_ = cmd.Wait()
|
|
|
|
// Verify the grandchild is gone by checking that signaling it with 0
|
|
// (no-op, just checks existence) returns ESRCH within a short window.
|
|
// We can't easily parse the PID without strconv import bloat in tests,
|
|
// so we rely on `pgrep` semantics: re-kill the group should be a no-op.
|
|
if err := forceKillCmd(cmd); err != nil {
|
|
t.Errorf("second forceKillCmd should be no-op, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSignalProcessGroup_NoProcess(t *testing.T) {
|
|
cmd := exec.Command("/bin/true")
|
|
if err := signalProcessGroup(cmd, syscall.SIGTERM); err != nil {
|
|
t.Errorf("expected no error on un-started cmd, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSignalProcessGroup_NilCmd(t *testing.T) {
|
|
if err := signalProcessGroup(nil, syscall.SIGTERM); err != nil {
|
|
t.Errorf("expected no error on nil cmd, got %v", err)
|
|
}
|
|
}
|