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

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