Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions tests/tools/test_windows_native_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading