diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py index 2fe5531489..03ed06dfbb 100644 --- a/hermes_cli/service_manager.py +++ b/hermes_cli/service_manager.py @@ -716,8 +716,12 @@ class S6ServiceManager: When the gateway exits with EX_CONFIG (78) — a fatal configuration error such as a token collision or no messaging platforms — we tell s6-supervise to stop restarting by exiting - 125 (permanent failure). Any other exit code lets s6 restart - normally. See #51228. + 125 (permanent failure). A clean exit 0 is an intentional stop, + not a crash: restarting after it turns any normal gateway exit + into a reconnect loop (the ashriel-discord storm in #76435 — + 1,000+ connections and a provider token reset). Only non-zero, + non-78 exits (genuine crashes) let s6 restart normally. + See #51228, #76435. """ from gateway.restart import GATEWAY_FATAL_CONFIG_EXIT_CODE @@ -727,9 +731,13 @@ class S6ServiceManager: "# shellcheck shell=sh\n" "# $1 = exit code from the run script.\n" f"# Exit {code} (EX_CONFIG) = fatal config error — don't restart.\n" + "# Exit 0 (clean stop) = intentional stop — don't restart.\n" f'if [ "$1" = "{code}" ]; then\n' " exit 125\n" "fi\n" + 'if [ "$1" = "0" ]; then\n' + " exit 125\n" + "fi\n" "exit 0\n" ) diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py index fb1cbefad0..2b044de7c5 100644 --- a/tests/hermes_cli/test_service_manager.py +++ b/tests/hermes_cli/test_service_manager.py @@ -286,6 +286,28 @@ def test_render_finish_script_exits_125_on_ex_config() -> None: assert "exit 0" in text +def test_render_finish_script_does_not_restart_on_clean_exit(tmp_path) -> None: + """Behavioral: the rendered finish script, executed for each run-exit + code, must exit 125 (no restart) for clean exit 0 and EX_CONFIG 78, + and exit 0 (restart) for genuine crashes (#76435 — restart-on-normal- + exit turned a supervised gateway into a reconnect storm).""" + import subprocess + + script = tmp_path / "finish" + script.write_text(S6ServiceManager._render_finish_script()) + script.chmod(0o755) + + def finish_exit(run_exit_code: int) -> int: + proc = subprocess.run(["sh", str(script), str(run_exit_code)], + capture_output=True) + return proc.returncode + + assert finish_exit(0) == 125 # clean stop — no restart + assert finish_exit(78) == 125 # fatal config — no restart + assert finish_exit(1) == 0 # crash — s6 restarts + assert finish_exit(137) == 0 # SIGKILL crash — s6 restarts + +