fix(cli): exclude desktop-managed backend from stale-dashboard kill

Fixes #37532
This commit is contained in:
liuhao1024 2026-06-03 01:46:06 +08:00 committed by Teknium
parent d833b1eff7
commit 192020992d
2 changed files with 69 additions and 2 deletions

View File

@ -7363,7 +7363,10 @@ def cmd_gui(args: argparse.Namespace):
sys.exit(launch_result.returncode)
def _find_stale_dashboard_pids() -> list[int]:
def _find_stale_dashboard_pids(
*,
exclude_pids: set[int] | None = None,
) -> list[int]:
"""Return PIDs of ``hermes dashboard`` processes other than ourselves.
``hermes dashboard`` is a long-lived server process commonly started and
@ -7378,6 +7381,15 @@ def _find_stale_dashboard_pids() -> list[int]:
it. This helper is just the detection step; see
``_kill_stale_dashboard_processes`` for the kill.
*exclude_pids* is an optional set of PIDs that must never be returned.
This is used by the Hermes Desktop Electron app to protect its own
backend child process: when the desktop spawns ``hermes dashboard`` as
a backend and triggers an auto-update, the update must not kill the
dashboard that the desktop itself manages. The desktop sets the
environment variable ``HERMES_DESKTOP_CHILD_PID`` on the spawned
backend process; ``_kill_stale_dashboard_processes`` reads it and
passes it here. (#37532)
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
"""
patterns = [
@ -7453,6 +7465,8 @@ def _find_stale_dashboard_pids() -> list[int]:
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return []
if exclude_pids:
dashboard_pids = [p for p in dashboard_pids if p not in exclude_pids]
return dashboard_pids
@ -7604,7 +7618,18 @@ def _kill_stale_dashboard_processes(
launch args (--host, --port, --insecure, --tui, --no-open). The user
restarts it manually; a hint is printed.
"""
pids = _find_stale_dashboard_pids()
# When the Hermes Desktop Electron app spawns this dashboard as a
# backend child, it sets HERMES_DESKTOP_CHILD_PID so that the update
# path can skip killing the desktop-managed process. (#37532)
exclude: set[int] | None = None
raw_pid = os.environ.get("HERMES_DESKTOP_CHILD_PID")
if raw_pid:
try:
exclude = {int(raw_pid)}
except (ValueError, TypeError):
pass
pids = _find_stale_dashboard_pids(exclude_pids=exclude)
if not pids:
return

View File

@ -185,6 +185,48 @@ class TestFindStaleDashboardPids:
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: