diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ed0caacc777..58cfa2ffccb 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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: diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 8bfb30f947b..0e983ec8e52 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -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, ):