proclive: add process-liveness package · Entire
proclive: add process-liveness package
dee13d6→main· Soph·3w ago·8 files·+570 added/-0 removed
New leaf package that captures a process's identity (PID + start-time fingerprint, plus host/boot guards) and reports whether that exact process is still alive. ResolveOwner walks up the process tree to the first non-shell, non-entire ancestor (the agent that spawned our hook), skipping the Go toolchain too so local-dev's go run wrapper isn't mistaken for the owner; it records no owner at all when the hostname can't be determined, since a PID is only meaningful on its own machine.
Check returns Alive/Dead/Unknown — Dead on a missing PID, start-time mismatch (PID reuse), or reboot, and Unknown when it can't confirm the host/boot or the platform can't introspect (Windows), so callers fail closed to a timeout rather than trusting a stale PID. darwin records no boot guard: kern.boottime drifts when the wall clock is stepped (NTP), and darwin's absolute P_starttime already distinguishes a reused PID across reboots; Linux uses ticks-since-boot and keeps the boot_id guard.
Stdlib + golang.org/x/sys/unix only, so session/strategy/cli can import it without an import cycle.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
Changes
8
cmd/entire/cli/proclive
Aproc_darwin.go+42
Aproc_linux.go+74
Aproc_linux_test.go+56
Aproc_other.go+16
Aproc_other_test.go+21
Aproclive.go+195
Aproclive_live_test.go+111
Aproclive_test.go+55
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//go:build darwin
package proclive
import (
"fmt"
"golang.org/x/sys/unix"
)
// procStat looks up a process via sysctl(kern.proc.pid) and returns its parent
// PID, executable name (comm), and start time as the fingerprint. It uses the
// typed KinfoProc decoder from golang.org/x/sys/unix rather than hand-decoding
// raw sysctl bytes. p_starttime is an absolute wall-clock timeval, so it is a
// stable per-process fingerprint without needing the boot guard.
func procStat(pid int) (ppid int, name, start string, err error) {
k, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil {
// A missing process surfaces as ESRCH or as EIO (sysctl returns a
// zero-length result, which the wrapper rejects). Either way it's gone.
if err == unix.ESRCH || err == unix.EIO || err == unix.ENOENT {
return 0, "", "", errProcessGone
}
return 0, "", "", fmt.Errorf("proclive: sysctl kern.proc.pid %d: %w", pid, err)
}
tv := k.Proc.P_starttime
return int(k.Eproc.Ppid),
unix.ByteSliceToString(k.Proc.P_comm[:]),
fmt.Sprintf("%d.%06d", tv.Sec, tv.Usec),
nil
}
// bootID returns no boot guard on darwin. kern.boottime is NOT stable for a
// running machine — the kernel recomputes it whenever the wall clock is stepped
// (e.g. an NTP correction), so using it would let a clock adjustment falsely
// declare a still-running session dead. It is also unnecessary here: darwin's
// P_starttime fingerprint is an absolute wall-clock timestamp fixed at process
// creation, so it already distinguishes a reused PID across reboots without a
// boot guard. (Linux uses ticks-since-boot, which does need the guard.)
func bootID() (string, error) {
return "", nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//go:build linux
package proclive
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// procStat reads /proc/<pid>/stat and returns the parent PID, executable name
// (comm), and the process start time (field 22, in clock ticks since boot) used
// as the start fingerprint. The fingerprint only needs to be stable for the
// process lifetime and distinct across PID reuse within a boot; the boot guard
// in Check invalidates it across reboots, so raw ticks suffice and we avoid
// needing _SC_CLK_TCK.
func procStat(pid int) (ppid int, name, start string, err error) {
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, "", "", errProcessGone
}
return 0, "", "", fmt.Errorf("proclive: read /proc/%d/stat: %w", pid, err)
}
return parseProcStat(string(data))
}
// parseProcStat parses the contents of /proc/<pid>/stat. It is separated from
// the file read so it can be unit-tested with adversarial comm values.
//
// The comm (field 2) is wrapped in parentheses and may itself contain spaces
// and ')'. Everything before the first '(' is the PID; the comm runs to the
// LAST ')'; the remaining space-separated fields begin at 'state' (field 3).
func parseProcStat(content string) (ppid int, name, start string, err error) {
openIdx := strings.IndexByte(content, '(')
closeIdx := strings.LastIndexByte(content, ')')
if openIdx < 0 || closeIdx < 0 || closeIdx < openIdx {
return 0, "", "", errors.New("proclive: malformed /proc stat: no comm parens")
}
name = content[openIdx+1 : closeIdx]
// Fields after the comm, 0-indexed: 0=state (field 3), 1=ppid (field 4),
// ... 19=starttime (field 22).
const ppidIdx, starttimeIdx = 1, 19
rest := strings.Fields(content[closeIdx+1:])
if len(rest) <= starttimeIdx {
return 0, "", "", errors.New("proclive: truncated /proc stat")
}
ppid, err = strconv.Atoi(rest[ppidIdx])
if err != nil {
return 0, "", "", fmt.Errorf("proclive: parse ppid: %w", err)
}
return ppid, name, rest[starttimeIdx], nil
}
// bootID returns the kernel boot id, which changes on every reboot. It falls
// back to /proc/stat's btime line if boot_id is unavailable.
func bootID() (string, error) {
if data, err := os.ReadFile("/proc/sys/kernel/random/boot_id"); err == nil {
return strings.TrimSpace(string(data)), nil
}
data, err := os.ReadFile("/proc/stat")
if err != nil {
return "", fmt.Errorf("proclive: read /proc/stat: %w", err)
}
for _, line := range strings.Split(string(data), "\n") {
if rest, ok := strings.CutPrefix(line, "btime "); ok {
return strings.TrimSpace(rest), nil
}
}
return "", nil
}
1
2
3
4
5
6
7
8
9
10
11
12
//go:build !linux && !darwin
package proclive
// On platforms without process introspection (e.g. Windows), the seam reports
// "unsupported". Check then yields LivenessUnknown and ResolveOwner yields no
// owner, so session liveness degrades cleanly to the inactivity-timeout
// fallback instead of producing wrong answers.
func procStat(pid int) (ppid int, name, start string, err error) {
return 0, "", "", errUnsupported
}
func bootID() (string, error) {
return "", errUnsupported
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//go:build !linux && !darwin
package proclive
import (
"os"
"testing"
)
// On unsupported platforms liveness must degrade to Unknown (never a wrong
// Alive/Dead), so callers fall back to the inactivity timeout.
func TestCheck_UnsupportedIsUnknown(t *testing.T) {
t.Parallel()
id := Identity{PID: os.Getpid(), Start: "anything"}
if got := Check(id); got != LivenessUnknown {
t.Errorf("Check on unsupported platform = %v, want unknown", got)
}
if _, ok := ResolveOwner(); ok {
t.Errorf("ResolveOwner on unsupported platform returned ok=true, want false")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Package proclive captures a process's identity (PID plus a start-time
// fingerprint) and later reports whether that exact process is still alive.
//
// It exists to detect agent sessions left in an ACTIVE state when the owning
// process went away — a clean exit, a crash, a kill, a closed terminal, or a
// reboot — without firing a SessionStop hook. Recording the owner's identity at
// turn start lets `entire status` / `entire doctor` notice the process is gone
// immediately, instead of waiting out a coarse inactivity timeout.
//
// This package is a leaf: it imports only the standard library and
// golang.org/x/sys/unix. It must NOT import session, strategy, agent, or cli,
// so those packages can depend on it without an import cycle.
package proclive
import (
"errors"
"os"
"strings"
)
// Liveness is the result of checking a recorded process Identity.
type Liveness int
const (
// LivenessUnknown means liveness could not be determined: the identity is
// empty, was recorded on another host, or the platform cannot introspect
// processes. Callers should fall back to a time-based heuristic.
LivenessUnknown Liveness = iota
// LivenessAlive means the recorded process is still running.
LivenessAlive
// LivenessDead means the recorded process is gone (exited, killed, or the
// machine rebooted) or its PID has been reused by a different process.
LivenessDead
)
func (l Liveness) String() string {
switch l {
case LivenessAlive:
return "alive"
case LivenessDead:
return "dead"
case LivenessUnknown:
return "unknown"
default:
return "unknown"
}
}
// Identity fingerprints the process that owns a session turn. It is persisted
// in session state and later passed to Check. The zero value means "no owner
// recorded" and always yields LivenessUnknown.
type Identity struct {
// PID is the operating-system process id of the owner.
PID int `json:"pid"`
// Start is an opaque, per-platform process start-time fingerprint. It need
// only be stable for the process lifetime and distinct across PID reuse
// within a single boot; the Boot guard invalidates it across reboots.
Start string `json:"start"`
// Boot identifies the current OS boot. A mismatch at check time means the
// machine rebooted, so the recorded PID cannot still be the same process.
Boot string `json:"boot,omitempty"`
// Host is the hostname where the identity was recorded. PIDs are only
// meaningful on their own machine, so a mismatch yields Unknown.
Host string `json:"host,omitempty"`
// Name is the owning process's executable name (comm). Diagnostic only.
Name string `json:"name,omitempty"`
}
var (
// errProcessGone is returned by procStat when no process with the given PID
// exists. Check maps it to LivenessDead.
errProcessGone = errors.New("proclive: process not found")
// errUnsupported is returned by the per-platform seam when the OS cannot be
// introspected (e.g. Windows). Check maps it to LivenessUnknown.
errUnsupported = errors.New("proclive: unsupported platform")
)
// maxAncestorDepth bounds the ResolveOwner walk so a pathological or cyclic
// process tree can never loop or hang.
const maxAncestorDepth = 12
// transientNames are process names that are never the long-lived session owner:
// our own hook binary, the shells agents commonly use to exec hooks, and the Go
// toolchain (local-dev runs hooks via `go run`, whose short-lived `go` parent
// would otherwise be recorded as the owner and exit immediately). The walk skips
// past these to reach the real agent process. Note that interpreter runtimes
// (node, bun, python) are deliberately absent — for several agents the runtime
// IS the long-lived agent, so treating it as transient would skip the real owner.
var transientNames = map[string]bool{
"entire": true,
"sh": true,
"bash": true,
"zsh": true,
"dash": true,
"fish": true,
"ash": true,
"ksh": true,
"env": true,
"go": true,
}
func isTransient(name string) bool {
return transientNames[strings.ToLower(strings.TrimSpace(name))]
}
// ResolveOwner walks up the process tree from the current process and returns
// the Identity of the first ancestor that is not our own hook binary or a
// shell — i.e. the long-lived agent that owns this session.
//
// It returns (zero, false) when no such ancestor can be determined: an
// unsupported platform, a truncated/looping tree, or only transient ancestors.
// In that case the caller should record no owner and let liveness degrade to
// the time-based fallback. Resolving to nothing is always safer than recording
// a guessed PID, which could later be (mis)read as a live or dead owner.
func ResolveOwner() (Identity, bool) {
// The host guard is essential — a PID is only meaningful on the machine that
// recorded it — so if the hostname can't be determined, record no owner
// rather than an unguarded one that Check could later (mis)classify as
// alive/dead across machines. Boot is a best-effort secondary guard; an
// empty value just disables it (darwin records none — see bootID there).
host, err := os.Hostname()
if err != nil || host == "" {
return Identity{}, false
}
boot, err := bootID()
if err != nil {
boot = ""
}
// Walk up from our own process, reading each ancestor exactly once: procStat
// returns its parent (to continue the walk), its name (to skip shells and our
// own binary), and its start fingerprint (to record).
candidate, _, _, err := procStat(os.Getpid())
if err != nil {
return Identity{}, false
}
for range maxAncestorDepth {
if candidate <= 1 {
return Identity{}, false
}
parent, name, start, err := procStat(candidate)
if err != nil {
return Identity{}, false
}
if !isTransient(name) {
return Identity{PID: candidate, Start: start, Boot: boot, Host: host, Name: name}, true
}
candidate = parent
}
return Identity{}, false
}
// Check reports whether the process recorded in id is still alive.
//
// Precedence: an empty identity or a host mismatch is Unknown (cannot judge); a
// boot mismatch means a reboot, so the process is Dead; a missing PID or a
// start-fingerprint mismatch (PID reuse) is Dead; otherwise Alive. An
// unsupported platform is always Unknown so callers fall back to a timeout.
func Check(id Identity) Liveness {
if id.PID <= 0 {
return LivenessUnknown
}
if id.Host != "" {
// Can't confirm we're on the recording host → can't trust its PIDs.
host, err := os.Hostname()
if err != nil || host != id.Host {
return LivenessUnknown
}
}
if id.Boot != "" {
boot, err := bootID()
switch {
case err != nil || boot == "":
return LivenessUnknown // can't confirm the boot → can't trust the PID
case boot != id.Boot:
return LivenessDead // rebooted: the process cannot have survived
}
}
_, _, start, err := procStat(id.PID)
switch {
case errors.Is(err, errUnsupported):
return LivenessUnknown
case errors.Is(err, errProcessGone):
return LivenessDead
case err != nil:
// Transient/unexpected error: don't claim the process is dead.
return LivenessUnknown
}
if id.Start != "" && start != "" && id.Start != start {
// Same PID, different start time: the PID was reused by another process.
return LivenessDead
}
return LivenessAlive
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
//go:build linux || darwin
package proclive
import (
"os"
"os/exec"
"testing"
)
// startSleeper spawns a real long-lived child bound to the test context (so it
// is killed when the test ends) and returns its PID and a captured Identity.
func startSleeper(t *testing.T) (int, Identity) {
t.Helper()
cmd := exec.CommandContext(t.Context(), "sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start sleep: %v", err)
}
t.Cleanup(func() {
// The context kill (on test end) signals the process; reap it here to
// avoid a zombie. A non-nil "signal: killed" error is expected.
if err := cmd.Wait(); err != nil {
t.Logf("sleeper wait: %v", err)
}
})
pid := cmd.Process.Pid
_, name, start, err := procStat(pid)
if err != nil {
t.Fatalf("procStat(child %d): %v", pid, err)
}
return pid, Identity{PID: pid, Start: start, Name: name}
}
func TestCheck_LiveProcessIsAlive(t *testing.T) {
t.Parallel()
_, id := startSleeper(t)
if got := Check(id); got != LivenessAlive {
t.Errorf("Check(live) = %v, want alive", got)
}
}
func TestCheck_ExitedProcessIsDead(t *testing.T) {
t.Parallel()
cmd := exec.CommandContext(t.Context(), "sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start sleep: %v", err)
}
pid := cmd.Process.Pid
_, name, start, err := procStat(pid)
if err != nil {
t.Fatalf("procStat(child %d): %v", pid, err)
}
id := Identity{PID: pid, Start: start, Name: name}
// Kill and reap, then the recorded identity must read as dead. (A PID reused
// within the test window would mismatch Start and still be Dead.)
if err := cmd.Process.Kill(); err != nil {
t.Fatalf("kill: %v", err)
}
if err := cmd.Wait(); err != nil {
t.Logf("wait after kill: %v", err) // expected: "signal: killed"
}
if got := Check(id); got != LivenessDead {
t.Errorf("Check(exited) = %v, want dead", got)
}
}
func TestCheck_StartMismatchIsDead(t *testing.T) {
t.Parallel()
// Our own process is alive, but a bogus start fingerprint must read as PID
// reuse → Dead.
id := Identity{PID: os.Getpid(), Start: "0.000000-not-a-real-fingerprint"}
if got := Check(id); got != LivenessDead {
t.Errorf("Check(start mismatch) = %v, want dead", got)
}
}
func TestProcStat_Self(t *testing.T) {
t.Parallel()
ppid, name, start, err := procStat(os.Getpid())
if err != nil {
t.Fatalf("procStat(self): %v", err)
}
if ppid <= 0 {
t.Errorf("ppid = %d, want > 0", ppid)
}
if name == "" {
t.Errorf("name is empty")
}
if start == "" {
t.Errorf("start is empty")
}
}
func TestResolveOwner_ReturnsSomething(t *testing.T) {
t.Parallel()
// Under `go test` the ancestor chain (test binary ← go ← shell ← ...) should
// resolve to some non-shell owner. We can't assert which, but if it resolves
// it must be self-consistent and currently alive.
id, ok := ResolveOwner()
if !ok {
t.Skip("no stable owner resolved in this environment")
}
if id.PID <= 0 {
t.Errorf("resolved PID = %d, want > 0", id.PID)
}
if got := Check(id); got != LivenessAlive {
t.Errorf("resolved owner Check = %v, want alive", got)
}
}