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:
parent
0bec37aefc
commit
a31fe8db6e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue