From 30bb55588fc05dc2afea9fdeef8d8f5fe016cb72 Mon Sep 17 00:00:00 2001 From: Gabriel Steenhoek Date: Tue, 14 Jul 2026 16:11:34 -0500 Subject: [PATCH] fix(gateway): retry detached restart watcher without breakaway The Windows /restart watcher's outer Popen spawns the watcher with windows_detach_popen_kwargs() (which carries CREATE_BREAKAWAY_FROM_JOB), but a restrictive parent job object can reject that bit with OSError and the current call has no retry. Preserve the current watcher implementation and add a focused breakaway-denied fallback. Preserved from current main: watcher_python / pythonw.exe selection, the str(restart_after_s) deadline, the scrubbed watcher_env, the intentional no-breakaway inline respawn, and the entire POSIX setsid/bash path. - primary keeps **windows_detach_popen_kwargs() - on OSError, retry the same argv/env with creationflags=windows_detach_flags_without_breakaway() - on dual failure, log a definitive, path-safe warning (interpreter basename + numeric winerror/errno only) and return without crashing Replace the superseded breakaway-first inline design and its AST tests with focused behavioral coverage that drives the real coroutine with a mocked subprocess.Popen (retry, argv/env/DEVNULL preservation, POSIX single-session kwarg, no-breakaway inline respawn, secret-safe logging). Co-Authored-By: Claude Opus 4.8 --- contributors/emails/phixxation@gmail.com | 1 + gateway/run.py | 67 ++++++- tests/tools/test_windows_native_support.py | 198 +++++++++++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 contributors/emails/phixxation@gmail.com diff --git a/contributors/emails/phixxation@gmail.com b/contributors/emails/phixxation@gmail.com new file mode 100644 index 000000000000..1bc5a8ff69f4 --- /dev/null +++ b/contributors/emails/phixxation@gmail.com @@ -0,0 +1 @@ +VerbalChainsaw diff --git a/gateway/run.py b/gateway/run.py index 1a3be8a897a5..99cc8a90c705 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6970,7 +6970,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # that triggered the /restart command closing its console. if sys.platform == "win32": import textwrap - from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + from hermes_cli._subprocess_compat import ( + windows_detach_flags_without_breakaway, + windows_detach_popen_kwargs, + ) cmd_argv = [*hermes_cmd, "gateway", "restart"] watcher = textwrap.dedent( @@ -7042,13 +7045,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if watcher_env.get("PYTHONPATH"): pythonpath.append(watcher_env["PYTHONPATH"]) watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) - subprocess.Popen( - [watcher_python, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=watcher_env, - **windows_detach_popen_kwargs(), - ) + watcher_argv = [ + watcher_python, + "-c", + watcher, + str(current_pid), + str(restart_after_s), + *cmd_argv, + ] + # The watcher process must itself break away from any job object the + # parent CLI lives in (Electron/Tauri-wrapped Hermes Desktop, Windows + # Terminal, schtasks shells); otherwise it is reaped when the CLI + # exits and the gateway never respawns. windows_detach_popen_kwargs() + # carries CREATE_BREAKAWAY_FROM_JOB, but a restrictive job object + # (no JOB_OBJECT_LIMIT_BREAKAWAY_OK) rejects that bit with + # ERROR_ACCESS_DENIED, surfaced as OSError. Retry once without the + # breakaway bit, preserving argv and the scrubbed watcher_env. + # Mirrors the canonical fallback in + # hermes_cli/gateway_windows.py::_spawn_detached. + try: + subprocess.Popen( + watcher_argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=watcher_env, + **windows_detach_popen_kwargs(), + ) + except OSError: + try: + subprocess.Popen( + watcher_argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=watcher_env, + creationflags=windows_detach_flags_without_breakaway(), + ) + except OSError as exc: + # Both spawn attempts failed (a breakaway-denying job object + # is the common cause, but OSError covers others too). + # Record a minimal, path-safe diagnostic and return without + # crashing the caller: state plainly that no watcher was + # started, and log only the interpreter basename and a + # numeric error code — never argv, env, watcher source, or + # str(exc) (which can carry a full interpreter path for a + # FileNotFoundError). + winerror = getattr(exc, "winerror", None) + error_code = winerror if winerror is not None else exc.errno + error_field = "winerror" if winerror is not None else "errno" + logger.warning( + "Detached restart watcher was not started after the " + "no-breakaway retry (%s; %s=%r). The gateway will not " + "be respawned by this restart attempt.", + os.path.basename(watcher_python), + error_field, + error_code, + ) return cmd = " ".join(shlex.quote(part) for part in hermes_cmd) diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 58bbc007a50d..b4187cf02a02 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -11,8 +11,10 @@ Windows runner. from __future__ import annotations +import asyncio import os import signal +import subprocess import sys from pathlib import Path from unittest import mock @@ -1139,3 +1141,199 @@ class TestWindowlessGatewayRestartSpec: assert cwd == "C:/hermes" assert env["VIRTUAL_ENV"] == str(Path("C:/venv")) assert "PYTHONPATH" in env + + +# --------------------------------------------------------------------------- +# gateway/run.py :: GatewayRunner._launch_detached_restart_command +# outer watcher Popen breakaway-denied fallback (PR #42993) +# --------------------------------------------------------------------------- + + +class TestGatewayRunRestartWatcherOuterPopenFallback: + """The Windows ``/restart`` watcher in ``gateway.run`` spawns an outer + detached ``python -c `` process with + ``windows_detach_popen_kwargs()`` (which carries + ``CREATE_BREAKAWAY_FROM_JOB``). A restrictive parent job object rejects + the breakaway bit with ``ERROR_ACCESS_DENIED`` (surfaced as ``OSError``); + the launcher must retry once without breakaway, preserving argv and the + scrubbed environment, and only warn — never crash, never leak secrets — + if the retry also fails. + + Behavioral: drives the real coroutine with a mocked ``subprocess.Popen`` + rather than asserting on source text. Runs on Linux CI via a + ``sys.platform`` patch; the breakaway-bit assertions are gated on the + real host being Windows because ``_subprocess_compat`` caches + ``IS_WINDOWS`` at import time. + """ + + @staticmethod + def _fake_self(): + from types import SimpleNamespace + + return SimpleNamespace( + _detached_restart_helper_started=False, + _restart_drain_timeout=0.0, + ) + + @classmethod + def _drive(cls, gr): + asyncio.run( + gr.GatewayRunner._launch_detached_restart_command(cls._fake_self()) + ) + + def test_outer_watcher_retries_without_breakaway_on_oserror(self, monkeypatch): + import gateway.run as gr + from hermes_cli._subprocess_compat import ( + IS_WINDOWS, + windows_detach_flags_without_breakaway, + windows_detach_popen_kwargs, + ) + + monkeypatch.setattr(gr.sys, "platform", "win32") + monkeypatch.setattr(gr, "_resolve_hermes_bin", lambda: ["hermes"]) + + calls = [] + + def fake_popen(argv, **kwargs): + calls.append((argv, kwargs)) + if len(calls) == 1: + raise OSError(5, "Access is denied") # ERROR_ACCESS_DENIED + return MagicMock() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + + self._drive(gr) + + assert len(calls) == 2, "outer watcher must retry exactly once on OSError" + (argv1, kw1), (argv2, kw2) = calls + + # argv is identical across primary and fallback, and every current + # watcher parameter survives: + # [watcher_python, "-c",