mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix(status): cross-platform start-time fingerprint via psutil fallback
The PID-reuse guard (#43846) reads /proc/<pid>/stat field 22, which only exists on Linux — on macOS/Windows it returned None and the guard silently degraded to a bare liveness check (a no-op, safety-wise). Add a psutil.create_time() fallback (psutil is a hard dep, cross-platform), quantized to centiseconds for stable equality, so the recycled-PID guard actually protects macOS/Windows too. /proc always wins first on Linux and always misses on macOS/Windows, so the two sources never mix on one host and same-source equality is all the guard needs.
This commit is contained in:
parent
1cefc2a24e
commit
012f40c98c
2 changed files with 73 additions and 1 deletions
|
|
@ -110,12 +110,37 @@ def _get_scope_lock_path(scope: str, identity: str) -> Path:
|
|||
|
||||
|
||||
def _get_process_start_time(pid: int) -> Optional[int]:
|
||||
"""Return the kernel start time for a process when available."""
|
||||
"""Return a stable per-process start-time fingerprint, or None.
|
||||
|
||||
Used as a PID-reuse guard: a ``(pid, start_time)`` pair uniquely identifies
|
||||
a process, so a recycled PID (same number, different process) yields a
|
||||
different value and is never mistaken for the original.
|
||||
|
||||
On Linux this is field 22 of ``/proc/<pid>/stat`` (start time in clock
|
||||
ticks since boot, an int). On platforms without ``/proc`` (macOS, Windows)
|
||||
we fall back to ``psutil.Process(pid).create_time()`` — a float epoch
|
||||
timestamp — quantized to an int (centiseconds) for stable equality.
|
||||
|
||||
The two sources are never mixed on a single platform: ``/proc`` always
|
||||
succeeds first on Linux, and always fails on macOS/Windows so psutil is
|
||||
always used there. Because the guard only compares the value recorded at
|
||||
spawn against the live value *on the same host*, the differing units across
|
||||
platforms are irrelevant — only same-source equality matters.
|
||||
"""
|
||||
stat_path = Path(f"/proc/{pid}/stat")
|
||||
try:
|
||||
# Field 22 in /proc/<pid>/stat is process start time (clock ticks).
|
||||
return int(stat_path.read_text(encoding="utf-8").split()[21])
|
||||
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# No /proc (macOS / Windows): psutil is a hard dependency and exposes a
|
||||
# cross-platform creation time. Quantize to centiseconds so repeated reads
|
||||
# of the same process compare equal without float-precision fragility.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
return int(round(psutil.Process(pid).create_time() * 100))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -359,6 +359,53 @@ class TestGatewayRuntimeStatus:
|
|||
assert payload["platforms"]["discord"]["error_message"] is None
|
||||
|
||||
|
||||
class TestGetProcessStartTime:
|
||||
"""Start-time fingerprint backing the PID-reuse guard (#43846 / #50468).
|
||||
|
||||
Must be stable across repeated reads of the same live process and degrade to
|
||||
a cross-platform psutil fallback when /proc is unavailable (macOS/Windows),
|
||||
so the guard isn't a Linux-only no-op.
|
||||
"""
|
||||
|
||||
def test_live_process_is_stable_int(self):
|
||||
import subprocess
|
||||
import time
|
||||
p = subprocess.Popen(["sleep", "20"])
|
||||
try:
|
||||
a = status._get_process_start_time(p.pid)
|
||||
time.sleep(0.2)
|
||||
b = status._get_process_start_time(p.pid)
|
||||
assert a is not None and isinstance(a, int)
|
||||
assert a == b # same process → identical fingerprint
|
||||
finally:
|
||||
p.kill()
|
||||
p.wait()
|
||||
|
||||
def test_dead_pid_returns_none(self):
|
||||
assert status._get_process_start_time(999999999) is None
|
||||
|
||||
def test_psutil_fallback_when_no_proc(self, monkeypatch):
|
||||
"""When /proc is missing (macOS/Windows), psutil supplies a stable int."""
|
||||
import subprocess
|
||||
orig_read_text = Path.read_text
|
||||
|
||||
def no_proc(self, *args, **kwargs):
|
||||
if str(self).startswith("/proc/"):
|
||||
raise FileNotFoundError
|
||||
return orig_read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", no_proc)
|
||||
p = subprocess.Popen(["sleep", "20"])
|
||||
try:
|
||||
a = status._get_process_start_time(p.pid)
|
||||
b = status._get_process_start_time(p.pid)
|
||||
assert a is not None and isinstance(a, int)
|
||||
assert a == b # fallback is stable across reads
|
||||
finally:
|
||||
p.kill()
|
||||
p.wait()
|
||||
|
||||
|
||||
class TestTerminatePid:
|
||||
def test_force_uses_taskkill_on_windows(self, monkeypatch):
|
||||
calls = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue