From a31fe8db6eb701ba7a9226a19e9f09ae651a6c00 Mon Sep 17 00:00:00 2001 From: iso2kx <8766057+iso2kx@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:32:38 +0800 Subject: [PATCH] fix(update): stop gateway holders the guard finds after the pause The pause stops every gateway its discovery maps, but the venv-holder guard sees the process table as it is now: a gateway respawned by its supervisor (Scheduled Task, login watchdog) inside the pause-to-guard window, or one started through a spawn path discovery does not map, still holds venv .pyds - and the guard dead-ended the update on exactly the kind of process the pause machinery exists to stop. When every remaining holder classifies as a pausable gateway - using the same _is_pausable_gateway matcher the Desktop preflight uses, so the two views cannot drift - stop them and re-scan once. Any non-gateway holder (REPL, stray script, Desktop backend) keeps the hard refusal exactly as before, and a survivor after the stop still aborts. --- hermes_cli/main.py | 1 + hermes_cli/update_cmd.py | 70 +++++++++++++++ .../test_update_concurrent_quarantine.py | 89 +++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7ed04d5956791..a8fb2ffc473ce 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5039,6 +5039,7 @@ from hermes_cli.update_cmd import ( # noqa: F401 _invalidate_update_cache, _is_android_python, _is_fork, + _leftover_pausable_gateway_pids, _log_only_write, _mark_skip_upstream_prompt, _npm_bin_exists, diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index cb7efc14593aa..2e0895ba778bf 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -2749,6 +2749,52 @@ def _venv_launcher_ancestors(pids: list[int]) -> list[int]: return found +def _leftover_pausable_gateway_pids( + matches: list[tuple[int, str, str]], +) -> list[int] | None: + """PIDs from *matches* when every remaining venv holder is a pausable gateway. + + ``_pause_windows_gateways_for_update()`` stops every gateway its discovery + finds, but the venv-holder guard downstream sees the process table as it + is *now*: a gateway respawned by its supervisor (Scheduled Task, login + watchdog) inside the pause→guard window, or one started through a spawn + path the discovery does not map, still holds venv ``.pyd`` files and + would dead-end the update — an abort pointed at exactly the kind of + process the pause machinery exists to stop. + + Holders are classified with the same matcher the Desktop preflight uses + to exempt them (``_is_pausable_gateway``), so the preflight's exemption + and this guard's tolerance cannot drift apart — matcher drift between + two views of the same process table is what produced the launcher/worker + dead-end fixed above. The scan captures only a 120-char cmdline prefix, + so the live argv is re-read where psutil allows; an unreadable argv + falls back to the captured prefix. + + Returns ``None`` when any holder is not a pausable gateway — an operator + REPL, a stray script, or the Desktop backend has no pause machinery + downstream, and the guard must keep refusing exactly as before. + """ + from hermes_cli._scan_venv_blockers import _is_pausable_gateway + + try: + import psutil # type: ignore + except Exception: + psutil = None + + pids: list[int] = [] + for pid, _name, cmdline in matches: + argv = cmdline + if psutil is not None: + try: + argv = " ".join(psutil.Process(int(pid)).cmdline()) or cmdline + except Exception: + pass + if not _is_pausable_gateway(argv): + return None + pids.append(int(pid)) + return pids + + def _pause_windows_gateways_for_update() -> dict | None: """Stop running Windows gateways before mutating the checkout or venv. @@ -3285,6 +3331,30 @@ def _cmd_update_impl(args, gateway_mode: bool): # --force-venv is the explicit escape hatch. if _m()._is_windows() and not getattr(args, "force_venv", False): _venv_holders = _m()._detect_venv_python_processes() + if _venv_holders: + _gateway_holders = _m()._leftover_pausable_gateway_pids(_venv_holders) + if _gateway_holders is not None: + # Every remaining holder is a gateway the pause machinery + # already owns — respawned by its supervisor inside the + # pause→guard window, or up through a spawn path discovery + # does not map. Stop them and re-check instead of + # dead-ending; the post-update resume (and the supervisor + # that respawned them) brings gateways back afterwards. + from gateway.status import terminate_pid + + print( + f" ⚠ {len(_gateway_holders)} gateway process(es) still " + "hold the venv after the pause; stopping them" + ) + for _pid in _gateway_holders: + try: + terminate_pid(int(_pid), force=True) + except Exception as exc: + logger.debug( + "Could not stop leftover gateway %s: %s", _pid, exc + ) + _time.sleep(1.0) + _venv_holders = _m()._detect_venv_python_processes() if _venv_holders: print(_format_venv_python_holders_message(_venv_holders)) _m()._resume_windows_gateways_after_update(_windows_gateway_resume) diff --git a/tests/hermes_cli/test_update_concurrent_quarantine.py b/tests/hermes_cli/test_update_concurrent_quarantine.py index c736a445eaa33..92d381f864190 100644 --- a/tests/hermes_cli/test_update_concurrent_quarantine.py +++ b/tests/hermes_cli/test_update_concurrent_quarantine.py @@ -401,6 +401,95 @@ def test_pause_kill_set_covers_venv_guard_abort_set( ) +# --------------------------------------------------------------------------- +# _leftover_pausable_gateway_pids (the guard-level gateway fallback) +# +# The pause stops every gateway discovery finds, but the venv-holder guard +# sees the process table as it is NOW. A supervisor (Scheduled Task, login +# watchdog) can respawn a gateway inside the pause→guard window, and some +# spawn paths never register in discovery at all. Those holders are exactly +# what the pause machinery exists to stop — the guard nominates them for a +# stop-and-recheck instead of dead-ending, and refuses the moment any +# non-gateway holder is present. +# --------------------------------------------------------------------------- + + +GATEWAY_ARGV = [ + r"C:\x\venv\Scripts\python.exe", + "-m", + "hermes_cli.main", + "gateway", + "run", +] + + +def _fake_psutil_cmdlines(argv_by_pid): + """psutil stand-in serving live argv per pid; unknown pids raise.""" + + class FakeProc: + def __init__(self, pid): + if pid not in argv_by_pid: + raise ValueError(f"no such pid {pid}") + self._argv = argv_by_pid[pid] + + def cmdline(self): + return self._argv + + return types.SimpleNamespace(Process=FakeProc) + + +def test_leftover_holders_that_are_all_gateways_are_nominated(monkeypatch): + """Respawned/unmapped gateway holders get stopped, not dead-ended on.""" + monkeypatch.setitem( + sys.modules, + "psutil", + _fake_psutil_cmdlines({300: GATEWAY_ARGV, 301: GATEWAY_ARGV}), + ) + matches = [ + (300, "python.exe", "truncated..."), + (301, "python.exe", "truncated..."), + ] + + assert cli_main._leftover_pausable_gateway_pids(matches) == [300, 301] + + +def test_one_non_gateway_holder_keeps_the_hard_refusal(monkeypatch): + """A REPL/backend holder means the guard must abort exactly as before.""" + monkeypatch.setitem( + sys.modules, + "psutil", + _fake_psutil_cmdlines( + {300: GATEWAY_ARGV, 400: [r"C:\x\venv\Scripts\python.exe", "-i"]} + ), + ) + matches = [(300, "python.exe", "..."), (400, "python.exe", "...")] + + assert cli_main._leftover_pausable_gateway_pids(matches) is None + + +def test_unreadable_argv_falls_back_to_the_captured_prefix(monkeypatch): + """psutil failure degrades to the scan's captured cmdline, not a crash. + + The captured prefix decides: a gateway invocation still qualifies, and + anything else still refuses. + """ + monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_cmdlines({})) + gateway_prefix = r"venv\Scripts\python.exe -m hermes_cli.main gateway run" + + assert cli_main._leftover_pausable_gateway_pids( + [(300, "python.exe", gateway_prefix)] + ) == [300] + assert ( + cli_main._leftover_pausable_gateway_pids( + [ + (300, "python.exe", gateway_prefix), + (400, "python.exe", "python.exe -i"), + ] + ) + is None + ) + +