Gateway service restart loops and exit code fixes
NousResearch/hermes-agent · cluster 576 · gateway
Data as of
9 open items. Fix merged in #614. Check it covers your case.
The short answer
- Known?
- Yes. 39 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #614. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #614, #9875, #53903. That is separate from the fix event. - Still open
- 3 issues and 6 PRs.
Next step: read #614, then check whether your report is one of the 3 still open.
Read #614Pick up a Lab taskThese issues and fixes address gateway daemon behavior under systemd, launchd, and other service managers, focusing on unintended restart loops caused by incorrect exit codes on intentional stops, the --replace flag in generated service definitions, and stale or colliding PID file handling.
Open issues3
- Issue#24344[Bug]: Systemd gateway service exits with status 1 on intentional stop
- Issue#41631[Bug]: gateway exits code 1 (→ unit 'failed') on systemctl stop; planned stops should exit 0
- Issue#57739[Bug]: macOS gateway enters restart loop — hardcoded --replace in launchd plist and incorrect exit code on SIGTERM
Open PRs6
- PR#24351fix(gateway): make intentional systemd stops exit cleanly
- PR#41642fix(gateway): exit 0 on systemctl stop instead of exit 1 (failed unit)
- PR#41690fix(gateway): exit 0 when systemd sends SIGTERM via systemctl stop
- PR#42555fix(gateway): write planned-stop marker from systemd ExecStop
- PR#63535fix(gateway): prevent macOS gateway restart loop on unexpected SIGTERM
- PR#85901fix(gateway): mark direct systemd stops as planned
Fixes on record
-
#614 fix: resolve systemd restart loop with --replace flag
files:
gateway/run.py,hermes_cli/gateway.py,hermes_cli/main.py -
#9875 fix: gateway auto-recovers from unexpected SIGTERM via systemd (#5646)
files:
gateway/run.py,gateway/status.pyShow the diff
-
gateway/run.py@@ -9261,8 +9261,18 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = runner = GatewayRunner(config) + # Track whether a signal initiated the shutdown (vs. internal request). + # When an unexpected SIGTERM kills the gateway, we exit non-zero so + # systemd's Restart=on-failure revives the process. systemctl stop + # is safe: systemd tracks stop-requested state independently of exit + # code, so Restart= never fires for a deliberate stop. + _signal_initiated_shutdown = False + # Set up signal handlers def shutdown_signal_handler(): + nonlocal _signal_initiated_shutdown + _signal_initiated_shutdown = True + logger.info("Received SIGTERM/SIGINT — initiating shutdown") asyncio.create_task(runner.stop()) def restart_signal_handler(): @@ -9332,6 +9342,21 @@ def restart_signal_handler(): if runner.exit_code is not None: raise SystemExit(runner.exit_code) + # When a signal (SIGTERM/SIGINT) caused the shutdown and it wasn't a + # planned restart (/restart, /update, SIGUSR1), exit non-zero so + # systemd's Restart=on-failure revives the process. This covers: + # - hermes update killing the gateway mid-work + # - External kill commands + # - WSL2/container runtime sending unexpected signals + # systemctl stop is safe: systemd tracks "stop requested" state + # independently of exit code, so Restart= never fires for it. + if _signal_initiated_shutdown and not runner._restart_requested: + logger.info( + "Exiting with code 1 (signal-initiated shutdown without restart " + "request) so systemd Restart=on-failure can revive the gateway." + ) + return False # → sys.exit(1) in the caller + return True -
gateway/status.py@@ -266,9 +266,25 @@ def read_runtime_status() -> Optional[dict[str, Any]]: def remove_pid_file() -> None: - """Remove the gateway PID file if it exists.""" + """Remove the gateway PID file, but only if it belongs to this process. + + During --replace handoffs, the old process's atexit handler can fire AFTER + the new process has written its own PID file. Blindly removing the file + would delete the new process's record, leaving the gateway running with no + PID file (invisible to ``get_running_pid()``). + """ try: - _get_pid_path().unlink(missing_ok=True) + path = _get_pid_path() + record = _read_json_file(path) + if record is not None: + try: + file_pid = int(record["pid"]) + except (KeyError, TypeError, ValueError): + file_pid = None + if file_pid is not None and file_pid != os.getpid(): + # PID file belongs to a different process — leave it alone. + return + path.unlink(missing_ok=True) except Exception: pass
-
-
#51734 fix(gateway): detect launchd in /restart service-manager probe (#43475)
files:
gateway/slash_commands.py,scripts/release.py,tests/gateway/test_restart_service_detection.pyShow the diff
-
gateway/slash_commands.py@@ -960,7 +960,15 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem # us. The detached subprocess approach (setsid + bash) doesn't work # under systemd (KillMode=mixed kills the cgroup) or Docker (tini # exits when the gateway dies, taking the detached helper with it). - _under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this + # systemd sets INVOCATION_ID; launchd sets XPC_SERVICE_NAME to the + # job label. Without the launchd check, macOS /restart takes the + # detached path and exits 0, which KeepAlive.SuccessfulExit=false + # treats as a deliberate stop — the gateway stays dead until next + # login. Interactive macOS shells inherit XPC_SERVICE_NAME=0, so + # "0" must count as not-under-launchd. + _under_service = bool(os.environ.get("INVOCATION_ID")) or os.environ.get( + "XPC_SERVICE_NAME", "0" + ) not in ("", "0") _in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") if _under_service or _in_container: self.request_restart(detached=False, via_service=True) -
scripts/release.py@@ -1547,6 +1547,7 @@ "[redacted:email]": "bird", # PR #25219 (gateway docker exit-75 restart) "[redacted:email]": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) "[redacted:email]": "davidcampbelldc", + "[redacted:email]": "chazmaniandinkle", # PR #43888 (launchd /restart detection) "[redacted:email]": "hehehe0803", # PR #26212 salvage (codex kanban writable root) "[redacted:email]": "hehehe0803", "[redacted:email]": "kasunvinod", # PR #24126 salvage (codex timeout propagation) -
tests/gateway/test_restart_service_detection.py@@ -0,0 +1,85 @@ +"""Tests for /restart service-manager detection (launchd vs interactive). + +The /restart handler routes through ``request_restart(via_service=True)`` +when a service manager supervises the gateway, so the process exits with +the service-restart code and the manager relaunches it. Under macOS +launchd the plist uses ``KeepAlive.SuccessfulExit=false`` — a clean exit 0 +is treated as a deliberate stop and the gateway stays dead (#43475) — so +launchd must be detected here in the handler, not only at the exit-code +site (which never runs unless ``via_service=True`` is already set). + +launchd sets ``XPC_SERVICE_NAME`` to the job label for processes it +spawns. Interactive macOS shells inherit ``XPC_SERVICE_NAME=0`` (a +truthy string), so the probe must treat ``"0"`` as not-under-launchd: +routing an unsupervised interactive gateway to the service path would +make it exit non-zero with nothing to revive it. +""" +from unittest.mock import MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.platforms.base import MessageEvent, MessageType +from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source + + +def _make_restart_event(update_id: int | None = 100) -> MessageEvent: + return MessageEvent( + text="/restart", + message_type=MessageType.TEXT, + source=make_restart_source(), + message_id="m1", + platform_update_id=update_id, + ) + + +def _make_runner_with_mock_restart(tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.delenv("INVOCATION_ID", raising=False) + monkeypatch.delenv("XPC_SERVICE_NAME", raising=False) + runner, _adapter = make_restart_runner() + runner.request_restart = MagicMock(return_value=True) + return runner + + [redacted:email] +async def test_restart_under_launchd_uses_service_path(tmp_path, monkeypatch): + """launchd job label in XPC_SERVICE_NAME routes /restart via the service path.""" + runner = _make_runner_with_mock_restart(tmp_path, monkeypatch) + monkeypatch.setenv("XPC_SERVICE_NAME", "ai.hermes.gateway") + + await runner._handle_restart_command(_make_restart_event()) + + runner.request_restart.assert_called_once_with(detached=False, via_service=True) + + [redacted:email] +async def test_restart_in_interactive_macos_shell_uses_detached_path(tmp_path, monkeypatch): + """XPC_SERVICE_NAME=0 (inherited by interactive macOS shells) is NOT a service.""" + runner = _make_runner_with_mock_restart(tmp_path, monkeypatch) + monkeypatch.setenv("XPC_SERVICE_NAME", "0") + + await runner._handle_restart_command(_make_restart_event()) + + runner.request_restart.assert_called_once_with(detached=True, via_service=False) + + [redacted:email] +async def test_restart_without_service_env_uses_detached_path(tmp_path, monkeypatch): + """No service-manager env at all falls back to the detached restart.""" + runner = _make_runner_with_mock_restart(tmp_path, monkeypatch) + + await runner._handle_restart_command(_make_restart_event()) + + runner.request_restart.assert_called_once_with(detached=True, via_service=False) + + [redacted:email] +async def test_restart_under_systemd_uses_service_path(tmp_path, monkeypatch): + """INVOCATION_ID (systemd) still routes via the service path.""" + runner = _make_runner_with_mock_restart(tmp_path, monkeypatch) + monkeypatch.setenv("INVOCATION_ID", "abc123") + + await runner._handle_restart_command(_make_restart_event()) + + runner.request_restart.assert_called_once_with(detached=False, via_service=True)
-
-
#53903 docs(gateway): warn against custom ExecStopPost kill drop-in (restart loop)
files:
website/docs/user-guide/messaging/index.md
17 PRs closed without merging
From the close comments: closed without comment 3 · superseded by another PR 3 · done elsewhere 2 · duplicate 1 · partial fix 1 · stale or abandoned 1 · other 6
- #580 fix: make gateway run idempotent when already running · partial fix
-
#582 fix: prevent systemd restart loop from stale/colliding PID files · other →
#614 - #583 fix(gateway): make systemd startup idempotent and service-safe · other
-
#613 fix(gateway): avoid systemd restart loop when instance already running · other →
#614 -
#21555 fix(gateway): exit cleanly when another instance detected to avoid launchd restart loop · done elsewhere →
#51734,#37388,#43475,#21549,#42446,#42450,#28561
Show 12 more
-
#21907 fix(gateway): avoid --replace in generated services · duplicate →
#23281,#23272 - #22391 fix(gateway): exit cleanly under systemd when duplicate instance detected · closed without comment
-
#23281 fix(gateway): stop default replace in service runs · other →
f556edc -
#23330 fix(gateway): remove --replace from systemd/launchd service definitions (fixes #23272) · superseded by another PR →
#26186 -
#23350 fix(gateway): skip --replace when running under systemd · other →
#23330,#23281,#23272 - #26186 fix(gateway): remove --replace flag from systemd/launchd/windows service definitions · superseded by another PR
-
#32989 fix(gateway): avoid systemd restart loops on replace · other →
4d18717,#32951 -
#36223 fix(gateway): wait for Windows restart slot before start · done elsewhere →
e7d2f0b -
#41639 fix(gateway): treat SIGTERM under systemd as planned stop (exit 0) · superseded by another PR →
#41642,#41631 - #57743 fix(gateway): prevent macOS gateway restart loop on unexpected SIGTERM · closed without comment
- #57746 fix(gateway): prevent macOS gateway restart loop on unexpected SIGTERM · closed without comment
-
#113358 fix(gateway): mark planned systemd stops · stale or abandoned →
#24351,#42517,#42555,#85901
Closed issues · 9
- Closed#576[Bug]: systemd restart loop (non-TTY CLI exit and "gateway already running" conflict)
- Closed#5646[Bug]: gateway restart / --replace flag causes gateway to exit immediately (status=0)
- Closed#21549fix(gateway): launchd double-spawn triggers infinite restart death spiral (exit code 1 on instance detection)
- Closed#21915[systemd] Incomplete process cleanup during restart causes port conflict and infinite restart loop
- Closed#23272Gateway restart loop: hardcoded --replace + systemd ExecStopPost causes infinite restart cycle
- Closed#27275Generated Linux gateway service can flap forever because ExecStart uses gateway run --replace under Type=simple
- Closed#32951Avoid systemd gateway restart/takeover loops from Restart=always + --replace
- Closed#36213[Bug] Windows: hermes gateway restart does not verify old process termination, causing silent Gateway failure
- Closed#42517fix(gateway): ExecStop should write planned-stop marker instead of inferring signal source
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab