Files
2026-06-02 23:14:41 +08:00

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)
}
}