feat(v0.6): IsStale supplements wall-clock freshness with PID liveness

This commit is contained in:
2026-05-15 14:26:15 -04:00
parent 9070c4274c
commit f379a6d059
2 changed files with 136 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package session
import (
"testing"
"time"
)
// withCheckProcess swaps the package-level checkProcess seam for the
// duration of a test and restores it on cleanup. Tests using it must not
// run with t.Parallel().
func withCheckProcess(t *testing.T, fn func(pid int) ProcessState) {
t.Helper()
orig := checkProcess
checkProcess = fn
t.Cleanup(func() { checkProcess = orig })
}
func freshLocalLease(pid int) *Lease {
now := time.Now().UTC()
return &Lease{
PID: pid,
Hostname: currentHostname(),
StartedAt: now,
LastHeartbeatAt: now,
}
}
// Wall-clock staleness wins unconditionally — existing behavior preserved.
func TestIsStaleByWallClock(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
now := time.Now().UTC()
l := &Lease{PID: 999, Hostname: currentHostname(), LastHeartbeatAt: now.Add(-90 * time.Second)}
if !IsStale(l, now, StaleLeaseAfter) {
t.Error("a lease 90s past heartbeat must be stale even if PID is alive")
}
}
// Fresh wall-clock + local host + dead owner PID => stale immediately.
func TestIsStaleByDeadPID(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessDead })
l := freshLocalLease(4242)
if !IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
t.Error("fresh local lease with a dead PID must be stale")
}
}
// Fresh wall-clock + local host + live owner PID => not stale.
func TestIsStaleFreshWithLivePID(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
l := freshLocalLease(4242)
if IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
t.Error("fresh local lease with a live PID must not be stale")
}
}
// Remote lease => PID check skipped entirely; wall-clock only.
func TestIsStaleRemoteIgnoresPID(t *testing.T) {
withCheckProcess(t, func(int) ProcessState {
t.Error("checkProcess must not be called for a remote lease")
return ProcessDead
})
now := time.Now().UTC()
l := &Lease{PID: 4242, Hostname: "some-other-host", LastHeartbeatAt: now}
if l.Hostname == currentHostname() {
t.Skip("test hostname collision; cannot construct a remote lease")
}
if IsStale(l, now, StaleLeaseAfter) {
t.Error("fresh remote lease must not be stale (PID check skipped)")
}
}
// Inconclusive PID check => conservative; fall back to wall-clock fresh.
func TestIsStaleUnknownPIDConservative(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessUnknown })
l := freshLocalLease(4242)
if IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
t.Error("fresh local lease with inconclusive PID check must not be stale")
}
}
// pid <= 0 => skip PID liveness entirely; wall-clock only.
func TestIsStaleInvalidPIDSkipsCheck(t *testing.T) {
withCheckProcess(t, func(int) ProcessState {
t.Error("checkProcess must not be called when pid <= 0")
return ProcessDead
})
now := time.Now().UTC()
for _, pid := range []int{0, -1} {
l := &Lease{PID: pid, Hostname: currentHostname(), LastHeartbeatAt: now}
if IsStale(l, now, StaleLeaseAfter) {
t.Errorf("fresh local lease with pid=%d must not be stale (wall-clock only)", pid)
}
}
}
// Defensive: a nil lease is stale.
func TestIsStaleNilLease(t *testing.T) {
if !IsStale(nil, time.Now().UTC(), StaleLeaseAfter) {
t.Error("nil lease must be reported stale")
}
}
+35
View File
@@ -131,6 +131,41 @@ func IsFresh(l *Lease, now time.Time, threshold time.Duration) bool {
return now.Sub(l.LastHeartbeatAt) <= threshold
}
// IsStale reports whether the lease should be treated as stale. It
// supplements the wall-clock heartbeat threshold with PID liveness:
//
// - A heartbeat older than threshold is stale (existing behavior).
// - Otherwise, for a lease whose hostname matches the current host and
// whose PID is valid (> 0), a dead owner PID makes the lease stale
// immediately. This is the v0.6 lazy-cleanup signal: a Ctrl-C'd or
// terminal-closed session is recognized without the 60s wall-clock
// wait.
// - Remote leases, leases with pid <= 0, and inconclusive PID checks
// (ProcessUnknown) fall back to wall-clock freshness only.
//
// PID liveness can only flip a wall-clock-fresh lease to stale; it never
// revives a wall-clock-stale lease. IsStale is the freshness predicate
// for adoption and Layer-1 decisions — prefer it over a bare IsFresh
// call at any site that decides whether a session is still owned.
func IsStale(l *Lease, now time.Time, threshold time.Duration) bool {
if l == nil {
return true
}
if !IsFresh(l, now, threshold) {
return true // wall-clock stale — existing behavior
}
if l.PID <= 0 {
return false // no usable PID — wall-clock only
}
if l.Hostname != currentHostname() {
return false // remote lease — wall-clock only
}
if checkProcess(l.PID) == ProcessDead {
return true // local owner confirmed dead
}
return false // alive or inconclusive — conservative
}
// CleanupStaleLease inspects the lease at path:
// - missing file: no-op, returns (nil, nil)
// - corrupt / unparseable: removes the file, returns (nil, nil)