"""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 json import os import subprocess 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 def _write_valid_ssh_backend_lock(tmp_path, monkeypatch) -> int: pid = 4242 ownership_id = "a" * 32 spawn_nonce = "b" * 16 lock_dir = tmp_path / "desktop-ssh" / ownership_id lock_dir.mkdir(parents=True) (lock_dir / "backend.lock.json").write_text(json.dumps({ "schemaVersion": 2, "protocolVersion": 1, "ownershipId": ownership_id, "spawnNonce": spawn_nonce, "tokenFingerprint": "c" * 32, "pid": pid, "port": 46369, "profile": "default", "hermesPath": "/opt/hermes/bin/hermes", "hermesHome": str(tmp_path), "logPath": f"{tmp_path}/desktop-ssh/{ownership_id}/{spawn_nonce}.log", "startedAt": "2026-08-21T15:27:39Z", })) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("HERMES_DESKTOP_CHILD_PID", raising=False) return pid def test_update_cleanup_spares_backend_owned_by_valid_ssh_lock(tmp_path, monkeypatch): pid = _write_valid_ssh_backend_lock(tmp_path, monkeypatch) def assert_owned_pid_is_excluded(*, exclude_pids=None): assert exclude_pids is not None assert pid in exclude_pids return [] with patch( "hermes_cli.main._find_stale_dashboard_pids", side_effect=assert_owned_pid_is_excluded, ): result = _kill_stale_dashboard_processes(restart_managed=True) assert result == {"matched": [], "killed": [], "failed": []} def test_explicit_stop_does_not_spare_backend_owned_by_valid_ssh_lock( tmp_path, monkeypatch, ): pid = _write_valid_ssh_backend_lock(tmp_path, monkeypatch) def assert_owned_pid_is_not_excluded(*, exclude_pids=None): assert exclude_pids is None or pid not in exclude_pids return [] with patch( "hermes_cli.main._find_stale_dashboard_pids", side_effect=assert_owned_pid_is_not_excluded, ): result = _kill_stale_dashboard_processes(restart_managed=False) assert result == {"matched": [], "killed": [], "failed": []} class TestFindStaleDashboardPids: """Unit tests for the ps/wmic-based detection step.""" @pytest.mark.skipif(sys.platform == "win32", reason="ps-based scan path") 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 _assert_ps_timeout_returns_empty(self): import subprocess as sp with patch("subprocess.run", side_effect=sp.TimeoutExpired("ps", 10)): assert _find_stale_dashboard_pids() == [] @pytest.mark.linux_only def test_ps_timeout_returns_empty_linux(self): self._assert_ps_timeout_returns_empty() @pytest.mark.macos_only def test_ps_timeout_returns_empty_macos(self): self._assert_ps_timeout_returns_empty() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX kill semantics") class TestKillStaleDashboardPosix: """Kill path on Linux / macOS: SIGTERM then SIGKILL any survivors.""" 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_user_scope_restart_never_falls_back_to_system_or_sudo(self, capsys): """A user unit is discovered and restarted through ``systemctl --user``. Since #92145 the managed restart no longer ends the pass — the scan for OTHER stale serve/dashboard backends continues (with the restarted unit recorded in ``already_restarted_units``), so the scan being reached is now part of the contract rather than a violation of it. The invariant this test pins is unchanged: nothing here may touch the system scope or sudo. """ 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=[]) 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) # The pass keeps scanning for serve backends the dashboard unit does # not own (#92145) — but with nothing stale, nothing is killed. find_pids.assert_called_once() kill.assert_not_called() assert "✓ restarted hermes-dashboard.service" in capsys.readouterr().out class TestKillStaleDashboardWindows: """Kill path on Windows: taskkill /F.""" @pytest.mark.windows_only def test_taskkill_invoked_for_each_pid(self, capsys): """``windows_only``: ``taskkill.exe`` only exists on Windows, and the faked platform also silently skipped the POSIX-only cgroup/argv snapshot the real Windows path must not take. """ 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("gateway.status.get_process_start_time", return_value=123), \ patch("hermes_cli._subprocess_compat.pid_is_hermes", return_value=True), \ 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 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 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_routed_through_bounded_probe_run_with_ignore_errors(self): """The wmic scan must go through ``bounded_probe_run`` — which owns the deterministic UTF-8 decode (#17049) and the deadlock-safe post-timeout cleanup (#87134) — with errors='ignore' so undecodable bytes from a non-UTF-8 system code page (e.g. cp936 on zh-CN) don't take down the reader thread, and with a finite timeout. Cross-platform: nothing Windows-native executes once the probe is mocked, so ``sys.platform`` is patched rather than gating the test to the Windows-only CI job. """ with patch("sys.platform", "win32"), \ patch("hermes_cli._subprocess_compat.bounded_probe_run") as mock_probe: mock_probe.return_value = subprocess.CompletedProcess( args=["wmic"], returncode=0, stdout=( "CommandLine=python -m hermes_cli.main dashboard\n" "ProcessId=12345\n" ), stderr="", ) pids = _find_stale_dashboard_pids() assert mock_probe.called, "bounded_probe_run was not invoked" wmic_call = mock_probe.call_args_list[0] assert wmic_call.args[0][0] == "wmic" kwargs = wmic_call.kwargs assert kwargs.get("errors") == "ignore", ( "errors kwarg must be 'ignore' so undecodable bytes don't take " "down the reader thread (#17049)." ) assert kwargs.get("timeout"), ( "the scan must carry a finite timeout — bounded_probe_run " "guarantees the post-timeout cleanup is bounded too (#87134)." ) assert pids == [12345] def test_probe_failure_fails_open_to_empty_list(self): """A spawn failure or timeout (bounded_probe_run → None) must yield an empty scan, not an AttributeError on result.stdout (#87134).""" with patch("sys.platform", "win32"), \ patch("hermes_cli._subprocess_compat.bounded_probe_run", return_value=None): assert _find_stale_dashboard_pids() == [] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX kill + systemd restart") 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_already_restarted_unit_is_left_untouched(self): """Review on #83595: hermes update's systemd fleet-restart loop may already have restarted this PID's owning unit directly (e.g. a Serve-only install). Passing it via already_restarted_units must skip killing/restarting it again here.""" live = self._live() 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") as restart, \ patch("os.kill") as kill, \ patch("time.sleep"): result = _kill_stale_dashboard_processes( restart_managed=True, already_restarted_units={"hermes-serve"} ) kill.assert_not_called() restart.assert_not_called() assert result == {"matched": [], "killed": [], "failed": []} 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"] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cmdline capture + respawn") 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 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cmdline capture + respawn") def test_non_orphan_fixed_port_still_respawns(self, capsys): """A supervised-by-shell dashboard with a fixed port is still restarted.""" live = self._live() argv = ["hermes", "dashboard", "--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=[6001]), \ 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=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ 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) respawn.assert_called_once_with([argv]) assert "when you're ready" not in capsys.readouterr().out @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cmdline capture + respawn") def test_port_zero_serves_killed_without_respawn(self, capsys): """``serve --port 0`` backends are stopped but not resurrected (#78821).""" live = self._live() argv = [ "python", "-m", "hermes_cli.main", "serve", "--host", "127.0.0.1", "--port", "0", ] 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=[7001, 7002, 7003]), \ 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=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes") as respawn, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): result = _kill_stale_dashboard_processes(restart_managed=True) respawn.assert_not_called() assert sorted(result["killed"]) == [7001, 7002, 7003] # Intentional skips are not "unrecovered" — no noisy manual hint. assert result["unrecovered"] == [] assert "when you're ready" not in capsys.readouterr().out @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cmdline capture + respawn") def test_detached_fixed_port_still_respawns_after_prior_update(self, capsys): """PPID-1 fixed-port backends (prior start_new_session respawn) stay eligible.""" live = self._live() argv = ["hermes", "dashboard", "--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=[8001]), \ 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=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ 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) respawn.assert_called_once_with([argv]) assert "when you're ready" not in capsys.readouterr().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 TestFilterDashboardRespawnCandidates: """Unit tests for respawn filtering / dedupe / orphan skip (#78821).""" def test_skips_serve_port_zero(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = [ "python", "-m", "hermes_cli.main", "--profile", "mini-cat", "serve", "--host", "127.0.0.1", "--port", "0", ] assert _filter_dashboard_respawn_candidates([ (42, argv, "/home/u/.hermes/profiles/mini-cat"), ]) == [] def test_skips_legacy_dashboard_port_zero(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = [ "hermes", "--profile", "coder", "dashboard", "--no-open", "--host", "127.0.0.1", "--port", "0", ] assert _filter_dashboard_respawn_candidates([(7, argv, None)]) == [] def test_skips_serve_port_equals_zero(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = ["hermes", "serve", "--port=0"] assert _filter_dashboard_respawn_candidates([(1, argv, None)]) == [] def test_keeps_ppid1_fixed_port_for_repeat_update(self): """Detached prior-update respawns (PPID 1) must remain restartable (#40449).""" from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = ["hermes", "dashboard", "--port", "9119"] assert _filter_dashboard_respawn_candidates([(10, argv, None)]) == [argv] def test_dedupes_identical_normalized_cmdlines(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates a = ["/usr/bin/python3", "-m", "hermes_cli.main", "dashboard", "--port", "8300"] b = ["/other/python", "-m", "hermes_cli.main", "dashboard", "--port", "8300"] out = _filter_dashboard_respawn_candidates([ (1, a, None), (2, b, None), ]) assert out == [a] def test_caps_one_per_profile(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates a = ["hermes", "--profile", "coder", "dashboard", "--port", "8300"] b = ["hermes", "--profile", "coder", "dashboard", "--port", "8301"] c = ["hermes", "--profile", "writer", "dashboard", "--port", "8302"] out = _filter_dashboard_respawn_candidates([ (1, a, None), (2, b, None), (3, c, None), ]) assert out == [a, c] def test_caps_one_per_hermes_home(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates home = "/tmp/hermes-home-a" a = ["hermes", "dashboard", "--port", "8300"] b = ["hermes", "dashboard", "--port", "8301"] out = _filter_dashboard_respawn_candidates( [ (1, a, home), (2, b, home), ], own_home=home, ) assert out == [a] def test_profile_flag_and_profiles_home_share_cap(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates a = ["hermes", "--profile", "coder", "dashboard", "--port", "8300"] b = ["hermes", "dashboard", "--port", "8301"] out = _filter_dashboard_respawn_candidates([ (1, a, None), (2, b, "/home/u/.hermes/profiles/coder"), ]) assert out == [a] def test_default_profile_same_root_home_caps(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates a = ["hermes", "--profile", "default", "dashboard", "--port", "8300"] b = ["hermes", "dashboard", "--port", "8301"] home = "/home/u/.hermes" out = _filter_dashboard_respawn_candidates( [ (1, a, home), (2, b, home), ], own_home=home, ) assert out == [a] def test_foreign_home_backend_is_not_replayed(self): """A backend from another HERMES_HOME is never respawned (#94030). Its supervisor/user owns its lifecycle; an argv-only replay would run on the updating install's home and steal the foreign install's fixed port. Supersedes the old "distinct homes don't share a cap" pin — a foreign home is no longer replayed at all. """ from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates a = ["hermes", "dashboard", "--port", "8300"] b = ["hermes", "dashboard", "--port", "8301"] out = _filter_dashboard_respawn_candidates( [ (1, a, "/home/u/.hermes"), (2, b, "/work/project/.hermes"), ], own_home="/home/u/.hermes", ) assert out == [a] def test_skips_sidecar_fixed_port_serve_on_foreign_home(self): """The reported case: launchd-supervised sidecar serve, fixed port.""" from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = [ "/Users/u/.hermes-sidecar/hermes-agent/venv/bin/python", "-m", "hermes_cli.main", "serve", "--host", "127.0.0.1", "--port", "9118", "--skip-build", ] out = _filter_dashboard_respawn_candidates( [(15364, argv, "/Users/u/.hermes-lifeos")], own_home="/Users/u/.hermes", ) assert out == [] def test_matching_hermes_home_is_kept(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = ["hermes", "serve", "--host", "127.0.0.1", "--port", "9118"] out = _filter_dashboard_respawn_candidates( [(15364, argv, "/Users/u/.hermes")], own_home="/Users/u/.hermes", ) assert out == [argv] def test_symlinked_hermes_home_compares_equal(self, tmp_path): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates real = tmp_path / "real-home" real.mkdir() link = tmp_path / "linked-home" try: link.symlink_to(real, target_is_directory=True) except (OSError, NotImplementedError): pytest.skip("symlinks unavailable on this platform") argv = ["hermes", "serve", "--port", "9118"] out = _filter_dashboard_respawn_candidates( [(15364, argv, str(real))], own_home=str(link), ) assert out == [argv] def test_unknown_home_stays_eligible(self): """Unreadable HERMES_HOME (env probe failed) keeps pre-#94030 behaviour.""" from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = ["hermes", "dashboard", "--port", "8300"] out = _filter_dashboard_respawn_candidates( [(1, argv, None)], own_home="/home/u/.hermes", ) assert out == [argv] def test_own_home_defaults_to_get_hermes_home(self, monkeypatch): from pathlib import Path import hermes_constants from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates monkeypatch.setattr( hermes_constants, "get_hermes_home", lambda: Path("/home/u/.hermes") ) argv = ["hermes", "serve", "--port", "9118"] foreign = _filter_dashboard_respawn_candidates([ (1, argv, "/Users/u/.hermes-lifeos"), ]) assert foreign == [] own = _filter_dashboard_respawn_candidates([ (1, argv, "/home/u/.hermes"), ]) assert own == [argv] def test_keeps_fixed_port_serve(self): from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = ["hermes", "serve", "--host", "0.0.0.0", "--port", "9119"] assert _filter_dashboard_respawn_candidates([ (9, argv, None), ]) == [argv] def test_seventeen_port_zero_orphans_collapse_to_zero(self): """The reported accumulation case: many identical serve --port 0 → none.""" from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates argv = [ "python", "-m", "hermes_cli.main", "serve", "--host", "127.0.0.1", "--port", "0", ] candidates = [(i, argv, None) for i in range(17)] assert _filter_dashboard_respawn_candidates(candidates) == [] class TestCmdlineCapture: """_dashboard_cmdline_for_pid reads /proc on Linux, ps on macOS.""" def _live(self): return sys.modules["hermes_cli.main"] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX /proc cmdline path") 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"] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ps cmdline fallback") 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"] @pytest.mark.windows_only def test_returns_none_on_windows(self): """``windows_only``: the contract is "no graceful-argv capture on a real Windows host" — asserting it against a faked platform only restated the branch condition. """ live = self._live() assert live._dashboard_cmdline_for_pid(123) is None class TestPostUpdateStaleModuleReload: """Regression tests for the post-update stale-module ImportError. ``hermes update`` runs in the PRE-pull Python process. When the update adds a new symbol to ``hermes_cli._subprocess_compat`` (as #87134 added ``bounded_probe_run``), the post-update dashboard cleanup's lazy ``from hermes_cli._subprocess_compat import bounded_probe_run`` hits the stale cached module and crashes with ImportError — after the code update itself already succeeded. The cleanup entry point must force-reload the process-scan modules first (PR #87757 + ZIP-path widening). """ def test_cleanup_reloads_before_scanning(self): """_finish_dashboard_update_cleanup must reload the process-scan modules BEFORE calling _kill_stale_dashboard_processes, on every call path (git update and ZIP fallback both route here).""" from hermes_cli import update_cmd order: list[str] = [] with patch.object( update_cmd, "_reload_process_scan_modules", side_effect=lambda: order.append("reload"), ), patch( "hermes_cli.main._kill_stale_dashboard_processes", side_effect=lambda **kw: order.append("kill") or {"unrecovered": []}, ): update_cmd._finish_dashboard_update_cleanup([]) assert order == ["reload", "kill"] def test_node_failures_skip_reload_and_kill(self): """A failed Node refresh leaves the running dashboard untouched — no reload, no kill (existing safety rule preserved).""" from hermes_cli import update_cmd with patch.object(update_cmd, "_reload_process_scan_modules") as mock_reload, \ patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill: update_cmd._finish_dashboard_update_cleanup(["dashboard"]) mock_reload.assert_not_called() mock_kill.assert_not_called() def test_reload_restores_missing_symbol(self): """Simulate the stale-module state: strip ``bounded_probe_run`` off the cached module object (what an old pre-#87134 module looks like) and verify the reload restores it from disk — the exact state the Windows update crash came from.""" import hermes_cli._subprocess_compat as compat from hermes_cli import update_cmd assert hasattr(compat, "bounded_probe_run") try: delattr(compat, "bounded_probe_run") assert not hasattr(compat, "bounded_probe_run") update_cmd._reload_process_scan_modules() stale = sys.modules["hermes_cli._subprocess_compat"] assert hasattr(stale, "bounded_probe_run") finally: importlib.reload(sys.modules["hermes_cli._subprocess_compat"]) importlib.reload(sys.modules["hermes_cli.dashboard_procs"]) def test_reload_failure_is_nonfatal(self): """A reload failure must log and continue, never raise — the cleanup step runs after the update already succeeded.""" from hermes_cli import update_cmd with patch("importlib.reload", side_effect=RuntimeError("boom")): update_cmd._reload_process_scan_modules() # must not raise def test_config_reload_list_includes_process_scan_modules(self): """PR #87757's half: the git-path pre-cleanup reload also refreshes the process-scan modules (belt to the entry-point suspenders).""" from hermes_cli import update_cmd reloaded: list[str] = [] with patch("importlib.reload", side_effect=lambda m: reloaded.append(m.__name__)): update_cmd._reload_config_modules() assert "hermes_cli._subprocess_compat" in reloaded assert "hermes_cli.dashboard_procs" in reloaded