From 1676c614b3e4e2cf8d6492cb0ed0a7fe21148794 Mon Sep 17 00:00:00 2001 From: fangliquan Date: Sat, 22 Aug 2026 10:41:53 +0800 Subject: [PATCH] fix(update): preserve SSH-owned backends during cleanup --- hermes_cli/dashboard_procs.py | 15 +++++--- .../hermes_cli/test_update_stale_dashboard.py | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index 1972898abd..3434b152db 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -360,24 +360,27 @@ def _kill_stale_dashboard_processes( # 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 + exclude: set[int] = set() raw_pid = os.environ.get("HERMES_DESKTOP_CHILD_PID") if raw_pid: # The desktop may manage several backends (one per active profile) and # passes them comma-separated; a lone int still parses for back-compat. - parsed: set[int] = set() for part in raw_pid.split(","): part = part.strip() if not part: continue try: - parsed.add(int(part)) + exclude.add(int(part)) except (ValueError, TypeError): pass - if parsed: - exclude = parsed - pids = _m()._find_stale_dashboard_pids(exclude_pids=exclude) + # An SSH-owned backend belongs to an attached Desktop client even when the + # updater runs from an unrelated remote shell with no Desktop child PID. + # Honor the same validated ownership records as the orphan reaper; killing + # one permanently strands that client's fixed SSH port-forward. + exclude |= _lock_owned_serve_pids() + + pids = _m()._find_stale_dashboard_pids(exclude_pids=exclude or None) if not pids: return {"matched": [], "killed": [], "failed": []} diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index 1c8bb0f9d8..85d8dff574 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -14,6 +14,7 @@ History: from __future__ import annotations import importlib +import json import os import subprocess import sys @@ -87,6 +88,43 @@ def _ps_runner(stdout: str): return _side_effect +def test_update_cleanup_spares_backend_owned_by_valid_ssh_lock(tmp_path, monkeypatch): + 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) + + 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": []} + + class TestFindStaleDashboardPids: """Unit tests for the ps/wmic-based detection step."""