diff --git a/internal/session/pidcheck.go b/internal/session/pidcheck.go new file mode 100644 index 0000000..6e968ec --- /dev/null +++ b/internal/session/pidcheck.go @@ -0,0 +1,22 @@ +package session + +// ProcessState is the tri-state result of a PID liveness check. The call +// site decides how to treat ProcessUnknown; IsStale treats it +// conservatively by falling back to wall-clock freshness. +type ProcessState int + +const ( + // ProcessAlive: the OS confirms a process with this PID exists. + ProcessAlive ProcessState = iota + // ProcessDead: the OS confirms no process with this PID exists. + ProcessDead + // ProcessUnknown: the liveness check was inconclusive (permission + // error, unexpected OS error). Callers must NOT treat this as dead. + ProcessUnknown +) + +// checkProcess is the test seam for PID liveness. Production code uses the +// platform-specific defaultCheckProcess. Tests override this package-level +// variable; such tests must restore it via t.Cleanup and must NOT run with +// t.Parallel(). +var checkProcess = defaultCheckProcess diff --git a/internal/session/pidcheck_test.go b/internal/session/pidcheck_test.go new file mode 100644 index 0000000..71a5261 --- /dev/null +++ b/internal/session/pidcheck_test.go @@ -0,0 +1,38 @@ +package session + +import ( + "os" + "os/exec" + "runtime" + "testing" +) + +// The compiled-in defaultCheckProcess (platform-specific) must report the +// running test process as alive. +func TestCheckProcessSelfAlive(t *testing.T) { + if got := defaultCheckProcess(os.Getpid()); got != ProcessAlive { + t.Errorf("defaultCheckProcess(self pid=%d) = %v, want ProcessAlive", os.Getpid(), got) + } +} + +// A child process that has exited and been reaped must report as dead. +// Best-effort: a PID-reuse race is theoretically possible but negligible +// within a test run. +func TestCheckProcessDeadAfterExit(t *testing.T) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "exit", "0") + } else { + cmd = exec.Command("true") + } + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait child: %v", err) + } + if got := defaultCheckProcess(pid); got != ProcessDead { + t.Errorf("defaultCheckProcess(exited child pid=%d) = %v, want ProcessDead", pid, got) + } +} diff --git a/internal/session/pidcheck_unix.go b/internal/session/pidcheck_unix.go new file mode 100644 index 0000000..0326ecc --- /dev/null +++ b/internal/session/pidcheck_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package session + +import ( + "errors" + "syscall" +) + +// defaultCheckProcess reports whether process pid exists, using signal 0 +// (the standard POSIX existence probe). pid is assumed > 0; IsStale guards +// the pid <= 0 case before calling. +func defaultCheckProcess(pid int) ProcessState { + err := syscall.Kill(pid, 0) + switch { + case err == nil: + return ProcessAlive + case errors.Is(err, syscall.ESRCH): + return ProcessDead + case errors.Is(err, syscall.EPERM): + // Process exists but is owned by another user. It exists — that + // is all IsStale needs. Conservative: treat as alive. + return ProcessAlive + default: + return ProcessUnknown + } +} diff --git a/internal/session/pidcheck_windows.go b/internal/session/pidcheck_windows.go new file mode 100644 index 0000000..dd0ac73 --- /dev/null +++ b/internal/session/pidcheck_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package session + +import ( + "errors" + "syscall" +) + +// processQueryLimitedInformation is the minimal access right needed to +// probe a process's existence. Defined locally to avoid a dependency on +// golang.org/x/sys/windows. +const processQueryLimitedInformation = 0x1000 + +// errorInvalidParameter is the Win32 error OpenProcess returns when no +// process with the given PID exists. +const errorInvalidParameter = syscall.Errno(87) + +// defaultCheckProcess reports whether process pid exists, by trying to +// open a query handle to it. pid is assumed > 0; IsStale guards the +// pid <= 0 case before calling. +// +// Note: a handle that opens successfully is treated as ProcessAlive even +// in the rare zombie-handle case — this is conservative (it preserves +// wall-clock fallback rather than declaring a live owner dead). +func defaultCheckProcess(pid int) ProcessState { + h, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) + if err != nil { + if errors.Is(err, errorInvalidParameter) { + return ProcessDead + } + // Access-denied or any other error: the process may exist; do + // not claim it is dead. + return ProcessUnknown + } + syscall.CloseHandle(h) + return ProcessAlive +}