6a312fba54
`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.
168 lines
6.2 KiB
Python
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"] == []
|