diff --git a/internal/util/process.go b/internal/util/process.go index 1d94ed8b9..38d9ef37b 100644 --- a/internal/util/process.go +++ b/internal/util/process.go @@ -4,17 +4,76 @@ package util import ( "os" + "strings" "syscall" ) // IsProcessAlive checks if a process with the given PID is still running. // On Unix, it sends signal 0 (no signal, just permission/existence check). +// +// #1839: a zombie (exited but not yet reaped by its parent) also answers +// signal 0, so the raw check would report a dead process as alive. Callers +// that only decide whether to KEEP state (crash-recovery anchors, instance +// files) fail safe on residue; but semantics here should be honest - a +// zombie is not running. When /proc is available we read the process state +// and treat Z (and X/x, exiting) as dead. /proc-less platforms keep the +// signal-0-only behavior (documented, conservative). func IsProcessAlive(pid int) bool { proc, err := os.FindProcess(pid) if err != nil { return false } - return proc.Signal(syscall.Signal(0)) == nil + if proc.Signal(syscall.Signal(0)) != nil { + return false + } + return !isZombieUnix(pid) +} + +// isZombieUnix reports whether /proc//stat shows the process in an +// exited (zombie or dying) state. Returns false on any read/parse failure - +// liveness stays with the signal-0 verdict when /proc is unavailable. +func isZombieUnix(pid int) bool { + if pid <= 0 { + return false + } + data, err := os.ReadFile("/proc/" + itoa(pid) + "/stat") + if err != nil { + return false + } + // Layout: pid (comm) state ... - the comm field may contain spaces or + // parens, so parse from the LAST ')'. + idx := strings.LastIndexByte(string(data), ')') + if idx < 0 || idx+2 >= len(data) { + return false + } + state := data[idx+2] + switch state { + case 'Z', 'X', 'x': // zombie / dead / dying + return true + } + return false +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) } // IsProcessAliveProc checks if the given os.Process is still running. @@ -22,5 +81,5 @@ func IsProcessAliveProc(proc *os.Process) bool { if proc == nil || proc.Pid <= 0 { return false } - return proc.Signal(syscall.Signal(0)) == nil + return IsProcessAlive(proc.Pid) } diff --git a/internal/util/process_1839_test.go b/internal/util/process_1839_test.go new file mode 100644 index 000000000..de618154a --- /dev/null +++ b/internal/util/process_1839_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package util + +import ( + "os" + "os/exec" + "testing" + "time" +) + +// #1839: a zombie (exited, unreaped child) must NOT count as alive. +func Test1839ZombieNotAlive(t *testing.T) { + if _, err := os.Stat("/proc/self/stat"); err != nil { + t.Skip("/proc unavailable; signal-0-only behavior documented") + } + cmd := exec.Command("true") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + pid := cmd.Process.Pid + // Exit without Wait -> the child becomes a zombie owned by us until + // test cleanup; poll briefly for the Z state to appear. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if isZombieUnix(pid) { + break + } + time.Sleep(20 * time.Millisecond) + } + if !isZombieUnix(pid) { + _ = cmd.Wait() + t.Skip("could not produce a zombie (fast reaping host)") + } + if IsProcessAlive(pid) { + _ = cmd.Wait() + t.Fatalf("zombie pid %d must not be reported alive", pid) + } + _ = cmd.Wait() +} + +// Normal and non-existent processes keep their verdicts. +func Test1839VerdictsUnchanged(t *testing.T) { + if !IsProcessAlive(os.Getpid()) { + t.Fatal("self must be alive") + } + if IsProcessAlive(1 << 22) { + t.Fatal("unlikely pid must be dead") + } +}