mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(windows): gateway-restart watcher leg must use windowless interpreter
The real cause of the persistent 'hermes update -> gateway restart flashes and flurries' report on Windows. #53810 rewrote the respawned-GATEWAY leg to the windowless base interpreter (windowless_gateway_restart_spec), but the WATCHER process that polls the old PID and spawns that gateway was still launched with bare sys.executable — the venv console python.exe, which re-execs the base console interpreter and allocates a conhost window even under CREATE_NO_WINDOW. Two console-python legs per restart = flash, then flurry. - _spawn_gateway_restart_watcher: resolve watcher_argv[0] via _resolve_detached_python (same windowless base interpreter the gateway leg uses), and overlay VIRTUAL_ENV/PYTHONPATH so the base interpreter can import hermes_cli in the inlined watcher snippet. No-op on POSIX. - Regression test pins the watcher leg to the windowless interpreter. NOTE: code-reasoned fix mirroring the proven _spawn_detached pattern; needs a native-Windows smoke pass to confirm (the gap that let #53791/#53810 ship without catching this).
This commit is contained in:
parent
2ecca1e7d3
commit
749edbf650
2 changed files with 93 additions and 2 deletions
|
|
@ -839,8 +839,51 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
|
|||
respawn_env_literal=respawn_env_literal,
|
||||
)
|
||||
|
||||
# The watcher process itself must ALSO launch under the windowless
|
||||
# interpreter on Windows. Using the venv's console ``python.exe`` (the
|
||||
# default ``sys.executable``) re-execs the base console interpreter, which
|
||||
# allocates its own conhost window even under CREATE_NO_WINDOW — the flash
|
||||
# users see on ``hermes update`` → gateway restart. The respawned *gateway*
|
||||
# leg was already rewritten to the windowless base interpreter above
|
||||
# (windowless_gateway_restart_spec); the *watcher* leg was not, so it kept
|
||||
# flashing. Resolve the same base ``pythonw.exe`` + cwd/env overlay here —
|
||||
# note the venv ``pythonw.exe`` is NOT enough for uv venvs (its launcher
|
||||
# shim re-execs the base console python), which is exactly why we reuse
|
||||
# _resolve_detached_python rather than _derive_venv_pythonw. No-op on POSIX.
|
||||
watcher_python = sys.executable
|
||||
watcher_cwd = ""
|
||||
watcher_env_overlay: dict[str, str] = {}
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
from hermes_cli.gateway_windows import _resolve_detached_python
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python(
|
||||
sys.executable
|
||||
)
|
||||
watcher_python = windowless_python
|
||||
# The base interpreter needs the venv's site config + hermes_cli on
|
||||
# PYTHONPATH to import _subprocess_compat / gateway.status in the
|
||||
# watcher snippet. Mirror windowless_gateway_restart_spec's overlay.
|
||||
overlay: dict[str, str] = {"HERMES_GATEWAY_DETACHED": "1"}
|
||||
if venv_dir:
|
||||
overlay["VIRTUAL_ENV"] = str(venv_dir)
|
||||
if extra_pythonpath:
|
||||
existing = os.environ.get("PYTHONPATH", "")
|
||||
joined = os.pathsep.join(
|
||||
p for p in (*extra_pythonpath, existing) if p
|
||||
)
|
||||
overlay["PYTHONPATH"] = joined
|
||||
watcher_env_overlay = overlay
|
||||
except Exception:
|
||||
# Fall back to the console interpreter — a possible flash is worse
|
||||
# than a watcher that never spawns, so keep the restart working.
|
||||
watcher_python = sys.executable
|
||||
watcher_cwd = ""
|
||||
watcher_env_overlay = {}
|
||||
|
||||
watcher_argv = [
|
||||
sys.executable,
|
||||
watcher_python,
|
||||
"-c",
|
||||
watcher,
|
||||
str(old_pid),
|
||||
|
|
@ -848,12 +891,21 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
|
|||
]
|
||||
|
||||
# Same platform-aware detach for the watcher process itself — so
|
||||
# closing the user's terminal doesn't kill the watcher.
|
||||
# closing the user's terminal doesn't kill the watcher. When the watcher
|
||||
# runs under the windowless base interpreter (Windows), it also needs the
|
||||
# cwd + env overlay so its own `from hermes_cli...` imports resolve without
|
||||
# the venv launcher's site config.
|
||||
_watcher_popen_extra: dict = {}
|
||||
if watcher_cwd:
|
||||
_watcher_popen_extra["cwd"] = watcher_cwd
|
||||
if watcher_env_overlay:
|
||||
_watcher_popen_extra["env"] = {**os.environ, **watcher_env_overlay}
|
||||
try:
|
||||
subprocess.Popen(
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_watcher_popen_extra,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
|
|
@ -872,6 +924,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
|
|||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_watcher_popen_extra,
|
||||
**fallback_kwargs,
|
||||
)
|
||||
except OSError:
|
||||
|
|
|
|||
|
|
@ -972,6 +972,44 @@ class TestGatewayDetachedWatcherWindowsFlags:
|
|||
"near the helper call."
|
||||
)
|
||||
|
||||
def test_restart_watcher_leg_uses_windowless_interpreter_on_windows(self):
|
||||
"""The *watcher* process (the python -c poller, not just the respawned
|
||||
gateway) must launch under the windowless base interpreter on Windows.
|
||||
|
||||
Root cause of the persistent "hermes update -> gateway restart flashes
|
||||
and flurries" report: the watcher_argv led with bare ``sys.executable``
|
||||
(the venv console ``python.exe``), which re-execs the base console
|
||||
interpreter and allocates a conhost window even under CREATE_NO_WINDOW.
|
||||
The respawned-gateway leg was rewritten to the windowless interpreter
|
||||
(windowless_gateway_restart_spec) but the watcher leg that spawns it was
|
||||
not — so it kept flashing. The fix resolves the same windowless base
|
||||
python via _resolve_detached_python for watcher_argv[0].
|
||||
|
||||
Static check on the function source so a refactor can't silently revert
|
||||
it back to bare sys.executable.
|
||||
"""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8")
|
||||
start = text.find("def _spawn_gateway_restart_watcher(")
|
||||
assert start != -1, "_spawn_gateway_restart_watcher not found"
|
||||
end = text.find("\ndef ", start + 1)
|
||||
body = text[start:end if end != -1 else len(text)]
|
||||
# The watcher interpreter must be resolved (not bare sys.executable).
|
||||
assert "_resolve_detached_python" in body, (
|
||||
"watcher leg must resolve the windowless base interpreter on Windows "
|
||||
"via _resolve_detached_python, not launch bare sys.executable "
|
||||
"(which re-execs the console python and flashes a conhost window)."
|
||||
)
|
||||
assert "watcher_python = windowless_python" in body, (
|
||||
"watcher_argv[0] must be the resolved windowless interpreter."
|
||||
)
|
||||
# And the watcher must carry the env overlay so its own hermes_cli
|
||||
# imports resolve under the base interpreter.
|
||||
assert "watcher_env_overlay" in body and "PYTHONPATH" in body, (
|
||||
"watcher leg must overlay VIRTUAL_ENV/PYTHONPATH so the base "
|
||||
"interpreter can import hermes_cli in the inlined watcher snippet."
|
||||
)
|
||||
|
||||
def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_fallback(
|
||||
self,
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue