From 012f40c98c18b6723e355abbba7544b752836276 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:11:28 -0700 Subject: [PATCH] fix(status): cross-platform start-time fingerprint via psutil fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PID-reuse guard (#43846) reads /proc//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. --- gateway/status.py | 27 ++++++++++++++++++++- tests/gateway/test_status.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/gateway/status.py b/gateway/status.py index c13752af171..0f812c23e34 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -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//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//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 diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 63f90fe3332..0a6129b2bb5 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -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 = []