Files
hermes-agent/tests/hermes_cli/test_dashboard_procs_kill_grace.py
T
teknium1 f93d33f93f fix(cli): name the dashboard kill grace and pin it against the lifespan teardown
Turn the SIGTERM→SIGKILL deadline in `_kill_pids_posix` into `_POSIX_TERM_GRACE_SECONDS`
(10.0s) documented against what it has to outlast: `web_server.py::_lifespan` blocks on
`stop_hosted_room_service(timeout=5.0)` + `join(1.0)` before `PTY_REGISTRY.close_all()`
(≤1.5s per attached Chat PTY). The 3.0s deadline predates the hosted-room stop (added
2026-08-30) and SIGKILLed the backend mid-teardown, so its ui-tui / tui_gateway.entry
children were never closed and kept the deleted state.db-wal inode open — the next
`hermes` start refused with FATAL DeletedWalGenerationError (#111912).

Two invariant tests against real child processes: a teardown as long as the lifespan
budget finishes gracefully (red on the old 3.0s deadline); a SIGTERM-ignoring process is
still SIGKILLed at the deadline. The orphan reaper's 1.5s grace is left alone on purpose
— it runs on the Desktop boot path under a 10s ready-probe — and says so in the comment.

Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
2026-09-15 18:29:16 -07:00

101 lines
3.5 KiB
Python

"""The dashboard SIGTERM→SIGKILL grace must outlast the lifespan teardown (#111912).
``hermes update`` / ``hermes dashboard --stop`` fall back to ``_kill_pids_posix`` for a
manually-started backend. Its lifespan teardown blocks on ``stop_hosted_room_service(timeout=5.0)``
before ``PTY_REGISTRY.close_all()`` runs; a SIGKILL inside that window orphans the ui-tui /
tui_gateway.entry children, which keep the deleted ``state.db-wal`` inode open until the next
start aborts with ``DeletedWalGenerationError``. Real child processes, real signals.
"""
from __future__ import annotations
import signal
import subprocess
import sys
import textwrap
import time
import pytest
from hermes_cli import dashboard_procs
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX signal semantics only")
# Mirrors the lifespan: sleep for the teardown budget on SIGTERM, then leave a marker and exit 0.
_GRACEFUL_CHILD = textwrap.dedent(
"""
import pathlib, signal, sys, time
marker, ready, secs = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), float(sys.argv[3])
def _on_term(_signum, _frame):
time.sleep(secs)
marker.write_text("teardown-complete")
sys.exit(0)
signal.signal(signal.SIGTERM, _on_term)
ready.write_text("ready")
time.sleep(300)
"""
)
_IGNORING_CHILD = textwrap.dedent(
"""
import pathlib, signal, sys, time
signal.signal(signal.SIGTERM, signal.SIG_IGN)
pathlib.Path(sys.argv[1]).write_text("ready")
time.sleep(300)
"""
)
def _spawn_ready(script: str, ready_path, *args: str) -> subprocess.Popen:
child = subprocess.Popen(
[sys.executable, "-c", script, *args],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + 10.0
while not ready_path.exists(): # the SIGTERM handler must be installed before we signal
if child.poll() is not None:
raise AssertionError(f"child exited before signaling ready: {child.returncode}")
if time.monotonic() > deadline:
raise AssertionError("child never signaled ready")
time.sleep(0.02)
return child
def _kill_and_reap(child: subprocess.Popen):
killed: list[int] = []
failed: list[tuple[int, str]] = []
dashboard_procs._kill_pids_posix([child.pid], killed, failed)
try:
child.wait(timeout=10)
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=10)
return killed, failed
def test_teardown_as_long_as_lifespan_budget_exits_gracefully(tmp_path):
"""A teardown spanning the 5s hosted-room stop + 1s join must not be SIGKILLed."""
marker, ready = tmp_path / "marker", tmp_path / "ready"
child = _spawn_ready(_GRACEFUL_CHILD, ready, str(marker), str(ready), "6.2")
killed, failed = _kill_and_reap(child)
assert failed == []
assert child.returncode == 0, f"SIGKILLed mid-teardown (rc={child.returncode})"
assert marker.read_text() == "teardown-complete"
assert killed == [child.pid]
def test_sigterm_ignoring_process_is_still_sigkilled(tmp_path, monkeypatch):
"""The grace is a ceiling, not a wait: a process that ignores SIGTERM is force-killed."""
monkeypatch.setattr(dashboard_procs, "_POSIX_TERM_GRACE_SECONDS", 0.6)
ready = tmp_path / "ready"
child = _spawn_ready(_IGNORING_CHILD, ready, str(ready))
killed, failed = _kill_and_reap(child)
assert failed == []
assert child.returncode == -signal.SIGKILL
assert killed == [child.pid]