fix(dashboard): --stop exits 0 when a launchd KeepAlive job respawns the backend

`hermes dashboard --stop` decided its exit code by re-scanning the process
table after the kill. On macOS a launchd KeepAlive job brings the backend
back on a fresh PID within the grace window, so the re-scan found it and
--stop exited 1 right after printing that the stop succeeded and that the job
restarts itself. Judge the exit code from the kill result instead: exit 1
only when a matched pid could not be stopped.
This commit is contained in:
teknium1
2026-09-15 14:17:04 -07:00
committed by Teknium
parent 29979b8c25
commit 469e5b6a2e
2 changed files with 18 additions and 13 deletions
+5 -3
View File
@@ -2439,11 +2439,13 @@ def _dashboard_lifecycle_flags(args, token_file) -> None:
print("No hermes dashboard processes running.")
sys.exit(0)
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`;
# it prints outcomes itself. Exit 1 only if every pid was unkillable.
# it prints outcomes itself. Exit 1 only if a pid was unkillable — judged
# from the kill result, not a re-scan: a launchd KeepAlive job respawns
# its backend on a fresh PID, which is not a failed stop.
from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes
_kill_stale_dashboard_processes(reason="requested via --stop")
sys.exit(1 if _find_stale_dashboard_pids() else 0)
result = _kill_stale_dashboard_processes(reason="requested via --stop")
sys.exit(1 if result["failed"] else 0)
def _dashboard_validate_serve_args(args, headless_backend, token_file):
@@ -80,12 +80,15 @@ class TestDashboardStatus:
class TestDashboardStop:
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
"""After the kill, if the second scan returns empty we exit 0."""
# First scan: finds two processes. Second (verification) scan: empty.
scans = iter([[12345, 12346], []])
"""Every matched pid was killed -> exit 0, even when a scan afterwards would find a
process again: a launchd KeepAlive job respawns its backend on a fresh PID, and that
respawn is not a failed stop (the kill path already warns about it)."""
scans = iter([[12345, 12346], [12347]])
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes") as mock_kill, \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes",
return_value={"matched": [12345, 12346], "killed": [12345, 12346],
"failed": [], "unrecovered": [12345, 12346]}) as mock_kill, \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
mock_kill.assert_called_once()
@@ -98,12 +101,12 @@ class TestDashboardStop:
assert exc.value.code == 0
def test_stop_exits_nonzero_if_kill_leaves_survivors(self):
"""If the second scan still finds PIDs, we exit 1 so scripts can
detect that the stop didn't succeed (e.g. permission denied)."""
scans = iter([[12345], [12345]]) # both scans find the same PID
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes"), \
"""A pid the kill path could not stop (e.g. permission denied) -> exit 1 so
scripts can detect that the stop didn't succeed."""
with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]), \
patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes",
return_value={"matched": [12345], "killed": [],
"failed": [(12345, "Operation not permitted")], "unrecovered": []}), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 1