fix(gateway): revive gateway on /restart under Restart=on-failure units - #50204
randomuser2026x wants to merge 1 commit into
Conversation
The in-chat /restart command was leaving the gateway dead on systemd
deployments using Restart=on-failure (the default for many
operator-managed and tutorial-style unit files). The gateway drained,
exited cleanly (code 0), and was never revived — the only recovery was
a host reboot.
Root cause was a multi-layer assumption mismatch:
1. gateway/run.py:_stop_impl assumed all systemd units use
Restart=always, so the Linux/systemd branch returned exit code 0
and relied on a `systemd-run` transient helper to restart the unit
immediately. Units with Restart=on-failure never see a clean exit
as a trigger, so nothing revived the process.
2. gateway/run.py:_launch_systemd_restart_shortcut hardcoded
`--user` scope, so it could not even locate the unit PID on
system-level deployments (the common case for
/etc/systemd/system/hermes-gateway.service). It silently returned
without launching the helper.
3. Even after the scope detection was fixed, the helper could not
actually start: non-root gateway units (User=ubunutu) hit a Polkit
denial on `systemd-run --system` ("Interactive authentication
required"), and `--user` requires a D-Bus user session that is
typically absent on headless servers.
The fix is two-fold:
* `_stop_impl` now always exits with GATEWAY_SERVICE_RESTART_EXIT_CODE
(75 / EX_TEMPFAIL) on service-managed restarts, regardless of
platform. Combined with RestartForceExitStatus=75 in the unit file,
systemd treats the planned restart as a controlled failure and
revives the gateway via Restart=on-failure, with RestartSec as the
only delay. The planned-restart helper is still attempted (for
RestartSec=0 setups that want sub-second restarts) but is no longer
load-bearing.
* `_launch_systemd_restart_shortcut` now probes both system and user
scopes via MainPID equality and uses whichever scope actually owns
the gateway process. It bails out safely if neither matches.
StartLimitBurst in the unit file still bounds accidental restart
loops, and the macOS launchd path is unchanged.
Verified end-to-end on Ubuntu 24.04 with hermes-gateway as a
/etc/systemd/system/... service running under User=ubunutu. The
unit uses Restart=on-failure, RestartSec=30, RestartForceExitStatus=75,
StartLimitIntervalSec=600, StartLimitBurst=5. /restart from Feishu now
drains cleanly, exits 75, and the gateway is back online ~30s later
without manual intervention.
Tests: tests/gateway/test_gateway_shutdown.py renamed the affected
case to test_gateway_stop_systemd_service_restart_uses_tempfail and
now asserts exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE.
14/14 tests in this module pass.
|
Independent root-cause analysis on a separate Evidence from our incident (system unit with
We applied the same core change ( One note for anyone mirroring the unit change: |
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
|
Merged via #56362 (commit 2f167a2) — cherry-picked onto current main with your authorship preserved in git log. Thanks for the fix! The salvage probes both |
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
- Correct the exit-75 comment: Hermes-generated units set StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst does not bound loops. The real bound is that genuine crashes exit non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists the planned code. - Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
Problem
The in-chat
/restartcommand leaves the gateway dead on systemd deployments whose unit file usesRestart=on-failure(the most common setup recommended in deployment guides and used byhermes setupon Ubuntu). The gateway drains, exits cleanly, and never comes back — the only recovery is a host reboot.Confirmed reproduction: operator-managed unit file at `/etc/systemd/system/hermes-gateway.service` with `User=ubunutu`, `Restart=on-failure`, `RestartForceExitStatus=75`. After `/restart` from Feishu the gateway stays down indefinitely until someone runs `sudo systemctl restart hermes-gateway` or reboots the host.
Root cause
Three independent bugs in series, any one of which breaks `/restart` on affected deployments:
Wrong exit-code assumption (`gateway/run.py` `_stop_impl`): the Linux/systemd branch returned exit code `0` under the assumption that all systemd units use `Restart=always` and would relaunch on a clean exit. Units with `Restart=on-failure` never see `0` as a trigger, so systemd does nothing.
Hardcoded `--user` scope (`gateway/run.py` `_launch_systemd_restart_shortcut`): the planned-restart helper assumed the unit was registered as a user service. On system-level deployments (the common case) `systemctl --user show hermes-gateway` returns an empty `MainPID`, the PID-equality check fails, and the helper silently returns without launching anything.
Polkit denial on `systemd-run --system` (deployment reality): non-root gateway units (e.g. `User=ubunutu`) invoke `systemd-run --system` to create the transient restart helper, but Polkit rejects it with `Interactive authentication required`. The `--user` fallback requires a D-Bus user session that is absent on headless servers. So even after the scope bug above is fixed, the helper still cannot start in real deployments.
Fix
Always exit `75` (EX_TEMPFAIL) on service-managed restarts. Combined with the existing `RestartForceExitStatus=75` directive in the unit file, systemd treats the planned restart as a controlled failure and revives the gateway via `Restart=on-failure`, with `RestartSec` as the only delay.
```diff
```
The planned-restart helper is still attempted (for setups that want sub-`RestartSec` restarts), but it is no longer load-bearing. If `systemd-run` is denied by Polkit or the user bus is missing, the helper silently fails and systemd's own `Restart=` machinery takes over.
A second commit also fixes the scope detection in `_launch_systemd_restart_shortcut` so the helper (when it does work) targets the correct scope:
```python
system_pid = _query_pid([]) # systemctl show ...
user_pid = _query_pid(["--user"])
if current_pid == system_pid: scope = [] # /etc/systemd/system
elif current_pid == user_pid: scope = ["--user"] # systemctl --user
else: return # not systemd
```
Behavior matrix after fix
Safety
Verification
End-to-end on Ubuntu 24.04 with hermes-gateway as a `/etc/systemd/system/` service under `User=ubunutu`, using `Restart=on-failure`, `RestartSec=30`, `RestartForceExitStatus=75`, `StartLimitIntervalSec=600`, `StartLimitBurst=5`.
Tests
`tests/gateway/test_gateway_shutdown.py`:
```
$ pytest tests/gateway/test_gateway_shutdown.py
14 passed in 16.58s
```