Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
749edbf650 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).
2026-06-27 15:26:07 -07:00
2 changed files with 93 additions and 2 deletions

View File

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

View File

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