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.
This commit is contained in:
iso2kx 2026-07-30 13:32:38 +08:00 committed by Teknium
parent 0bec37aefc
commit a31fe8db6e
3 changed files with 160 additions and 0 deletions

View File

@ -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,

View File

@ -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 pauseguard 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)

View File

@ -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
)