Files
hermes-agent/tests/hermes_cli/test_update_cron_deadlock_guard.py
T
teknium1 6a312fba54 feat(update): name the work a draining gateway is waiting on
`hermes update` printed "draining (up to 1875s)..." and then nothing for up
to 30 minutes while the gateway's in-band restart waited on in-flight work
(agent.restart_after_turn_timeout). Neither the updater nor the gateway log
said WHAT was being waited on, so a single long cron job read as a hung
update.

Gateway side: GatewayShutdownMixin._describe_active_work() enumerates each
unit the restart wait holds for — chat turns (session key, model, current
tool, elapsed), cron jobs (job id, elapsed, and the restart-safe external
worker pid when the run was handed off; cron/scheduler now records that pid
next to the running id), api/deferred runs by count. It is written to
gateway_state.json as `active_work` while the state is `draining` (cleared
otherwise) and appended to the 30s "Restart deferred" log line.

CLI side: hermes_cli/update_cmd_drain_report.py reads `active_work` and
prints a progress block every 30s during the SIGUSR1 exit wait — the
holder(s), their pids, elapsed time, seconds left before the forced
restart, and the config knob that caps the wait. Wired into the systemd,
launchd and manual gateway restart paths of `hermes update` and into
`hermes gateway restart`; `hermes gateway status` lists the same units
while draining. A pre-fix gateway (no `active_work` field) gets an explicit
"gateway did not report" line rather than silence.

Live A/B (real gateway, 90s no-agent cron job in flight, SIGUSR1 from the
caller): base = 79s of silence, no `active_work` in the state file; head =
the job named with pid/elapsed/remaining every interval, log line carries
the same detail.
2026-09-13 05:08:20 -07:00

168 lines
6.2 KiB
Python

"""Regression tests for #100179: cron-update three-way restart deadlock.
When `hermes update` runs INSIDE the gateway's own process tree (the
hermes-auto-update cron job), waiting for that gateway to exit is a
circular wait:
gateway waits on all in-flight work units (#77184)
-> cron agent session waits on the `hermes update` process
-> `hermes update` waits on the gateway to exit [back to A]
The wedged-loop probe cannot break it: the cron session posts activity
every ~180s (process-tool poll return), so it is never marked wedged and
the gateway burns the full 1800s force-drain cap.
The fix: when the target gateway PID is an ancestor of this process,
fire-and-forget (SIGUSR1 + return) instead of drain-waiting.
"""
from unittest.mock import patch
import pytest
linux_only = pytest.mark.linux_only
class TestAncestorDetectionGuard:
"""_is_pid_ancestor_of_current_process is the deadlock discriminator."""
def test_own_pid_is_ancestor(self):
import os
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
assert _is_pid_ancestor_of_current_process(os.getpid()) is True
def test_parent_pid_is_ancestor(self):
import os
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
ppid = os.getppid()
if ppid <= 1:
pytest.skip("no meaningful parent in this environment")
assert _is_pid_ancestor_of_current_process(ppid) is True
def test_unrelated_pid_is_not_ancestor(self):
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
# PID 0 / negative are never ancestors; a very high unlikely PID isn't
# either. Use the documented zero/negative contract for determinism.
assert _is_pid_ancestor_of_current_process(0) is False
assert _is_pid_ancestor_of_current_process(-5) is False
@linux_only
class TestSelfRestartFireAndForget:
"""_request_gateway_self_restart signals without waiting for exit."""
def test_refuses_non_ancestor_pid(self):
from hermes_cli.gateway import _request_gateway_self_restart
# A non-ancestor must be refused — signalling an unrelated gateway
# and returning immediately would skip its drain entirely.
assert _request_gateway_self_restart(0) is False
def test_signals_ancestor_and_returns_immediately(self):
"""The ancestor path sends SIGUSR1 and does NOT poll for exit."""
import os
import signal as _signal
from hermes_cli import gateway as gw
sent = []
def _fake_kill(pid, sig):
sent.append((pid, sig))
with patch.object(gw.os, "kill", side_effect=_fake_kill), patch.object(
gw, "_wait_for_pid_exit",
side_effect=AssertionError(
"fire-and-forget must NOT wait for the gateway to exit — "
"that wait is the #100179 deadlock"
),
):
ok = gw._request_gateway_self_restart(os.getpid())
assert ok is True
assert sent == [(os.getpid(), _signal.SIGUSR1)]
def test_graceful_restart_does_wait(self):
"""Contrast: the non-ancestor path DOES drain-wait (unchanged)."""
import signal as _signal
from hermes_cli import gateway as gw
waited = []
with patch.object(gw.os, "kill"), patch.object(
gw, "_wait_for_pid_exit",
side_effect=lambda pid, t, **_: waited.append((pid, t)) or True,
):
ok = gw._graceful_restart_via_sigusr1(4242, drain_timeout=7.0)
assert ok is True
assert waited == [(4242, 7.0)], "drain path must still wait for exit"
class TestDrainOrSignalTriage:
"""_drain_or_signal_gateway_for_update routes the three cases correctly."""
def _patched(self, monkeypatch, *, ancestor, wedged):
from hermes_cli import gateway as gw
calls = {"self_restart": [], "escalate": [], "drain": []}
monkeypatch.setattr(
gw, "_is_pid_ancestor_of_current_process", lambda pid: ancestor
)
monkeypatch.setattr(
gw,
"probe_gateway_loop_liveness",
lambda pid: gw.GATEWAY_LOOP_WEDGED if wedged else "alive",
)
monkeypatch.setattr(
gw,
"_request_gateway_self_restart",
lambda pid: calls["self_restart"].append(pid) or True,
)
monkeypatch.setattr(
gw, "_escalate_wedged_gateway", lambda pid: calls["escalate"].append(pid)
)
monkeypatch.setattr(
gw,
"_graceful_restart_via_sigusr1",
lambda pid, drain_timeout, **_: calls["drain"].append((pid, drain_timeout))
or True,
)
return calls
def test_ancestor_gateway_is_signalled_fire_and_forget(self, monkeypatch):
"""In-tree gateway (#100179): SIGUSR1 request, NO drain wait."""
from hermes_cli.update_cmd import _drain_or_signal_gateway_for_update
calls = self._patched(monkeypatch, ancestor=True, wedged=False)
assert _drain_or_signal_gateway_for_update(1234, 900.0, "svc") is True
assert calls["self_restart"] == [1234]
assert calls["drain"] == [], "drain-waiting on an ancestor IS the deadlock"
assert calls["escalate"] == []
def test_wedged_gateway_is_escalated(self, monkeypatch):
"""Provably-dead loop (#81642): bounded escalation, no drain wait."""
from hermes_cli.update_cmd import _drain_or_signal_gateway_for_update
calls = self._patched(monkeypatch, ancestor=False, wedged=True)
assert _drain_or_signal_gateway_for_update(1234, 900.0, "svc") is True
assert calls["escalate"] == [1234]
assert calls["drain"] == []
assert calls["self_restart"] == []
def test_live_out_of_tree_gateway_still_drain_waits(self, monkeypatch):
"""Normal out-of-tree update keeps the full graceful drain semantics."""
from hermes_cli.update_cmd import _drain_or_signal_gateway_for_update
calls = self._patched(monkeypatch, ancestor=False, wedged=False)
assert _drain_or_signal_gateway_for_update(1234, 900.0, "svc") is True
assert calls["drain"] == [(1234, 900.0)]
assert calls["self_restart"] == []
assert calls["escalate"] == []