Three coordinated fixes for the Windows gateway reliability story:
1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn
The 'hermes update' triggered from the Electron Desktop GUI ran inside
Electron's job object. Without breakaway, the post-update gateway
watcher spawned by update — already DETACHED_PROCESS — was still
reaped when Electron's job tore down, so the gateway never came back
after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000)
to:
- hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by
every helper that calls windows_detach_popen_kwargs(), including
launch_detached_profile_gateway_restart()
- The watcher subprocess's own respawn snippet in
hermes_cli/gateway.py (inlined flags so the watcher's child
respawn also breaks away)
_spawn_detached() in gateway_windows.py already had the flag; this
change brings the rest of the codebase to parity.
2. Per-minute supervisor Scheduled Task — Windows equivalent of
systemd Restart=always
Introduces hermes_cli/gateway_supervisor.py and registers it as a
second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1,
LIMITED rights) alongside the existing ONLOGON task. Every minute,
the supervisor uses the same gateway.status.get_running_pid() probe
as 'hermes gateway status' and, if no gateway is alive, calls
gateway_windows._spawn_detached() (which now includes BREAKAWAY) to
bring one back.
Covers every crash mode, not just 'machine rebooted': taskkill,
OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw
startup per minute when down, one PID-existence check per minute
when up.
Wired into both the schtasks-success and Startup-folder-fallback
install paths via _install_supervisor_best_effort(), and removed in
uninstall(). Best-effort: a failing supervisor install logs a
warning but doesn't roll back the primary install.
3. 'hermes gateway status --deep' shows per-probe PASS/FAIL
Replaces the existing terse '--deep' output (which only printed
paths) with an actual diagnostic table:
[1] PID file present
[2] Lock file held by a live process
[3] get_running_pid() result
[4] _pid_exists(pid) — OS-level liveness
[5] gateway_state.json (state + age)
[6] Last lifecycle event from gateway-exit-diag.log
When the high-level summary disagrees with reality, the user can
see exactly which signal is lying.
Test-leak fix
-------------
tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages
monkey-patched is_linux/is_wsl/supports_systemd_services to simulate
WSL but did NOT stub is_windows(). On a Windows host, the dispatcher
in _gateway_command_inner takes the is_windows() branch BEFORE the
WSL guidance branch, so the test invoked gateway_windows.install()
for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd
— the REAL user Startup folder, never sandboxed by tmp_path — pointing
at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper.
When pytest tore down the tmp_path, every subsequent Windows login
flashed a cmd.exe window that failed to find the missing target.
Stubs is_windows=False on all four affected tests:
test_install_wsl_no_systemd
test_start_wsl_no_systemd
test_status_wsl_running_manual
test_status_wsl_not_running
Defense-in-depth: _build_startup_launcher() now prefixes the launcher
with 'if not exist <target> exit /b 0', so any future stale Startup
entry silently no-ops instead of flashing a console window.
Status enhancements
-------------------
- status() now reports supervisor task presence alongside the existing
schtasks/Startup info, and nudges the user to reinstall if the
supervisor isn't registered.
- Deep mode dumps both the supervisor task name + script path.
171 lines
5.4 KiB
Python
171 lines
5.4 KiB
Python
"""Windows gateway supervisor — poll-and-respawn for crash recovery.
|
|
|
|
Invoked once per minute by the ``Hermes_Gateway_Supervisor`` Scheduled Task
|
|
(see :func:`hermes_cli.gateway_windows._install_supervisor_task`). Checks
|
|
whether the per-profile gateway is alive and, if not, spawns a detached
|
|
replacement using the same ``_spawn_detached()`` helper that
|
|
``hermes gateway start`` uses.
|
|
|
|
This is the Windows analogue of systemd's ``Restart=always``. It runs as the
|
|
logged-in user (LIMITED rights), needs no admin context, and exits silently
|
|
when the gateway is already up.
|
|
|
|
CLI:
|
|
pythonw -m hermes_cli.gateway_supervisor [--profile NAME]
|
|
|
|
Exit codes:
|
|
0 always (the supervisor is best-effort; never crash the schtasks parent)
|
|
|
|
Logging:
|
|
All actions are appended to ``$HERMES_HOME/logs/gateway-supervisor.log``.
|
|
The file is truncated to the last 2000 lines on each invocation to keep
|
|
it bounded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import os
|
|
import sys
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
|
|
_LOG_MAX_LINES = 2000
|
|
|
|
|
|
def _resolve_log_path() -> Path:
|
|
"""Return the supervisor log path under the current HERMES_HOME.
|
|
|
|
Imported lazily so a missing/broken hermes_constants import doesn't
|
|
crash the supervisor — we fall back to a sensible default.
|
|
"""
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
|
|
home = get_hermes_home()
|
|
except Exception:
|
|
# Best-effort fallback. The supervisor must never crash on import.
|
|
home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
|
|
log_dir = Path(home) / "logs"
|
|
return log_dir / "gateway-supervisor.log"
|
|
|
|
|
|
def _log(msg: str) -> None:
|
|
"""Append a timestamped line to the supervisor log.
|
|
|
|
Best-effort: a logging failure must never crash the supervisor.
|
|
"""
|
|
try:
|
|
log_path = _resolve_log_path()
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
|
with open(log_path, "a", encoding="utf-8") as fh:
|
|
fh.write(f"[{ts}] {msg}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _truncate_log() -> None:
|
|
"""Keep the supervisor log bounded (last ``_LOG_MAX_LINES`` lines).
|
|
|
|
Cheap to do once per invocation — file is small and reads sequentially.
|
|
"""
|
|
try:
|
|
log_path = _resolve_log_path()
|
|
if not log_path.exists():
|
|
return
|
|
with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
lines = fh.readlines()
|
|
if len(lines) <= _LOG_MAX_LINES:
|
|
return
|
|
tail = lines[-_LOG_MAX_LINES:]
|
|
tmp = log_path.with_suffix(log_path.suffix + ".tmp")
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
fh.writelines(tail)
|
|
tmp.replace(log_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="hermes_cli.gateway_supervisor",
|
|
description="Windows poll-and-respawn supervisor for the Hermes gateway.",
|
|
)
|
|
# ``--profile`` is accepted for parity with the main CLI but the
|
|
# supervisor relies on HERMES_HOME being correctly set in the
|
|
# scheduled-task environment; the .cmd wrapper sets it explicitly.
|
|
parser.add_argument("--profile", default=None)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _gateway_is_alive() -> tuple[bool, int | None]:
|
|
"""Probe whether a gateway is running for the current HERMES_HOME.
|
|
|
|
Returns ``(alive, pid)``. ``pid`` is the running gateway PID when
|
|
``alive`` is True, else None.
|
|
"""
|
|
try:
|
|
from gateway.status import get_running_pid
|
|
except Exception as exc:
|
|
_log(f"probe import failure: {exc!r}")
|
|
return (True, None) # Conservative: don't try to respawn if we can't probe
|
|
|
|
try:
|
|
pid = get_running_pid(cleanup_stale=False)
|
|
except Exception as exc:
|
|
_log(f"get_running_pid raised: {exc!r}")
|
|
return (True, None)
|
|
return (pid is not None, pid)
|
|
|
|
|
|
def _respawn() -> int | None:
|
|
"""Spawn a fresh detached gateway. Return the PID, or None on failure."""
|
|
try:
|
|
from hermes_cli import gateway_windows
|
|
except Exception as exc:
|
|
_log(f"gateway_windows import failure: {exc!r}")
|
|
return None
|
|
try:
|
|
return gateway_windows._spawn_detached()
|
|
except Exception as exc:
|
|
_log(f"_spawn_detached raised: {exc!r}\n{traceback.format_exc()}")
|
|
return None
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Entrypoint for ``pythonw -m hermes_cli.gateway_supervisor``."""
|
|
try:
|
|
_parse_args(argv)
|
|
except SystemExit:
|
|
# argparse exits with 2 on --help / bad args. The schtasks parent
|
|
# should still see 0 so the task doesn't get marked "last run failed".
|
|
return 0
|
|
except Exception as exc:
|
|
_log(f"argparse raised: {exc!r}")
|
|
return 0
|
|
|
|
_truncate_log()
|
|
|
|
try:
|
|
alive, pid = _gateway_is_alive()
|
|
if alive:
|
|
# Stay quiet on healthy ticks to keep the log small.
|
|
return 0
|
|
_log("gateway is down; respawning")
|
|
new_pid = _respawn()
|
|
if new_pid is None:
|
|
_log("respawn failed; will retry on next tick")
|
|
else:
|
|
_log(f"respawned gateway pid={new_pid}")
|
|
except Exception as exc:
|
|
_log(f"supervisor uncaught: {exc!r}\n{traceback.format_exc()}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|