fix(cli): arm exit watchdog on shutdown signal, not at chat startup (#66278)

A hermes --tui session whose main thread wedges before app.run() returns
never executes the finally that calls _run_cleanup — the only place the
exit watchdog was armed — so a dead CLI lingered indefinitely (observed
~47 min at 4% CPU, the #65998 class).

Arm the backstop from the SIGTERM/SIGHUP handlers instead (both the
interactive and single-query paths), the earliest moment shutdown intent
is unambiguous. The signal-armed leash is 2x HERMES_EXIT_WATCHDOG_S so a
slow-but-progressing _run_cleanup (which still arms its own tighter timer)
is never cut short; the outer timer only wins when cleanup was never
reached. Idempotent across repeated signals; never raises from a handler.

Deliberately NOT armed at startup: the watchdog thread calls os._exit(0)
unconditionally after its sleep, so a startup-armed timer (the #65998
approach) would hard-kill every session that outlives the timeout.

Supersedes #65998; thanks @JeffStone69 for the report and root-cause gap
analysis.
This commit is contained in:
Teknium 2026-07-17 06:49:04 -07:00 committed by GitHub
parent 348e9912ff
commit c66891db08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 178 additions and 0 deletions

55
cli.py
View file

@ -1056,6 +1056,50 @@ def _arm_exit_watchdog(timeout_s: float | None = None) -> None:
pass # best-effort — never block shutdown on watchdog setup
_signal_watchdog_armed = False
def _arm_exit_watchdog_on_shutdown_signal() -> None:
"""Arm the exit backstop the moment a termination signal arrives.
SIGTERM/SIGHUP establish unambiguous shutdown intent, but the graceful
path from signal ``agent.interrupt()`` ``app.exit()`` /
``KeyboardInterrupt`` ``finally`` ``_run_cleanup`` has several wedge
points BEFORE ``_run_cleanup`` arms the normal watchdog: a main thread
parked in a syscall that never observes the unwind, a prompt_toolkit
teardown that never returns, or an agent worker blocking the ``finally``.
When that happens the process has NO backstop and a "dead" CLI lingers
(observed: ``hermes --tui`` alive ~47 min at 4% CPU after terminal close
the #65998 class).
Arming at signal time closes that window. The leash is 2× the normal
cleanup timeout so a slow-but-progressing ``_run_cleanup`` (which arms
its own tighter timer when it starts) is never cut short by this outer
backstop this timer only wins when cleanup was never reached at all.
Deliberately NOT armed at chat startup: the watchdog thread calls
``os._exit(0)`` unconditionally after its sleep, so arming without
shutdown intent would hard-kill every session that outlives the timeout.
Idempotent (module flag) so repeated signals don't stack timer threads.
Never raises safe to call from a signal handler.
"""
global _signal_watchdog_armed
if _signal_watchdog_armed:
return
_signal_watchdog_armed = True
try:
base = float(os.getenv("HERMES_EXIT_WATCHDOG_S", "30"))
except (TypeError, ValueError):
base = 30.0
if base <= 0:
return # explicitly disabled
try:
_arm_exit_watchdog(timeout_s=base * 2)
except Exception:
pass # never let the backstop break signal handling
def _run_cleanup(*, notify_session_finalize: bool = True):
"""Run resource cleanup exactly once."""
global _cleanup_done
@ -15574,6 +15618,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
logger.debug("Received signal %s, triggering graceful shutdown", signum)
except Exception:
pass # never let logging raise from a signal handler (#13710 regression)
# Shutdown intent is now unambiguous — arm the exit backstop
# IMMEDIATELY, before the graceful unwind below. If any step of
# that unwind wedges (main thread parked in a syscall, prompt_toolkit
# teardown never returning), _run_cleanup never runs and would
# never arm its own watchdog — leaving a "dead" CLI alive for
# minutes (#65998 class). Never raises.
_arm_exit_watchdog_on_shutdown_signal()
try:
if getattr(self, "agent", None) and getattr(self, "_agent_running", False):
self.agent.interrupt(f"received signal {signum}")
@ -16178,6 +16229,10 @@ def main(
# default for debugging.
def _signal_handler_q(signum, frame):
logger.debug("Received signal %s in single-query mode", signum)
# Arm the exit backstop now that shutdown intent is unambiguous —
# covers wedges in the unwind below that would otherwise leave the
# process alive with no watchdog (#65998 class). Never raises.
_arm_exit_watchdog_on_shutdown_signal()
try:
_agent = getattr(cli, "agent", None)
if _agent is not None:

View file

@ -0,0 +1,123 @@
"""Exit watchdog: arm on shutdown *intent* (signal), never at chat startup.
Regression coverage for the #65998 class: a ``hermes --tui`` process whose
main thread wedges before ``app.run()`` returns never executes the ``finally``
that calls ``_run_cleanup`` the only place the exit watchdog used to be
armed so a "dead" CLI lingered indefinitely (observed ~47 min at 4% CPU).
The fix arms the backstop from the SIGTERM/SIGHUP handlers via
``_arm_exit_watchdog_on_shutdown_signal()``. Arming at *startup* (the
rejected #65998 approach) is specifically forbidden: the watchdog thread
calls ``os._exit(0)`` unconditionally after its sleep, so a startup-armed
timer hard-kills every session that outlives the timeout.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from unittest.mock import patch
import pytest
import cli
@pytest.fixture(autouse=True)
def _reset_arm_flag(monkeypatch):
"""Each test starts with the idempotency flag clear."""
monkeypatch.setattr(cli, "_signal_watchdog_armed", False)
class TestSignalArmLogic:
def test_arms_with_double_cleanup_timeout(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=14.0)
def test_idempotent_across_repeated_signals(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
assert arm.call_count == 1
def test_disabled_via_env_zero(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "0")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_not_called()
def test_bad_env_value_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "not-a-number")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=60.0)
def test_never_raises_even_if_arm_explodes(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog", side_effect=RuntimeError("boom")):
cli._arm_exit_watchdog_on_shutdown_signal() # must not raise
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# A minimal stand-in for the wedged-CLI shape: signal handlers mirror the
# production wiring (arm-on-signal, then a graceful unwind that wedges), and
# the main thread parks the way a stuck app.run() does.
_WEDGE_SRC = """
import os, signal, sys, time
sys.path.insert(0, {repo!r})
import cli
def _handler(signum, frame):
# Production wiring: arm the backstop the moment shutdown intent exists,
# then attempt a graceful unwind — which, in this repro, wedges (the
# KeyboardInterrupt lands in a frame that swallows it).
cli._arm_exit_watchdog_on_shutdown_signal()
signal.signal(signal.SIGTERM, _handler)
print("READY", flush=True)
while True: # the wedge: never observes any unwind
time.sleep(0.2)
"""
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def test_sigterm_on_wedged_process_forces_exit_within_leash():
"""E2E: a wedged process armed via the signal path self-exits at ~2×
HERMES_EXIT_WATCHDOG_S; without the signal it would live forever."""
env = dict(os.environ, HERMES_EXIT_WATCHDOG_S="1", PYTHONPATH=_REPO_ROOT)
# _arm_exit_watchdog refuses to arm under pytest (it would kill the test
# worker); the subprocess must look like a real CLI.
env.pop("PYTEST_CURRENT_TEST", None)
src = _WEDGE_SRC.format(repo=_REPO_ROOT)
p = subprocess.Popen(
[sys.executable, "-c", src],
env=env,
stdout=subprocess.PIPE,
text=True,
)
try:
assert p.stdout is not None
assert p.stdout.readline().strip() == "READY"
# Wedged, no signal yet: must still be alive well past the leash
# (proves we did NOT arm at startup — the #65998 regression).
time.sleep(3.0)
assert p.poll() is None, "watchdog fired without shutdown intent"
p.send_signal(signal.SIGTERM)
t0 = time.time()
rc = p.wait(timeout=10)
elapsed = time.time() - t0
assert rc == 0
# Leash is 2×1s; generous CI slack.
assert elapsed < 8.0, f"exit took {elapsed:.1f}s; leash should be ~2s"
finally:
if p.poll() is None:
p.kill()