diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index 0b5804dfcf..72344603b7 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -108,6 +108,23 @@ def _scan_dashboard_processes(*, exclude_pids: set[int] | None = None) -> list[t return found +def _ledger_serve_binds() -> dict[int, tuple[str, int]]: + """``pid -> (host, port)`` recorded in the spawn ledger for live serve/dashboard backends. + + The entry is written after the bind, so it carries the real port where argv only says + ``--port 0`` (Desktop SSH backends ask the OS for a port). Empty when the ledger is unavailable. + """ + binds: dict[int, tuple[str, int]] = {} + with contextlib.suppress(Exception): + from hermes_cli.process_identity import ledger_entries + for entry in ledger_entries(): + pid, port = entry.get("pid"), entry.get("port") + if (entry.get("purpose") in ("serve", "dashboard") and isinstance(pid, int) + and isinstance(port, int) and port > 0): + binds[pid] = (str(entry.get("host") or ""), port) + return binds + + def _pid_environ(pid: int) -> dict[str, str] | None: """Exec-time environment of *pid* (psutil, then /proc); ``None`` when unreadable.""" with contextlib.suppress(Exception): diff --git a/hermes_cli/main_dashboard.py b/hermes_cli/main_dashboard.py index 542b23a721..c3067c721d 100644 --- a/hermes_cli/main_dashboard.py +++ b/hermes_cli/main_dashboard.py @@ -503,16 +503,21 @@ def _report_dashboard_status() -> int: ``--status`` let an operator kill what they couldn't see. Ledger-registered serves (profiled launches the argv scan can't match) surface via the spawn-ledger - augmentation in _scan_dashboard_processes. See #81564. + augmentation in _scan_dashboard_processes, and the ledger's recorded bind replaces the argv port so + ``--port 0`` backends are probed on the port the OS actually gave them. See #81564. """ - from hermes_cli.dashboard_procs import _scan_dashboard_processes + from hermes_cli.dashboard_procs import _ledger_serve_binds, _scan_dashboard_processes from gateway.status import _pid_exists + binds = _ledger_serve_binds() live: list[tuple[int, str, str]] = [] for pid, command in _scan_dashboard_processes(): runtime = _parse_dashboard_runtime(command) if runtime is None: continue mode, host, port = runtime + if pid in binds: + ledger_host, port = binds[pid] + host = ledger_host or host if port <= 0 or not _pid_exists(pid) or not _dashboard_listening(host, port): continue live.append((pid, command, mode)) diff --git a/tests/hermes_cli/test_dashboard_lifecycle_flags.py b/tests/hermes_cli/test_dashboard_lifecycle_flags.py index 8f77cb91ad..99af7ca397 100644 --- a/tests/hermes_cli/test_dashboard_lifecycle_flags.py +++ b/tests/hermes_cli/test_dashboard_lifecycle_flags.py @@ -56,6 +56,31 @@ class TestDashboardStatus: assert "PID 12346" in out assert "PID 12347" in out and "[serve]" in out + def test_status_lists_os_assigned_port_serve_that_stop_targets(self, capsys, monkeypatch): + """A ``--port 0`` serve (Desktop SSH backend) is listed on the port the ledger recorded, + so ``--status`` shows every backend ``--stop`` would kill (#81564).""" + import socket + + from hermes_cli import dashboard_procs, process_identity + from hermes_cli.main_dashboard import _find_stale_dashboard_pids + + pid = 424242 + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + real_port = listener.getsockname()[1] + monkeypatch.setattr(dashboard_procs, "_iter_process_table", lambda: [ + (pid, "/usr/local/bin/hermes serve --host 127.0.0.1 --port 0 --ssh-isolated")]) + monkeypatch.setattr(process_identity, "ledger_entries", lambda: [ + {"pid": pid, "purpose": "serve", "host": "127.0.0.1", "port": real_port}]) + monkeypatch.setattr("gateway.status._pid_exists", lambda p: p == pid) + + assert _find_stale_dashboard_pids() == [pid] + with pytest.raises(SystemExit) as exc: + cmd_dashboard(_ns(status=True)) + assert exc.value.code == 0 + assert f"PID {pid} [serve]" in capsys.readouterr().out + def test_status_does_not_try_to_import_fastapi(self): """`--status` must not require dashboard runtime deps — it's a process-table scan only. We prove this by making fastapi import