Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,942 @@
|
||||
"""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 <n> /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
|
||||
Reference in New Issue
Block a user