diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index dc988fa40f230..292e7fd016cd5 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -840,13 +840,49 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: *run_argv, ] + # The watcher leg has the same console trap as the respawned gateway: + # ``sys.executable`` here is the venv's console ``python.exe``. Under a + # uv venv that launcher re-execs the *base* console interpreter — a + # fresh CreateProcess that inherits none of our DETACHED / + # CREATE_NO_WINDOW flags — so a blank visible console sits on screen for + # the whole drain window (it belongs to the watcher, not the gateway). + # Route the watcher through the same windowless rewrite as the gateway + # leg above; the env overlay lets the base ``pythonw.exe`` import + # ``hermes_cli`` / ``gateway.status`` without the venv launcher. + watcher_cwd = "" + watcher_env: dict[str, str] | None = None + if sys.platform == "win32": + try: + from hermes_cli.gateway_windows import ( + windowless_gateway_restart_spec, + ) + + watcher_argv, watcher_cwd, watcher_env_overlay = ( + windowless_gateway_restart_spec(watcher_argv) + ) + if watcher_env_overlay: + watcher_env = {**os.environ, **watcher_env_overlay} + except Exception: + # Best-effort, mirroring the gateway-leg fallback above: a + # visible watcher window beats a restart that never happens. + watcher_cwd = "" + watcher_env = None + + watcher_popen_kwargs: dict = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if watcher_cwd: + watcher_popen_kwargs["cwd"] = watcher_cwd + if watcher_env is not None: + watcher_popen_kwargs["env"] = watcher_env + # Same platform-aware detach for the watcher process itself — so # closing the user's terminal doesn't kill the watcher. try: subprocess.Popen( watcher_argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + **watcher_popen_kwargs, **windows_detach_popen_kwargs(), ) except OSError: @@ -863,8 +899,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: ) subprocess.Popen( watcher_argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + **watcher_popen_kwargs, **fallback_kwargs, ) except OSError: diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 2e6606ead5b81..9ed3d6a42a4c0 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -1052,6 +1052,73 @@ def test_watcher_rewrites_console_python_to_windowless(self): "HERMES_HOME) so the windowless base pythonw resolves hermes_cli." ) + def test_watcher_process_itself_is_windowless(self): + """The watcher *process* must not launch via the venv's console + ``python.exe`` either. + + Same trap, other leg: ``watcher_argv`` led with bare + ``sys.executable``. Under a uv venv that console launcher re-execs + the base console interpreter with default creationflags — a fresh + CreateProcess that inherits none of the watcher's DETACHED / + CREATE_NO_WINDOW flags — so a blank console window stays visible for + the whole drain window even though the respawned gateway leg was + already rewritten to pythonw. + + Static check: the watcher spawn must route ``watcher_argv`` through + ``windowless_gateway_restart_spec`` and thread the resulting cwd / + env overlay into the watcher ``Popen``. + """ + root = Path(__file__).resolve().parents[2] + text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") + start = text.find("watcher_argv = [") + assert start != -1 + block = text[start:] + assert "windowless_gateway_restart_spec(watcher_argv)" in block, ( + "_spawn_gateway_restart_watcher must rewrite watcher_argv via " + "windowless_gateway_restart_spec so the watcher runs under the " + "windowless base pythonw.exe, not the venv console python.exe." + ) + + def test_watcher_popen_carries_windowless_interpreter_and_overlay(self): + """Behavioral: on win32 the watcher ``Popen`` argv leads with the + windowless interpreter and carries the cwd + env overlay from + ``windowless_gateway_restart_spec``.""" + import hermes_cli.gateway as gateway_mod + import hermes_cli.gateway_windows as gw + + popen_calls: list[tuple[list[str], dict]] = [] + + def fake_spec(argv): + return ( + ["C:/base/pythonw.exe", *argv[1:]], + "C:/hermes", + {"VIRTUAL_ENV": "C:/venv", "PYTHONPATH": "C:/proj"}, + ) + + def fake_popen(argv, **kwargs): + popen_calls.append((argv, kwargs)) + return MagicMock() + + with mock.patch.object( + gw, "windowless_gateway_restart_spec", side_effect=fake_spec + ), mock.patch.object( + gateway_mod.subprocess, "Popen", side_effect=fake_popen + ), mock.patch.object(gateway_mod.sys, "platform", "win32"): + ok = gateway_mod._spawn_gateway_restart_watcher( + 1234, + ["C:/venv/Scripts/python.exe", "-m", "hermes_cli.main", "gateway", "run"], + ) + + assert ok is True + assert popen_calls, "watcher Popen was never invoked" + argv, kwargs = popen_calls[-1] + assert argv[0] == "C:/base/pythonw.exe", ( + "watcher must launch under the windowless base interpreter" + ) + assert kwargs.get("cwd") == "C:/hermes" + assert kwargs["env"]["VIRTUAL_ENV"] == "C:/venv" + assert "C:/proj" in kwargs["env"]["PYTHONPATH"] + class TestWindowlessGatewayRestartSpec: """gateway_windows.windowless_gateway_restart_spec — the helper that