fix(gateway): prevent Windows Telegram /restart leaving gateway stopped

This commit is contained in:
Martin 2026-05-14 22:22:29 +02:00 committed by Teknium
parent 1d378605dd
commit 417a653d9e
4 changed files with 198 additions and 14 deletions

View file

@ -268,6 +268,67 @@ def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkey
assert calls == [False]
def test_gateway_restart_on_windows_without_service_uses_detached_backend(monkeypatch):
"""Windows manual restart must not fall back to foreground run_gateway().
A Telegram-hosted agent may run `hermes gateway restart` via the terminal
tool. The generic manual fallback stops the gateway and then calls
run_gateway() in the same foreground subprocess; on Windows that subprocess
can be reaped when its gateway parent is terminated, leaving the gateway
down. The Windows backend restarts via detached pythonw.exe even when no
Scheduled Task / Startup item is installed.
"""
import hermes_cli.gateway_windows as gateway_windows
calls = []
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway_windows, "is_installed", lambda: False)
monkeypatch.setattr(gateway_windows, "restart", lambda: calls.append("restart"))
monkeypatch.setattr(
gateway,
"run_gateway",
lambda *args, **kwargs: pytest.fail("Windows restart must not use foreground run_gateway()"),
)
monkeypatch.setattr(
gateway,
"stop_profile_gateway",
lambda: pytest.fail("Windows restart must not use generic manual stop fallback"),
)
args = SimpleNamespace(gateway_command="restart", system=False, all=False)
gateway.gateway_command(args)
assert calls == ["restart"]
def test_gateway_restart_on_windows_preserves_failure_fallback(monkeypatch):
"""If the Windows backend cannot launch, keep the existing fallback."""
import hermes_cli.gateway_windows as gateway_windows
calls = []
def fail_restart():
calls.append("restart")
raise OSError("simulated detached backend failure")
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
monkeypatch.setattr(gateway, "is_windows", lambda: True)
monkeypatch.setattr(gateway_windows, "is_installed", lambda: False)
monkeypatch.setattr(gateway_windows, "restart", fail_restart)
monkeypatch.setattr(gateway, "stop_profile_gateway", lambda: calls.append("stop") or False)
monkeypatch.setattr(gateway, "_wait_for_gateway_exit", lambda *args, **kwargs: calls.append("wait"))
monkeypatch.setattr(gateway, "run_gateway", lambda *args, **kwargs: calls.append("run"))
args = SimpleNamespace(gateway_command="restart", system=False, all=False)
gateway.gateway_command(args)
assert calls == ["restart", "stop", "wait", "run"]
def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys):
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("[Unit]\n")

View file

@ -0,0 +1,63 @@
"""Tests for the Windows gateway backend."""
import pytest
import hermes_cli.gateway_windows as gateway_windows
@pytest.mark.parametrize(
"detail",
[
"ERROR: Access is denied.",
"ERROR: Acceso denegado.",
"ERROR: Přístup byl odepřen.",
"schtasks timed out after 15s",
"schtasks produced no output",
],
)
def test_schtasks_fallback_patterns_cover_localized_access_denied(detail):
"""Localized schtasks access-denied errors should use Startup fallback."""
assert gateway_windows._should_fall_back(1, detail) is True
def test_schtasks_fallback_does_not_hide_unknown_errors():
assert gateway_windows._should_fall_back(1, "ERROR: The system cannot find the file specified.") is False
def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, tmp_path):
"""Avoid uv's venv pythonw launcher because it respawns console python.exe."""
project = tmp_path / "project"
scripts = project / "venv" / "Scripts"
site_packages = project / "venv" / "Lib" / "site-packages"
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
scripts.mkdir(parents=True)
site_packages.mkdir(parents=True)
base.mkdir(parents=True)
venv_python = scripts / "python.exe"
venv_pythonw = scripts / "pythonw.exe"
base_pythonw = base / "pythonw.exe"
for exe in (venv_python, venv_pythonw, base_pythonw):
exe.write_text("", encoding="utf-8")
(project / "venv" / "pyvenv.cfg").write_text(
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
encoding="utf-8",
)
import hermes_cli.gateway as gateway
monkeypatch.setattr(gateway_windows.sys, "platform", "win32")
monkeypatch.setattr(gateway, "PROJECT_ROOT", project)
monkeypatch.setattr(gateway, "get_python_path", lambda: str(venv_python))
monkeypatch.setattr(gateway, "_profile_arg", lambda hermes_home: "")
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(tmp_path / "hermes-home"))
argv, cwd, env_overlay = gateway_windows._build_gateway_argv()
assert argv[:3] == [str(base_pythonw), "-m", "hermes_cli.main"]
assert cwd == str(project)
assert env_overlay["VIRTUAL_ENV"] == str(project / "venv")
assert str(project) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)