Skip to content

bug(diagnostics): check_systemd_timing_alignment false-positive — systemctl show returns default 90s for nonexistent --user unit #36755

Description

@afred-pennyworht

Symptom

Running hermes-gateway from a system-level systemd unit (operator deploys via NixOS / Ansible / etc., not via hermes gateway install) produces a permanent startup warning even when the actual system unit's TimeoutStopSec is correctly sized:

```
WARNING gateway.run: Stale systemd unit detected: hermes-gateway.service has
TimeoutStopSec=90s but drain_timeout=180s (expected >=210s). systemd may
SIGKILL the gateway mid-drain. Run `hermes gateway service install --replace`
to regenerate the unit, or shorten agent.restart_drain_timeout.
```

…but systemctl cat hermes-gateway.service confirms TimeoutStopSec=240s, which satisfies the documented drain_timeout + 30s headroom = 210s threshold. No mismatch in reality.

(Side note: the warning text also references the old CLI surface — should be hermes gateway install --replace, not hermes gateway service install --replace. The service sub-noun was removed.)

Root cause

gateway/shutdown_forensics.py:367-389 in 0.15.2:

```python
for flag in (["--user"], []):
try:
result = subprocess.run(
["systemctl", *flag, "show", unit_name, "--property=TimeoutStopUSec"],
capture_output=True, text=True, timeout=2.0,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if result.returncode != 0:
continue
# … parses TimeoutStopUSec, sets timeout_us, breaks out
```

systemctl show is permissive: for a unit that does not exist in the targeted manager, it returns rc=0 and emits the systemd compiled-in defaults — TimeoutStopUSec=1min 30s (90s). It does not error out the way systemctl status <nonexistent> would.

When the gateway runs as User=hermes with XDG_RUNTIME_DIR=/run/user/<uid> set (mandatory for rootless podman in the same service), the user-bus is reachable. So systemctl --user show hermes-gateway.service --property=TimeoutStopUSec is the first iteration of the loop, succeeds with rc=0, returns the 90s default, parses, breaks — and the system unit (with the correct 240s) is never consulted.

Reproducer (on a host where the hermes user has no user-level units installed):

```bash
$ sudo -u hermes -H \
env XDG_RUNTIME_DIR=/run/user/1002 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus \
systemctl --user list-unit-files | grep -i hermes
UNIT FILE STATE PRESET
57 unit files listed.

$ sudo -u hermes -H \
env XDG_RUNTIME_DIR=/run/user/1002 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus \
systemctl --user show hermes-gateway.service \
--property=LoadState --property=TimeoutStopUSec
LoadState=not-found
TimeoutStopUSec=1min 30s # ← bogus default, since LoadState=not-found
```

Hermes blindly trusts the 90s value.

Proposed fix

Add LoadState to the same query and skip iterations where the unit isn't loaded:

```diff
for flag in (["--user"], []):
try:
result = subprocess.run(

  •        [\"systemctl\", *flag, \"show\", unit_name, \"--property=TimeoutStopUSec\"],
    
  •        [\"systemctl\", *flag, \"show\", unit_name,
    
  •         \"--property=LoadState\",
    
  •         \"--property=TimeoutStopUSec\"],
           capture_output=True, text=True, timeout=2.0,
       )
    
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
    continue
    if result.returncode != 0:
    continue
  • Output: "TimeoutStopUSec=1min 30s" or "TimeoutStopUSec=90000000"

  • Skip if the unit isn't actually loaded in this manager — `systemctl

  • show` returns rc=0 and emits compiled-in defaults for nonexistent

  • units (notably TimeoutStopUSec=1min 30s), which would produce a

  • false-positive 'stale unit' warning when the gateway is deployed

  • via a system-level unit but the user manager is reachable.

  • load_state: Optional[str] = None
  • for line in result.stdout.splitlines():
  •    if line.startswith(\"LoadState=\"):
    
  •        load_state = line.split(\"=\", 1)[1].strip()
    
  •        break
    
  • if load_state and load_state != "loaded":
  •    continue
    
    for line in result.stdout.splitlines():
    if line.startswith("TimeoutStopUSec="):
    ...
    ```

Same selectivity, one extra property in the query, no other behaviour change.

Bonus: while touching the file, the warning text on gateway/run.py:3904 could be updated to reference hermes gateway install --replace (the service sub-noun was removed in a recent CLI refactor — the current text gives invalid choice: 'service').

Happy to send a PR.

Environment

  • hermes-agent 0.15.2 (v2026.5.29.2)
  • NixOS x86_64, gateway deployed via systemd.services.hermes-gateway (system unit, NOT via hermes gateway install)
  • gateway user: hermes (linger=true, system service with XDG_RUNTIME_DIR=/run/user/<uid> set for rootless podman)
  • No user-level hermes-gateway.service installed (verified via systemctl --user list-unit-files)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havecomp/gatewayGateway runner, session dispatch, deliverytype/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions