From 469e5b6a2eb0086682d77ce888dc782b50985257 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:17:04 -0700 Subject: [PATCH] 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. --- hermes_cli/main.py | 8 ++++--- .../test_dashboard_lifecycle_flags.py | 23 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ea237c953c..ac4e0a4fb7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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): diff --git a/tests/hermes_cli/test_dashboard_lifecycle_flags.py b/tests/hermes_cli/test_dashboard_lifecycle_flags.py index c44dd7df9f..1d78547242 100644 --- a/tests/hermes_cli/test_dashboard_lifecycle_flags.py +++ b/tests/hermes_cli/test_dashboard_lifecycle_flags.py @@ -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