fix(lifecycle-ledger): pre-stamp sentinels still tell an owner from a PID reuser

Review follow-up: a sentinel written before the create_time stamp has only
the claim time, but that is epoch seconds too, and the owner was born before
it claimed while a reuser was born after the owner died. One-sided compare
instead of trusting any live PID. Dead monkeypatch line removed from the test.
This commit is contained in:
kshitijk4poor
2026-09-14 21:06:33 +05:30
committed by kshitij
parent 40087fba7d
commit e6e4213dbf
2 changed files with 19 additions and 14 deletions
+15 -10
View File
@@ -104,16 +104,17 @@ def _append_exit_diag(record: Dict[str, Any], home: Optional[Path]) -> None:
logger.debug("Failed to append unclean-exit record", exc_info=True)
def _pid_is_sentinel_owner(pid: Any, create_time: Any) -> bool:
def _pid_is_sentinel_owner(pid: Any, start_time: Any, create_time: Any) -> bool:
"""True when ``pid`` is a live process that is the sentinel's incarnation — guards the
``--replace`` race: a live matching owner mid-teardown is a handover, not a death.
Identity is the psutil ``create_time`` the sentinel stamps at claim (epoch seconds, same
producer on both sides). The sentinel's ``start_time`` is the ledger claim time and is NOT comparable with
``gateway.status.get_process_start_time`` (proc ticks on Linux, centiseconds elsewhere) — the
old comparison never matched, so every ``--replace`` handover read as an unclean death. A
sentinel without ``create_time`` (pre-stamp gateway) cannot disambiguate PID reuse; a live PID
is taken as the owner, matching the psutil-silent case below."""
producer on both sides). The sentinel's ``start_time`` is the ledger claim time and is NOT
comparable with ``gateway.status.get_process_start_time`` (proc ticks on Linux, centiseconds
elsewhere) — the old comparison never matched, so every ``--replace`` handover read as an
unclean death. A pre-stamp sentinel (no ``create_time``) still has ``start_time`` in epoch
seconds: the owner was born BEFORE it claimed, a PID reuser AFTER the owner died, so a birth
later than the claim is a reuser."""
try:
pid_int = int(pid)
# NOT os.kill(pid, 0): on Windows that sends CTRL_C_EVENT to the target's console group.
@@ -123,12 +124,16 @@ def _pid_is_sentinel_owner(pid: Any, create_time: Any) -> bool:
return False
except Exception:
return False
if type(create_time) not in (int, float):
return True
from hermes_cli.process_identity import _process_create_time
actual = _process_create_time(pid_int)
return actual is None or abs(actual - float(create_time)) <= 2.0 # None: can't disambiguate PID reuse
if actual is None:
return True # can't disambiguate PID reuse
if type(create_time) in (int, float):
return abs(actual - float(create_time)) <= 2.0
if type(start_time) in (int, float):
return actual <= float(start_time) + 2.0
return True
def _suspected_oom(mem: Dict[str, Any]) -> bool:
@@ -149,7 +154,7 @@ def detect_unclean_exit(home: Optional[Path] = None) -> Optional[Dict[str, Any]]
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if not sentinel or sentinel.get("phase") != "running":
return None
if _pid_is_sentinel_owner(sentinel.get("pid"), sentinel.get("create_time")):
if _pid_is_sentinel_owner(sentinel.get("pid"), sentinel.get("start_time"), sentinel.get("create_time")):
return None # live owner — planned takeover in flight, not a death
evidence: Dict[str, Any] = {
"prior_pid": sentinel.get("pid"), "prior_started_at": sentinel.get("started_at"),