feat(v0.6): tri-state PID liveness probe (ProcessAlive/Dead/Unknown)

This commit is contained in:
2026-05-15 14:25:31 -04:00
parent 8d5243dce2
commit 9070c4274c
4 changed files with 125 additions and 0 deletions
+22
View File
@@ -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
+38
View File
@@ -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)
}
}
+27
View File
@@ -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
}
}
+38
View File
@@ -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
}