Files
hermes-agent/tests/hermes_cli/test_serve_parent_watchdog_live_macos.py
Austin Pickett 79445a496c fix(desktop): macOS parent-death watchdog treats ps: marker drift as inconclusive (#103172)
* fix(desktop): treat a macOS ps: parent-marker mismatch as inconclusive, not death

`ps -o lstart=` is a TZ/locale-rendered wall-clock string. Electron caches it
once per app lifetime; the backend re-renders it per spawn. A timezone change
(travel, or the DST boundary) while the app stays open makes the SAME instant
differ byte-for-byte, and _is_serve_orphaned() treated that as proof the parent
died -- os._exit(0) ~5ms after HERMES_BACKEND_READY, silently, on every respawn
until a full quit.

Route mismatches through _parent_start_marker_mismatch_is_conclusive(): linux:/
win:/winms: machine markers stay conclusive (PID-reuse defence intact); any ps:
side degrades to the PID-liveness check the legacy Desktop path already uses.
Log one warning before os._exit(0) so the exit is no longer traceless.

Fixes #95693
Fixes #93958

Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>

* fix(desktop): reject truncated ps: parent markers; blank marker env means absent

A ps: marker that got whitespace-split in env plumbing (`ps:Sat`) passed
_valid_parent_start_marker and armed a watchdog that could never match, so
the backend exited 0 right after HERMES_BACKEND_READY. Require a full
lstart value (>=4 tokens, a 4-digit year, a time). Treat empty
HERMES_PARENT_START_MARKER / HERMES_PARENT_NONCE as absent so a blank
inherited value degrades to PID-only tracking instead of disarming or
misfiring.

* chore: map contributor email for drewTuzson

* fix(desktop): log when the parent-death watchdog disarms on an unusable marker

Disarming is the fail-safe branch (the backend keeps serving) but it also
means this backend will never reap itself when the Desktop dies. Leave one
warning naming the rejected marker so that downgrade is not traceless.

* test(desktop): live macOS proof that a TZ-drifted ps: marker no longer kills the backend

Runs the real start_server in a subprocess against the host ps, with the
marker rendered under Europe/Paris and the backend under America/New_York.
Asserts READY + still alive after several watchdog polls, then that the
backend still exits once the stand-in parent is killed. Fails on main with
the reported symptom (exit 0, live parent); macos_only lane.

---------

Co-authored-by: ygd58 <buraysandro9@gmail.com>
Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>
Co-authored-by: Drew Tuzson <drew.tuzson@uqual.com>
2026-09-04 20:24:51 -04:00

121 lines
4.1 KiB
Python

"""#95693 / #93958 — live macOS proof for the parent-death watchdog.
The unit tests in ``test_serve_parent_watchdog.py`` pin ``_is_serve_orphaned``
as a pure function. The bug itself lived one layer up: a daemon thread that
calls ``os._exit(0)`` ~5ms after ``HERMES_BACKEND_READY``, which no in-process
test can observe. This file runs the REAL ``start_server`` in a subprocess, on
the host ``ps``, with the marker the Desktop would hand it rendered in a
different timezone than the backend's own probe.
Contract:
* live parent + TZ-drifted ``ps:`` marker → backend announces READY and is
still alive well past several watchdog polls;
* that same backend still exits once the parent actually dies (the marker
degrade did not turn the watchdog off).
"""
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.macos_only
def _lstart(pid: int, tz: str) -> str:
env = dict(os.environ, TZ=tz)
out = subprocess.run(
["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, env=env, check=True
).stdout
return " ".join(out.split())
def _read_until(proc: subprocess.Popen, token: str, timeout: float = 120.0) -> bool:
hit = threading.Event()
def _pump():
assert proc.stdout is not None
for line in proc.stdout:
if token in line:
hit.set()
return
threading.Thread(target=_pump, daemon=True).start()
return hit.wait(timeout)
def _wait_exit(proc: subprocess.Popen, timeout: float) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if proc.poll() is not None:
return True
time.sleep(0.1)
return False
def test_live_backend_survives_timezone_drifted_parent_marker(tmp_path):
# Stand-in for the Electron parent: a long-lived process we control.
parent = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(600)"])
serve = None
try:
backend_tz = "America/New_York"
cached_tz = "Europe/Paris"
drifted_marker = f"ps:{_lstart(parent.pid, cached_tz)}"
assert drifted_marker != f"ps:{_lstart(parent.pid, backend_tz)}", (
"precondition: the two TZ renderings of the same instant must differ"
)
home = tmp_path / "hermes_home"
home.mkdir()
env = dict(os.environ)
env.update(
TZ=backend_tz,
HERMES_HOME=str(home),
HERMES_SERVE_HEADLESS="1",
PYTHONUNBUFFERED="1",
HERMES_PARENT_PID=str(parent.pid),
HERMES_PARENT_START_MARKER=drifted_marker,
HERMES_PARENT_NONCE="nonce-95693",
HERMES_SERVE_WATCHDOG_POLL_S="0.5",
)
env.pop("HERMES_DESKTOP", None)
code = (
"from hermes_cli.web_server import start_server\n"
"start_server(host='127.0.0.1', port=0, open_browser=False, headless=True)\n"
)
serve = subprocess.Popen(
[sys.executable, "-c", code],
cwd=str(REPO_ROOT),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
assert _read_until(serve, "HERMES_BACKEND_READY"), "backend never announced READY"
# Before the fix the watchdog fired on its very first poll. Several
# polls later the backend must still be here.
assert not _wait_exit(serve, timeout=4.0), (
f"backend exited (code {serve.returncode}) with a live parent; "
f"TZ-drifted marker {drifted_marker!r} was treated as proof of death"
)
# The degrade to PID liveness must still reap a genuinely dead parent.
parent.kill()
parent.wait(timeout=10)
assert _wait_exit(serve, timeout=15.0), "backend outlived its dead parent"
assert serve.returncode == 0
finally:
for proc in (serve, parent):
if proc is not None and proc.poll() is None:
proc.kill()
proc.wait(timeout=10)