fix(update): skip Windows gateway cold-start when Desktop owns lifecycle
Vestigial autostart is not proof the user wants a standalone gateway run. When Desktop currently supervises this install's control plane, the updater must not spawn a competing messaging daemon. Serve is not treated as gateway-equivalent.
This commit is contained in:
@@ -4893,6 +4893,7 @@ _LAZY_COMMAND_EXPORTS = {
|
||||
"_dependency_sync_would_rewrite",
|
||||
"_detect_self_loaded_native_modules",
|
||||
"_detect_venv_python_processes",
|
||||
"_desktop_owns_gateway_lifecycle",
|
||||
"_defer_update_for_self_lock",
|
||||
"_discard_lockfile_churn",
|
||||
"_discard_stashed_changes",
|
||||
|
||||
@@ -4674,6 +4674,71 @@ def _stop_process_trees(pids: list[int]) -> None:
|
||||
logger.debug("Could not stop process tree %s: %s", pid, exc)
|
||||
|
||||
|
||||
def _looks_like_desktop_control_plane(cmdline: str) -> bool:
|
||||
"""True for this-install ``hermes serve`` / ``hermes dashboard`` argv.
|
||||
|
||||
That is the Desktop control plane, not the messaging gateway. Serve and
|
||||
dashboard do not host platform adapters (#92091); do not feed this into
|
||||
``looks_like_gateway_command_line``.
|
||||
"""
|
||||
low = (cmdline or "").lower()
|
||||
return "hermes_cli.main" in low and (" serve" in low or " dashboard" in low)
|
||||
|
||||
|
||||
def _desktop_owns_gateway_lifecycle() -> bool:
|
||||
"""True when Desktop currently supervises this install's control plane.
|
||||
|
||||
The updater must not steal gateway start in that case: Desktop owns
|
||||
start/stop via ``/api/gateway/*``. This is *not* proof messaging is
|
||||
already served — a live serve process is the control plane, and the
|
||||
gateway is a detached sibling (#76129 / #92091).
|
||||
|
||||
Prefer the spawn ledger (owned identity). Fall back to the install-scoped
|
||||
venv-holder scan already used by the lock guard; an orphaned control-plane
|
||||
process (supervisor gone) does not count.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.process_identity import ledger_entries, spawner_is_dead
|
||||
|
||||
for entry in ledger_entries():
|
||||
if entry.get("purpose") not in ("serve", "dashboard"):
|
||||
continue
|
||||
if spawner_is_dead(entry) is False:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Desktop-lifecycle ledger probe failed: %s", exc)
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
psutil = None
|
||||
|
||||
try:
|
||||
holders = _m()._detect_venv_python_processes()
|
||||
except Exception as exc:
|
||||
logger.debug("Desktop-lifecycle holder scan failed: %s", exc)
|
||||
return False
|
||||
|
||||
for pid, _name, cmdline in holders:
|
||||
if not _looks_like_desktop_control_plane(cmdline):
|
||||
continue
|
||||
if psutil is None:
|
||||
# Cannot prove orphanhood; a live this-install control plane is
|
||||
# enough to refuse stealing gateway start.
|
||||
return True
|
||||
try:
|
||||
proc = psutil.Process(int(pid))
|
||||
parent = proc.parent()
|
||||
if parent is None or not parent.is_running():
|
||||
continue
|
||||
if parent.create_time() > proc.create_time():
|
||||
continue
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _pause_windows_gateways_for_update() -> dict | None:
|
||||
"""Stop running Windows gateways before mutating the checkout or venv.
|
||||
|
||||
@@ -4712,6 +4777,24 @@ def _pause_windows_gateways_for_update() -> dict | None:
|
||||
# gateways that were running when the update began. Cold-start one after
|
||||
# the update so an installed gateway is actually up post-update. Users
|
||||
# who run gateway-less (no autostart entry) get nothing forced on them.
|
||||
#
|
||||
# Exception: Desktop currently owns this install's gateway lifecycle
|
||||
# (live supervised serve/dashboard). A vestigial Startup/Scheduled
|
||||
# Task is not the owner — spawning ``gateway run`` beside Desktop
|
||||
# races ports/state (#76129). Serve is the control plane, not proof
|
||||
# messaging is served; the skip is ownership, not liveness (#92091).
|
||||
try:
|
||||
if _desktop_owns_gateway_lifecycle():
|
||||
logger.debug(
|
||||
"Skipping Windows gateway cold-start plan: "
|
||||
"Desktop owns gateway lifecycle"
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not check Desktop gateway-lifecycle ownership before update: %s",
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
from hermes_cli import gateway_windows
|
||||
|
||||
@@ -4864,6 +4947,18 @@ def _cold_start_windows_gateway_after_update() -> None:
|
||||
logger.debug("Could not re-check gateway liveness before cold-start: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
if _desktop_owns_gateway_lifecycle():
|
||||
logger.debug(
|
||||
"Skipping Windows gateway cold-start: Desktop owns gateway lifecycle"
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not re-check Desktop gateway-lifecycle ownership before cold-start: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
pid = gateway_windows._spawn_detached()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""#76129: post-update Windows cold-start must not steal Desktop-owned lifecycle.
|
||||
|
||||
A vestigial Startup/Scheduled-Task autostart is not proof the user wants a
|
||||
standalone ``gateway run``. When Desktop currently supervises this install's
|
||||
control plane, the updater must not spawn a competing messaging daemon.
|
||||
|
||||
Serve/dashboard are the control plane, not the messaging gateway (#92091).
|
||||
``looks_like_gateway_command_line`` stays strict; ownership is a separate
|
||||
predicate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli import gateway as hermes_gateway
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli import main as cli_main
|
||||
from hermes_cli import process_identity
|
||||
from hermes_cli import update_cmd
|
||||
|
||||
|
||||
def _live_serve_ledger_entry() -> dict:
|
||||
return {
|
||||
"pid": 111,
|
||||
"create_time": 1.0,
|
||||
"purpose": "serve",
|
||||
"install": "abc",
|
||||
"spawner_pid": 99,
|
||||
"spawner_create": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def test_control_plane_argv_is_not_a_gateway():
|
||||
from gateway.status import looks_like_gateway_command_line
|
||||
|
||||
serve = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main serve --host 127.0.0.1"
|
||||
run = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main gateway run"
|
||||
|
||||
assert update_cmd._looks_like_desktop_control_plane(serve) is True
|
||||
assert looks_like_gateway_command_line(serve) is False
|
||||
assert update_cmd._looks_like_desktop_control_plane(run) is False
|
||||
assert looks_like_gateway_command_line(run) is True
|
||||
|
||||
|
||||
def test_ledger_live_serve_with_live_spawner_owns_lifecycle(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()]
|
||||
)
|
||||
monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: False)
|
||||
monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: [])
|
||||
|
||||
assert update_cmd._desktop_owns_gateway_lifecycle() is True
|
||||
|
||||
|
||||
def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()]
|
||||
)
|
||||
monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: True)
|
||||
monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: [])
|
||||
|
||||
assert update_cmd._desktop_owns_gateway_lifecycle() is False
|
||||
|
||||
|
||||
def test_pause_skips_cold_start_plan_when_desktop_owns_lifecycle(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True)
|
||||
|
||||
assert update_cmd._pause_windows_gateways_for_update() is None
|
||||
|
||||
|
||||
def test_pause_still_cold_starts_when_autostart_and_no_desktop_owner(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False)
|
||||
|
||||
token = update_cmd._pause_windows_gateways_for_update()
|
||||
|
||||
assert token == {
|
||||
"resume_needed": True,
|
||||
"profiles": {},
|
||||
"unmapped_pids": [],
|
||||
"unmapped": [],
|
||||
"cold_start_if_installed": True,
|
||||
}
|
||||
|
||||
|
||||
def test_cold_start_aborts_when_desktop_owns_lifecycle(monkeypatch):
|
||||
spawned = []
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows, "_spawn_detached", lambda: spawned.append(1) or 4242
|
||||
)
|
||||
|
||||
update_cmd._cold_start_windows_gateway_after_update()
|
||||
|
||||
assert spawned == []
|
||||
Reference in New Issue
Block a user