"""Tests for the stale-dashboard handling run at the end of ``hermes update``. ``hermes update`` detects ``hermes dashboard`` processes left over from the previous version and kills them (SIGTERM + SIGKILL grace, or ``taskkill /F`` on Windows). Without this, the running backend silently serves stale Python against a freshly-updated JS bundle, producing 401s / empty data. History: - #16872 introduced the warn-only helper (``_warn_stale_dashboard_processes``). - #17049 fixed a Windows wmic UnicodeDecodeError crash on non-UTF-8 locales. - This file now also covers the kill semantics that replaced the warning. """ from __future__ import annotations import importlib import os import sys from unittest.mock import patch, MagicMock import pytest from hermes_cli.main import ( _finish_dashboard_update_cleanup, _find_stale_dashboard_pids, _kill_stale_dashboard_processes, _restart_managed_dashboard_service, _warn_stale_dashboard_processes, # back-compat alias ) @pytest.fixture(autouse=True) def _refresh_bindings_against_live_module(): """Rebind module-level names to the *current* ``hermes_cli.main``. Other tests in the suite (notably ``test_env_loader.py`` and ``test_skills_subparser.py``) reload or delete ``hermes_cli.main`` from ``sys.modules``. When that happens on the same xdist worker before we run, our top-of-file ``from hermes_cli.main import ...`` bindings end up pointing at the *old* module object. ``patch(\"hermes_cli.main.X\")`` then patches the *new* module, but the function we call still resolves ``_find_stale_dashboard_pids`` via its stale ``__globals__``, so every patch becomes a no-op and the kill path silently returns early. Refreshing the bindings (and the patch target) to the live module object — and keeping them consistent — makes the tests immune to ordering within the worker. The fix lives in the test module because the two pollutants above are load-bearing for their own tests. """ global _finish_dashboard_update_cleanup global _find_stale_dashboard_pids global _kill_stale_dashboard_processes global _restart_managed_dashboard_service global _warn_stale_dashboard_processes live = sys.modules.get("hermes_cli.main") if live is None: live = importlib.import_module("hermes_cli.main") _finish_dashboard_update_cleanup = live._finish_dashboard_update_cleanup _find_stale_dashboard_pids = live._find_stale_dashboard_pids _kill_stale_dashboard_processes = live._kill_stale_dashboard_processes _restart_managed_dashboard_service = live._restart_managed_dashboard_service _warn_stale_dashboard_processes = live._warn_stale_dashboard_processes yield def _ps_line(pid: int, cmd: str) -> str: """Format a line as it would appear in ``ps -A -o pid=,command=`` output.""" return f"{pid:>7} {cmd}" def _ps_runner(stdout: str): """Build a subprocess.run side_effect that only stubs ps -A calls. Any other subprocess.run invocation (e.g. taskkill on Windows) is handed back as a successful no-op. This lets tests exercise the real scan path without having to re-stub every unrelated subprocess call made later in ``_kill_stale_dashboard_processes``. """ def _side_effect(args, *a, **kw): if isinstance(args, (list, tuple)) and args and args[0] == "ps": return MagicMock(returncode=0, stdout=stdout, stderr="") # Any other subprocess.run (e.g. taskkill) — benign success stub. return MagicMock(returncode=0, stdout="", stderr="") return _side_effect class TestFindStaleDashboardPids: """Unit tests for the ps/wmic-based detection step.""" def test_no_matches_returns_empty(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=_ps_line(111, "/usr/bin/python3 -m some.other.module") + "\n" + _ps_line(222, "/usr/bin/bash") + "\n", stderr="", ) assert _find_stale_dashboard_pids() == [] def test_matches_running_dashboard(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=_ps_line(12345, "python3 -m hermes_cli.main dashboard --port 9119") + "\n", stderr="", ) assert _find_stale_dashboard_pids() == [12345] def test_multiple_matches(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ _ps_line(12345, "python3 -m hermes_cli.main dashboard --port 9119"), _ps_line(12346, "hermes dashboard --port 9120 --no-open"), _ps_line(12347, "python /home/x/hermes_cli/main.py dashboard"), ]) + "\n", stderr="", ) assert sorted(_find_stale_dashboard_pids()) == [12345, 12346, 12347] def test_self_pid_excluded(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ _ps_line(os.getpid(), "python3 -m hermes_cli.main dashboard"), _ps_line(12345, "hermes dashboard --port 9119"), ]) + "\n", stderr="", ) pids = _find_stale_dashboard_pids() assert os.getpid() not in pids assert 12345 in pids def test_ps_not_found_returns_empty(self): with patch("subprocess.run", side_effect=FileNotFoundError): assert _find_stale_dashboard_pids() == [] def test_ps_timeout_returns_empty(self): import subprocess as sp with patch("subprocess.run", side_effect=sp.TimeoutExpired("ps", 10)): assert _find_stale_dashboard_pids() == [] def test_unrelated_process_containing_word_dashboard_not_matched(self): """Guards against greedy pgrep-style matching catching chat sessions or unrelated processes whose cmdline happens to contain 'dashboard'. """ with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ _ps_line(12345, "python3 -m hermes_cli.main dashboard --port 9119"), _ps_line(22222, "python3 -m hermes_cli.main chat -q 'rewrite my dashboard'"), _ps_line(33333, "node /opt/grafana/dashboard-server.js"), ]) + "\n", stderr="", ) pids = _find_stale_dashboard_pids() assert pids == [12345] def test_grep_lines_ignored(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ _ps_line(99999, "grep hermes dashboard"), _ps_line(12345, "hermes dashboard --port 9119"), ]) + "\n", stderr="", ) pids = _find_stale_dashboard_pids() assert 99999 not in pids assert 12345 in pids def test_invalid_pid_lines_skipped(self): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ "notapid hermes dashboard --bad", _ps_line(12345, "hermes dashboard --port 9119"), " ", ]) + "\n", stderr="", ) pids = _find_stale_dashboard_pids() assert pids == [12345] def test_exclude_pids_filters_specified_pids(self): """exclude_pids removes specific PIDs from the result — used by the Desktop Electron app to protect its own backend child. (#37532) """ with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="\n".join([ _ps_line(11111, "hermes dashboard --port 9119"), _ps_line(22222, "hermes dashboard --port 9120"), _ps_line(33333, "hermes dashboard --port 9121"), ]) + "\n", stderr="", ) # Exclude the desktop-managed backend PID pids = _find_stale_dashboard_pids(exclude_pids={22222}) assert 11111 in pids assert 22222 not in pids assert 33333 in pids def test_exclude_pids_none_is_noop(self): """Passing exclude_pids=None (the default) changes nothing.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=_ps_line(12345, "hermes dashboard --port 9119") + "\n", stderr="", ) pids = _find_stale_dashboard_pids(exclude_pids=None) assert pids == [12345] def test_exclude_all_pids_returns_empty(self): """If all matched PIDs are excluded, the result is empty.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=_ps_line(12345, "hermes dashboard --port 9119") + "\n", stderr="", ) pids = _find_stale_dashboard_pids(exclude_pids={12345}) assert pids == [] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX kill semantics") class TestKillStaleDashboardPosix: """Kill path on Linux / macOS: SIGTERM then SIGKILL any survivors.""" def test_no_stale_processes_is_a_noop(self, capsys): with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[]): result = _kill_stale_dashboard_processes() assert capsys.readouterr().out == "" assert result == {"matched": [], "killed": [], "failed": []} def test_sigterm_graceful_exit(self, capsys): """Processes that exit on SIGTERM (the probe gets ProcessLookupError) are reported as stopped and SIGKILL is never sent.""" import signal as _signal killed_signals: list[tuple[int, int]] = [] def fake_kill(pid, sig): killed_signals.append((pid, sig)) if sig == 0: # Probe after SIGTERM → "process gone". raise ProcessLookupError # SIGTERM itself: succeed silently. with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345, 12346]), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): result = _kill_stale_dashboard_processes() # Both got SIGTERM. sigterms = [pid for pid, sig in killed_signals if sig == _signal.SIGTERM] assert sorted(sigterms) == [12345, 12346] # No SIGKILL was needed. assert not any(sig == _signal.SIGKILL for _, sig in killed_signals) assert result["matched"] == [12345, 12346] assert result["killed"] == [12345, 12346] assert result["failed"] == [] out = capsys.readouterr().out assert "Stopping 2 dashboard" in out assert "✓ stopped PID 12345" in out assert "✓ stopped PID 12346" in out assert "Restart the dashboard" in out def test_sigkill_fallback_for_survivors(self, capsys): """If a process survives SIGTERM + the grace window, SIGKILL is sent.""" import signal as _signal sent: list[tuple[int, int]] = [] def fake_kill(pid, sig): sent.append((pid, sig)) # Simulate stubborn process: probe (sig 0) always succeeds, # SIGTERM does nothing, SIGKILL is where it "dies". if sig in {_signal.SIGTERM, 0, _signal.SIGKILL}: return # Any other signal — also fine. with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[99999]), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"), \ patch("time.monotonic", side_effect=[0.0] + [10.0] * 20): # monotonic jumps past the 3s deadline on the second read so the # grace loop exits immediately after one iteration. _kill_stale_dashboard_processes() signals_sent = [sig for _, sig in sent] assert _signal.SIGTERM in signals_sent assert _signal.SIGKILL in signals_sent out = capsys.readouterr().out assert "✓ stopped PID 99999" in out def test_permission_error_is_reported_not_raised(self, capsys): """os.kill raising PermissionError (e.g. another user's process) must not abort hermes update — it's reported as a failure and we move on.""" def fake_kill(pid, sig): raise PermissionError("Operation not permitted") with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes() # must not raise out = capsys.readouterr().out assert "✗ failed to stop PID 12345" in out assert "Operation not permitted" in out def test_process_already_gone_counts_as_stopped(self, capsys): """ProcessLookupError on the initial SIGTERM means the process already exited between detection and the kill — treat as success.""" def fake_kill(pid, sig): raise ProcessLookupError with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes() out = capsys.readouterr().out assert "✓ stopped PID 12345" in out assert "failed to stop" not in out def test_update_path_restarts_managed_dashboard_instead_of_killing(self, capsys): """A systemd-managed dashboard must be restarted through systemd. Raw-killing the unit's main PID makes systemd record a clean stop, so Restart=on-failure does not recover the Cloudflare origin. """ calls: list[list[str]] = [] def fake_run(args, *a, **kw): calls.append(list(args)) if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]: return MagicMock(returncode=0, stdout="", stderr="") if args[:2] == ["systemctl", "list-unit-files"]: return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="") if args[:2] == ["systemctl", "is-active"]: return MagicMock(returncode=0, stdout="active\n", stderr="") if args[:2] == ["systemctl", "is-enabled"]: return MagicMock(returncode=0, stdout="enabled\n", stderr="") if args == ["systemctl", "restart", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="", stderr="") raise AssertionError(f"unexpected subprocess.run call: {args}") with patch("subprocess.run", side_effect=fake_run), \ patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]) as find_pids, \ patch("os.kill") as kill: _kill_stale_dashboard_processes(restart_managed=True) assert ["systemctl", "restart", "hermes-dashboard.service"] in calls find_pids.assert_not_called() kill.assert_not_called() out = capsys.readouterr().out assert "Restarting managed dashboard service" in out assert "✓ restarted hermes-dashboard.service" in out def test_user_scope_restart_never_falls_back_to_system_or_sudo(self, capsys): """A user unit is discovered and restarted through ``systemctl --user``.""" calls: list[list[str]] = [] def fake_run(args, *a, **kw): calls.append(list(args)) if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]: return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="") if args == ["systemctl", "--user", "is-active", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="active\n", stderr="") if args == ["systemctl", "--user", "is-enabled", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="enabled\n", stderr="") if args == ["systemctl", "--user", "restart", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="", stderr="") raise AssertionError(f"unexpected subprocess.run call: {args}") with patch("subprocess.run", side_effect=fake_run), \ patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]) as find_pids, \ patch("os.kill") as kill: _kill_stale_dashboard_processes(restart_managed=True) assert calls == [ ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"], ["systemctl", "--user", "is-active", "hermes-dashboard.service"], ["systemctl", "--user", "is-enabled", "hermes-dashboard.service"], ["systemctl", "--user", "restart", "hermes-dashboard.service"], ] assert all(call[:1] != ["sudo"] and call[:2] != ["systemctl"] for call in calls) find_pids.assert_not_called() kill.assert_not_called() assert "✓ restarted hermes-dashboard.service" in capsys.readouterr().out def test_user_scope_restart_failure_does_not_try_system_or_sudo(self): """A failed user-manager restart remains fail-closed and never raw-kills.""" calls: list[list[str]] = [] def fake_run(args, *a, **kw): calls.append(list(args)) if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]: return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="") if args[-2:] == ["is-active", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="active\n", stderr="") if args[-2:] == ["is-enabled", "hermes-dashboard.service"]: return MagicMock(returncode=0, stdout="enabled\n", stderr="") if args[-2:] == ["restart", "hermes-dashboard.service"]: return MagicMock(returncode=1, stdout="", stderr="user manager unavailable") raise AssertionError(f"unexpected subprocess.run call: {args}") with patch("subprocess.run", side_effect=fake_run), \ patch("hermes_cli.main._find_stale_dashboard_pids") as find_pids, \ patch("os.kill") as kill: _kill_stale_dashboard_processes(restart_managed=True) assert calls[-1] == ["systemctl", "--user", "restart", "hermes-dashboard.service"] assert not any(call[:1] == ["sudo"] or call == ["systemctl", "restart", "hermes-dashboard.service"] for call in calls) find_pids.assert_not_called() kill.assert_not_called() def test_managed_dashboard_restart_failure_does_not_raw_kill(self, capsys): """If systemd restart cannot run, print the fix and do not kill the PID.""" def fake_run(args, *a, **kw): if args == ["systemctl", "--user", "list-unit-files", "hermes-dashboard.service", "--no-legend", "--no-pager"]: return MagicMock(returncode=0, stdout="", stderr="") if args[:2] == ["systemctl", "list-unit-files"]: return MagicMock(returncode=0, stdout="hermes-dashboard.service enabled enabled\n", stderr="") if args[:2] == ["systemctl", "is-active"]: return MagicMock(returncode=0, stdout="active\n", stderr="") if args[:2] == ["systemctl", "is-enabled"]: return MagicMock(returncode=0, stdout="enabled\n", stderr="") if args == ["systemctl", "restart", "hermes-dashboard.service"]: return MagicMock(returncode=1, stdout="", stderr="Interactive authentication required.\n") if args == ["sudo", "-n", "systemctl", "restart", "hermes-dashboard.service"]: return MagicMock(returncode=1, stdout="", stderr="a password is required\n") raise AssertionError(f"unexpected subprocess.run call: {args}") with patch("subprocess.run", side_effect=fake_run), \ patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]) as find_pids, \ patch("os.kill") as kill: _kill_stale_dashboard_processes(restart_managed=True) find_pids.assert_not_called() kill.assert_not_called() out = capsys.readouterr().out assert "failed to restart hermes-dashboard.service" in out assert "not raw-killing its PID" in out assert "sudo systemctl restart hermes-dashboard.service" in out class TestKillStaleDashboardWindows: """Kill path on Windows: taskkill /F.""" def test_taskkill_invoked_for_each_pid(self, monkeypatch, capsys): monkeypatch.setattr(sys, "platform", "win32") def fake_run(args, *a, **kw): # taskkill returns 0 on success return MagicMock(returncode=0, stdout="", stderr="") with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345, 12346]), \ patch("subprocess.run", side_effect=fake_run) as mock_run: _kill_stale_dashboard_processes() # Each PID triggered a taskkill /PID /F invocation. taskkill_calls = [ c for c in mock_run.call_args_list if c.args and isinstance(c.args[0], list) and c.args[0][:1] == ["taskkill"] ] assert len(taskkill_calls) == 2 assert ["taskkill", "/PID", "12345", "/F"] in [c.args[0] for c in taskkill_calls] assert ["taskkill", "/PID", "12346", "/F"] in [c.args[0] for c in taskkill_calls] out = capsys.readouterr().out assert "✓ stopped PID 12345" in out assert "✓ stopped PID 12346" in out def test_taskkill_failure_is_reported(self, monkeypatch, capsys): monkeypatch.setattr(sys, "platform", "win32") def fake_run(args, *a, **kw): return MagicMock(returncode=128, stdout="", stderr="ERROR: Access is denied.") with patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[12345]), \ patch("subprocess.run", side_effect=fake_run): _kill_stale_dashboard_processes() # must not raise out = capsys.readouterr().out assert "✗ failed to stop PID 12345" in out assert "Access is denied" in out class TestBackCompatAlias: """``_warn_stale_dashboard_processes`` is kept as an alias for the new kill function so old imports don't break.""" def test_alias_is_the_kill_function(self): assert _warn_stale_dashboard_processes is _kill_stale_dashboard_processes class TestDashboardUpdateCleanup: """The git and Windows ZIP update paths share this final cleanup.""" def test_all_failed_stops_do_not_claim_the_dashboard_was_stopped(self, capsys): with patch( "hermes_cli.main._kill_stale_dashboard_processes", return_value={"matched": [12345], "killed": [], "failed": [(12345, "denied")], "unrecovered": []}, ): _finish_dashboard_update_cleanup([]) assert "stopped during update" not in capsys.readouterr().out def test_auto_restarted_stop_prints_no_notice(self, capsys): """A killed process that was auto-restarted (systemd unit or argv respawn) must not trigger the manual-relaunch warning.""" with patch( "hermes_cli.main._kill_stale_dashboard_processes", return_value={"matched": [12345], "killed": [12345], "failed": [], "unrecovered": []}, ): _finish_dashboard_update_cleanup([]) assert "stopped during update" not in capsys.readouterr().out def test_unrecovered_stop_prints_shared_update_notice(self, capsys): with patch( "hermes_cli.main._kill_stale_dashboard_processes", return_value={"matched": [12345], "killed": [12345], "failed": [], "unrecovered": [12345]}, ): _finish_dashboard_update_cleanup([]) out = capsys.readouterr().out assert "stopped during update and could not be auto-restarted" in out assert "hermes dashboard --port " in out class TestWindowsWmicEncoding: """Regression tests for #17049 — the Windows wmic branch must not crash `hermes update` on non-UTF-8 system locales (e.g. cp936 on zh-CN). """ def test_wmic_invoked_with_utf8_ignore_errors(self, monkeypatch): """The wmic subprocess.run call must pass encoding='utf-8' and errors='ignore' so the subprocess reader thread cannot raise UnicodeDecodeError on non-UTF-8 wmic output.""" monkeypatch.setattr(sys, "platform", "win32") with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=( "CommandLine=python -m hermes_cli.main dashboard\n" "ProcessId=12345\n" ), stderr="", ) _find_stale_dashboard_pids() # The wmic call is the first subprocess.run invocation. assert mock_run.called, "subprocess.run was not invoked" wmic_call = mock_run.call_args_list[0] kwargs = wmic_call.kwargs assert kwargs.get("encoding") == "utf-8", ( "encoding kwarg must be 'utf-8' so wmic output is decoded " "deterministically rather than via the implicit reader-thread " "default that crashes on non-UTF-8 locales (#17049)." ) assert kwargs.get("errors") == "ignore", ( "errors kwarg must be 'ignore' so undecodable bytes don't take " "down the reader thread (#17049)." ) def test_wmic_returns_none_stdout_does_not_crash(self, monkeypatch): """If subprocess.run returns successfully but stdout is None — which is what Python 3.11 leaves behind when the reader thread silently crashed on UnicodeDecodeError before this fix landed — detection must short-circuit instead of raising AttributeError on ``None.split('\\n')`` and aborting `hermes update` (#17049).""" monkeypatch.setattr(sys, "platform", "win32") with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=None, stderr="" ) # Must not raise. assert _find_stale_dashboard_pids() == [] class TestSupervisedBackendRestart: """After the kill, systemd-supervised PIDs get their owning unit restarted (#68934) — SIGTERM reads as a clean stop to systemd, so Restart=on-failure never fires on its own.""" def _live(self): return sys.modules["hermes_cli.main"] def test_supervised_pid_restarts_owning_unit(self, capsys): """A killed PID whose cgroup names a custom unit → systemctl restart.""" live = self._live() def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[4321]), \ patch.object(live, "_get_pid_cgroup_path", return_value="/system.slice/hermes-serve.service"), \ patch.object(live, "_get_systemd_service_for_pid", return_value="hermes-serve.service"), \ patch.object(live, "_try_restart_systemd_service", return_value=True) as restart, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) restart.assert_called_once_with( "hermes-serve.service", "/system.slice/hermes-serve.service" ) out = capsys.readouterr().out assert "✓ restarted systemd service hermes-serve.service" in out # Supervised restart succeeded — no manual hint. assert "when you're ready" not in out def test_supervised_restart_failure_prints_hint(self, capsys): live = self._live() def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[4321]), \ patch.object(live, "_get_pid_cgroup_path", return_value="/system.slice/hermes-serve.service"), \ patch.object(live, "_get_systemd_service_for_pid", return_value="hermes-serve.service"), \ patch.object(live, "_try_restart_systemd_service", return_value=False), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) out = capsys.readouterr().out assert "⚠ hermes-serve.service" in out assert "Restart anything not auto-restarted" in out def test_same_unit_restarted_once_for_multiple_pids(self, capsys): """Two killed PIDs in the same unit → one systemctl restart.""" live = self._live() def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[111, 222]), \ patch.object(live, "_get_pid_cgroup_path", return_value="/system.slice/hermes-serve.service"), \ patch.object(live, "_get_systemd_service_for_pid", return_value="hermes-serve.service"), \ patch.object(live, "_try_restart_systemd_service", return_value=True) as restart, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) assert restart.call_count == 1 def test_stop_path_never_restarts(self, capsys): """`hermes dashboard --stop` (restart_managed=False) must stay a stop: no cgroup snapshot, no unit restart, no respawn.""" live = self._live() def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_find_stale_dashboard_pids", return_value=[4321]), \ patch.object(live, "_get_pid_cgroup_path") as cg, \ patch.object(live, "_try_restart_systemd_service") as restart, \ patch.object(live, "_respawn_dashboard_processes") as respawn, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(reason="requested via --stop") cg.assert_not_called() restart.assert_not_called() respawn.assert_not_called() out = capsys.readouterr().out assert "Restart the dashboard when you're ready" in out class TestManualBackendRespawn: """Manually-started dashboards/serves have their argv captured before the kill and are respawned detached after the update (#40449).""" def _live(self): return sys.modules["hermes_cli.main"] def test_manual_pid_respawned_with_captured_argv(self, capsys): live = self._live() argv = ["/usr/bin/python3", "-m", "hermes_cli.main", "serve", "--port", "8300"] def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[5555]), \ patch.object(live, "_get_pid_cgroup_path", return_value="/user.slice/user-1000.slice/session-3.scope"), \ patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ patch.object(live, "_dashboard_cmdline_for_pid", return_value=argv) as capture, \ patch.object(live, "_respawn_dashboard_processes", return_value=[]) as respawn, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) capture.assert_called_once_with(5555) respawn.assert_called_once_with([argv]) out = capsys.readouterr().out assert "when you're ready" not in out def test_argv_capture_failure_falls_back_to_hint(self, capsys): live = self._live() def fake_kill(pid, sig): if sig == 0: raise ProcessLookupError with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[5555]), \ patch.object(live, "_get_pid_cgroup_path", return_value=None), \ patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ patch.object(live, "_dashboard_cmdline_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes") as respawn, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) respawn.assert_not_called() out = capsys.readouterr().out assert "Restart anything not auto-restarted" in out def test_respawn_adds_no_open_to_dashboard_commands(self, tmp_path, monkeypatch): """Respawned `dashboard` argv gains --no-open; `serve` argv untouched.""" live = self._live() monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) spawned: list[list[str]] = [] class _FakePopen: def __init__(self, cmd, **kwargs): spawned.append(list(cmd)) with patch.object(live.subprocess, "Popen", _FakePopen): failed = live._respawn_dashboard_processes([ ["hermes", "dashboard", "--port", "8300"], ["hermes", "serve", "--host", "0.0.0.0"], ]) assert failed == [] assert spawned[0] == ["hermes", "dashboard", "--port", "8300", "--no-open"] assert spawned[1] == ["hermes", "serve", "--host", "0.0.0.0"] def test_respawn_failure_returned(self, tmp_path, monkeypatch, capsys): live = self._live() monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) with patch.object(live.subprocess, "Popen", side_effect=OSError("no such file")): failed = live._respawn_dashboard_processes([["hermes", "serve"]]) assert failed == [["hermes", "serve"]] out = capsys.readouterr().out assert "✗ failed to restart" in out class TestCmdlineCapture: """_dashboard_cmdline_for_pid reads /proc on Linux, ps on macOS.""" def _live(self): return sys.modules["hermes_cli.main"] def test_reads_proc_cmdline_when_available(self, tmp_path, monkeypatch): live = self._live() proc_file = tmp_path / "cmdline" proc_file.write_bytes(b"/usr/bin/python3\x00-m\x00hermes_cli.main\x00serve\x00") real_exists = os.path.exists def fake_exists(path): if path == "/proc/777/cmdline": return True return real_exists(path) real_open = open def fake_open(path, *a, **kw): if path == "/proc/777/cmdline": return real_open(proc_file, *a, **kw) return real_open(path, *a, **kw) with patch.object(live.os.path, "exists", fake_exists), \ patch("builtins.open", fake_open): argv = live._dashboard_cmdline_for_pid(777) assert argv == ["/usr/bin/python3", "-m", "hermes_cli.main", "serve"] def test_falls_back_to_ps_without_proc(self, monkeypatch): live = self._live() def fake_run(args, *a, **kw): assert args == ["ps", "-p", "888", "-o", "command="] return MagicMock(returncode=0, stdout="hermes serve --port 8300\n", stderr="") with patch.object(live.os.path, "exists", return_value=False), \ patch("subprocess.run", side_effect=fake_run): argv = live._dashboard_cmdline_for_pid(888) assert argv == ["hermes", "serve", "--port", "8300"] def test_returns_none_on_windows(self, monkeypatch): live = self._live() monkeypatch.setattr(live.sys, "platform", "win32") assert live._dashboard_cmdline_for_pid(123) is None