fix(gateway,windows): preserve restart watcher env
This commit is contained in:
parent
fa7f24e898
commit
264ac72b67
2 changed files with 136 additions and 0 deletions
|
|
@ -32,6 +32,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import site
|
||||
import sys
|
||||
import signal
|
||||
import tempfile
|
||||
|
|
@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
|
|||
)
|
||||
|
||||
|
||||
def _ensure_windows_gateway_venv_imports() -> None:
|
||||
"""Make detached Windows gateway runs see the Hermes venv packages.
|
||||
|
||||
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
|
||||
to avoid the venv launcher respawning a visible console interpreter. That
|
||||
mode can import the source tree via cwd/PYTHONPATH but still miss optional
|
||||
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
|
||||
Patch the live process before MCP discovery so tool injection does not
|
||||
depend on every launcher preserving PYTHONPATH perfectly.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
|
||||
candidates.append(project_root / "venv")
|
||||
|
||||
seen: set[str] = set()
|
||||
for venv_dir in candidates:
|
||||
try:
|
||||
resolved_venv = venv_dir.resolve()
|
||||
except OSError:
|
||||
resolved_venv = venv_dir
|
||||
venv_key = str(resolved_venv).lower()
|
||||
if venv_key in seen:
|
||||
continue
|
||||
seen.add(venv_key)
|
||||
|
||||
site_packages = resolved_venv / "Lib" / "site-packages"
|
||||
if not site_packages.exists():
|
||||
continue
|
||||
|
||||
project_entry = str(project_root)
|
||||
site_entry = str(site_packages)
|
||||
if project_entry not in sys.path:
|
||||
sys.path.insert(0, project_entry)
|
||||
# addsitepackages() semantics matter here: pywin32, used by the MCP
|
||||
# SDK on Windows, relies on .pth processing to expose pywintypes.
|
||||
site.addsitedir(site_entry)
|
||||
if site_entry in sys.path:
|
||||
sys.path.remove(site_entry)
|
||||
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
|
||||
sys.path.insert(insert_at, site_entry)
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
|
||||
pythonpath = [project_entry, site_entry]
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
pythonpath.append(os.environ["PYTHONPATH"])
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
return
|
||||
|
||||
|
||||
def _gateway_platform_value(platform: Any) -> str:
|
||||
"""Return a normalized gateway platform value for enums or raw strings."""
|
||||
return str(getattr(platform, "value", platform) or "").strip().lower()
|
||||
|
|
@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
)
|
||||
"""
|
||||
).strip()
|
||||
watcher_env = os.environ.copy()
|
||||
# This watcher is intentionally outside the running gateway. If it
|
||||
# inherits the gateway marker, `hermes gateway restart` refuses to
|
||||
# run as a self-restart loop guard and the gateway stays stopped.
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
if site_packages.exists():
|
||||
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
pythonpath = [str(project_root), str(site_packages)]
|
||||
if watcher_env.get("PYTHONPATH"):
|
||||
pythonpath.append(watcher_env["PYTHONPATH"])
|
||||
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
return
|
||||
|
|
@ -15910,6 +15980,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
|||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
_ensure_windows_gateway_venv_imports()
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
|||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "linux")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
|
||||
|
|
@ -219,6 +220,69 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
|||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
pth_extra = tmp_path / "pywin32_system32"
|
||||
site_packages.mkdir(parents=True)
|
||||
pth_extra.mkdir()
|
||||
(site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8")
|
||||
project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run.sys, "path", ["existing"])
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
monkeypatch.setenv("PYTHONPATH", "already-there")
|
||||
|
||||
gateway_run._ensure_windows_gateway_venv_imports()
|
||||
|
||||
assert gateway_run.sys.path[:2] == [project_root, str(site_packages)]
|
||||
assert str(pth_extra) in gateway_run.sys.path
|
||||
assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve())
|
||||
pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert pythonpath[:3] == [project_root, str(site_packages), "already-there"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
site_packages.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
|
||||
import hermes_cli._subprocess_compat as subprocess_compat
|
||||
|
||||
monkeypatch.setattr(
|
||||
subprocess_compat,
|
||||
"windows_detach_popen_kwargs",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
await runner._launch_detached_restart_command()
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[-3:] == ["hermes", "gateway", "restart"]
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir)
|
||||
assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
# ── Shutdown notification tests ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue