diff --git a/gateway/run.py b/gateway/run.py index 9afea42c3228..5e08eebe8064 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6942,19 +6942,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # 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 + # The watcher runs sys.executable (console python) under the + # CREATE_NO_WINDOW detach kwargs below: it owns one hidden + # console, inherited by the `hermes gateway restart` child, so + # nothing flashes. Do NOT swap in GUI-subsystem pythonw.exe — + # a console-less watcher forces every console-subsystem + # descendant to allocate a visible conhost (#54220/#56747). watcher_python = sys.executable - try: - # Prefer a real GUI-subsystem interpreter for the watcher - # itself. With uv venvs, ``python.exe`` can re-exec the base - # console interpreter and flash even when the Popen carries - # CREATE_NO_WINDOW; pythonw.exe avoids console allocation. - from hermes_cli.gateway_windows import _resolve_detached_python - - watcher_python, _watcher_venv_dir, _watcher_site_packages = ( - _resolve_detached_python(sys.executable) - ) - except Exception: - watcher_python = sys.executable venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") site_packages = venv_dir / "Lib" / "site-packages" if site_packages.exists(): diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 4086d42f303b..00358fd3bb79 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -10,8 +10,9 @@ Several common subprocess patterns break silently-or-loudly on Windows: * ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and actually detaches the child. On Windows it's silently ignored; the - Windows equivalent is ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` - creationflags, which Python only applies when you pass them explicitly. + Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`` + creationflags bundle, which Python only applies when you pass it + explicitly. * Console-window flashes — every ``subprocess.Popen`` of a ``.exe`` on Windows spawns a cmd window briefly unless ``CREATE_NO_WINDOW`` is @@ -98,7 +99,23 @@ def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]: # because CREATE_NO_WINDOW and DETACHED_PROCESS aren't guaranteed to be # present on stdlib subprocess on older Pythons or non-Windows builds. _CREATE_NEW_PROCESS_GROUP = 0x00000200 -_DETACHED_PROCESS = 0x00000008 +# DETACHED_PROCESS is intentionally NOT part of any flag bundle here — do not +# re-add it. Two reasons (the recurring console-flash bug #54220 / #56747): +# +# 1. MSDN (Process Creation Flags): CREATE_NO_WINDOW "is ignored if used with +# either CREATE_NEW_CONSOLE or DETACHED_PROCESS". Combining them means +# DETACHED_PROCESS governs and the no-window bit is dead. +# 2. A DETACHED_PROCESS child has NO console at all, so every console-subsystem +# descendant it ever spawns (git, gh, cmd, node, wmic, powershell, …) must +# allocate its OWN console — a visible flash per spawn, including spawns +# inside third-party libraries that no per-call-site CREATE_NO_WINDOW sweep +# can reach. A CREATE_NO_WINDOW child instead OWNS a hidden console that +# all descendants inherit, making "no flashing windows" a property of the +# one daemon launch. Root cause isolated + A/B verified on Windows 11 by +# the desktop backend fix (commit aa2ae36c3f): with per-site hide flags +# neutered, naive git/gh/cmd spawns don't flash under a hidden-console +# parent and do flash under a console-less one. +_DETACHED_PROCESS = 0x00000008 # kept for reference; must stay out of bundles _CREATE_NO_WINDOW = 0x08000000 # Escape any Win32 job object the parent process belongs to. Without this, # a detached child still inherits its parent's job object membership, and @@ -114,7 +131,8 @@ _CREATE_BREAKAWAY_FROM_JOB = 0x01000000 def windows_detach_flags() -> int: """Return Win32 creationflags that detach a child from the parent - console and process group. 0 on non-Windows. + console and process group without leaving it console-less. 0 on + non-Windows. Pair with ``start_new_session=False`` (default) when calling subprocess.Popen — on POSIX use ``start_new_session=True`` instead, @@ -123,19 +141,23 @@ def windows_detach_flags() -> int: Rationale: - ``CREATE_NEW_PROCESS_GROUP`` — child has its own process group so Ctrl+C in the parent console doesn't propagate. - - ``DETACHED_PROCESS`` — child has no console at all. Necessary for - background daemons (gateway watchers, update respawners) because - without it, closing the console kills the child. - - ``CREATE_NO_WINDOW`` — suppress the brief cmd flash that would - otherwise appear when launching a console app. Redundant with - DETACHED_PROCESS but explicit for clarity. + - ``CREATE_NO_WINDOW`` — the child gets its own fresh console that is + never shown. This both detaches it from the parent's console + lifetime (closing the launching terminal doesn't CTRL_CLOSE it) AND + gives every console-subsystem descendant (git, gh, cmd, node, …) a + console to inherit, so they don't allocate visible flashing ones. + This deliberately replaces the old ``DETACHED_PROCESS`` approach: + MSDN specifies CREATE_NO_WINDOW is *ignored* when combined with + DETACHED_PROCESS, and a truly console-less daemon re-creates the + per-descendant console-flash bug (#54220/#56747) at every spawn — + see the note on ``_DETACHED_PROCESS`` above. - ``CREATE_BREAKAWAY_FROM_JOB`` — escape any job object the parent is in. Electron (Desktop app) and Tauri (bootstrap installer) wrap their children in job objects; without breakaway, those children - die when the parent process exits even if they were spawned with - DETACHED_PROCESS. This was the missing flag that made the - post-update gateway respawn watcher silently die alongside the - Tauri updater after the Electron Desktop's update flow finished. + die when the parent process exits even though they have their own + console. This was the missing flag that made the post-update + gateway respawn watcher silently die alongside the Tauri updater + after the Electron Desktop's update flow finished. If a process is in a job that disallows breakaway (rare — JOB_OBJECT_LIMIT_BREAKAWAY_OK isn't set), CreateProcess returns @@ -149,7 +171,6 @@ def windows_detach_flags() -> int: return 0 return ( _CREATE_NEW_PROCESS_GROUP - | _DETACHED_PROCESS | _CREATE_NO_WINDOW | _CREATE_BREAKAWAY_FROM_JOB ) @@ -182,7 +203,7 @@ def windows_detach_flags_without_breakaway() -> int: """ if not IS_WINDOWS: return 0 - return _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW + return _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW def windows_hide_flags() -> int: @@ -193,10 +214,12 @@ def windows_hide_flags() -> int: operation (``taskkill``, ``where``, version probes) where we want no flash but also want to collect stdout/exit code synchronously. - The key difference from :func:`windows_detach_flags`: NO - ``DETACHED_PROCESS`` — the child still inherits stdio handles so - ``capture_output=True`` works. ``DETACHED_PROCESS`` would sever - stdio and break stdout capture. + The difference from :func:`windows_detach_flags`: no + ``CREATE_NEW_PROCESS_GROUP`` / ``CREATE_BREAKAWAY_FROM_JOB`` — the + child stays in the parent's process group and job so Ctrl+C and job + teardown propagate normally, as a short-lived helper wants. Stdio + handles are inherited either way, so ``capture_output=True`` works + with both bundles. """ if not IS_WINDOWS: return 0 diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index efd16c3e1965..ac95fac06de0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -766,14 +766,14 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: ) # On Windows the incoming ``run_argv`` leads with the venv's console - # ``python.exe`` (from ``get_python_path()``). Respawning the gateway - # with that interpreter — even under CREATE_NO_WINDOW — leaves a - # persistent console window, because uv's venv launcher re-execs the - # base console interpreter, which allocates its own conhost. Rewrite - # the argv to the windowless ``pythonw.exe`` (mirroring the clean-start - # ``_spawn_detached`` path) and capture the cwd + env overlay the base - # interpreter needs to resolve imports without the venv launcher. - # No-op on POSIX. See gateway_windows.windowless_gateway_restart_spec. + # ``python.exe`` (from ``get_python_path()``). That's the interpreter we + # want: the watcher respawns it under CREATE_NO_WINDOW detach flags, so + # the gateway owns one hidden console that all descendants inherit — + # nothing flashes (#54220/#56747). The spec helper normalizes the + # interpreter and captures the stable cwd + env overlay (HERMES_HOME, + # VIRTUAL_ENV, PYTHONPATH) so the respawn doesn't depend on the watcher's + # transient working directory. No-op on POSIX. + # See gateway_windows.windowless_gateway_restart_spec. respawn_cwd = "" respawn_env_overlay: dict[str, str] = {} if sys.platform == "win32": diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 55ed976433da..a55200a15dff 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -10,7 +10,7 @@ Design notes ------------ * ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the CURRENT USER's next logon without any elevation prompt. Manual starts and - install ``--start-now`` use the direct detached ``pythonw`` launcher instead + install ``--start-now`` use the direct hidden-console launcher instead of ``schtasks /Run`` so start/restart behavior is consistent. * We write a shared ``gateway.cmd`` wrapper plus a console-less ``gateway.vbs`` launcher. Scheduled Task and Startup-folder persistence both route through @@ -208,9 +208,14 @@ def _current_profile_cli_args() -> list[str]: def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None = None) -> bool: """Launch an elevated gateway subcommand via UAC and return True on handoff. - Use pythonw.exe for the elevated child so approving UAC does not leave a - second elevated console window sitting open after the handoff. All operator - decisions are already collected in the parent shell before this point. + The elevated child is the console ``python.exe`` launched with + ``SW_HIDE``: ShellExecuteW applies the show-command to a console app's + console window, so the child owns a single *hidden* console that its own + subprocess spawns (schtasks, taskkill, …) inherit — no visible window + after the UAC approval, and no per-descendant conhost flashes (the + console-less pythonw.exe alternative re-created #54220/#56747 for every + console-subsystem child). All operator decisions are already collected in + the parent shell before this point. """ _assert_windows() args = ["-m", "hermes_cli.main", *_current_profile_cli_args(), "gateway", command] @@ -218,7 +223,7 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None args.extend(extra_args) params = subprocess.list2cmdline(args) cwd = str(Path(__file__).resolve().parent.parent) - elevated_python = _derive_venv_pythonw(sys.executable) + elevated_python = sys.executable try: result = ctypes.windll.shell32.ShellExecuteW( None, @@ -226,7 +231,7 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None elevated_python, params, cwd, - 0, # SW_HIDE: pythonw child should not create a visible console. + 0, # SW_HIDE: the child's console exists but is never shown. ) except Exception as exc: print(f"⚠ Could not launch elevated gateway {command} prompt: {exc}") @@ -389,8 +394,13 @@ def _build_gateway_cmd_script( The script: - cd's into a stable working directory - exports HERMES_HOME, PYTHONIOENCODING, VIRTUAL_ENV - - invokes ``pythonw -m hermes_cli.main [--profile X] gateway run`` - directly so the wrapper cmd.exe exits without a visible gateway console + - invokes ``python -m hermes_cli.main [--profile X] gateway run`` + + The .cmd is a compatibility/manual-run artifact: service persistence + (Scheduled Task, Startup folder) routes through the ``.vbs`` launcher, + which runs this same command line hidden (window style 0). Run by hand + in a real terminal, the console interpreter keeps the gateway attached + to that terminal like a normal foreground ``hermes gateway run``. We intentionally do NOT inline PATH overrides here — cmd.exe inherits the per-user PATH the Scheduled Task was created with, and forcibly @@ -401,7 +411,7 @@ def _build_gateway_cmd_script( lines.append(f'set "HERMES_HOME={hermes_home}"') lines.append('set "PYTHONIOENCODING=utf-8"') lines.append('set "HERMES_GATEWAY_DETACHED=1"') - pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) + python_exe_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) # VIRTUAL_ENV lets the gateway's own python detection find the venv # if someone imports hermes_constants-based logic during startup. lines.append(f'set "VIRTUAL_ENV={_preserve_hermes_home_path(venv_dir)}"') @@ -411,14 +421,12 @@ def _build_gateway_cmd_script( ] lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"') - prog_args = [pythonw_path, "-m", "hermes_cli.main"] + prog_args = [python_exe_path, "-m", "hermes_cli.main"] if profile_arg: prog_args.extend(profile_arg.split()) prog_args.extend(["gateway", "run"]) - # `pythonw.exe` is a GUI-subsystem executable: cmd.exe launches it and - # returns immediately, so the Scheduled Task action finishes without a - # visible console window. Do NOT use `start` here; that creates an extra - # wrapper process and made gateway lifecycle/status harder to reason about. + # Do NOT use `start` here; that creates an extra wrapper process and made + # gateway lifecycle/status harder to reason about. # Do NOT use `--replace` for service-managed starts; repeated /Run calls # should be idempotent, not churn parent/child takeover loops. lines.append(" ".join(_quote_cmd_script_arg(a) for a in prog_args)) @@ -443,7 +451,7 @@ def _build_gateway_vbs_script( hermes_home: str, profile_arg: str, ) -> str: - """Build a console-less ``gateway.vbs`` launcher (CRLF-terminated). + """Build a hidden-console ``gateway.vbs`` launcher (CRLF-terminated). The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``. @@ -454,15 +462,20 @@ def _build_gateway_vbs_script( code as a user cancel, so the ``RestartOnFailure`` policy never fires and the gateway silently disappears on every reboot. - ``wscript.exe`` and ``pythonw.exe`` are both GUI-subsystem executables with - no console, so this launcher receives no console control events. It mirrors - ``_build_gateway_cmd_script`` (same env + argv via ``_resolve_detached_python``) - but sets the environment on the WScript.Shell process and ``Run``s pythonw - directly — no cmd.exe anywhere in the chain. + ``wscript.exe`` is a GUI-subsystem executable with no console, so this + launcher receives no console control events. It ``Run``s the console + ``python.exe`` with window style 0 (hidden): the gateway owns a single + hidden console — never shown, never CTRL_CLOSE'd at logon, and inherited + by every console-subsystem descendant (git, gh, node, …) so none of them + allocate a visible flashing conhost (#54220/#56747; the previous + console-less pythonw.exe gateway forced exactly that per-descendant + flash). No cmd.exe anywhere in the chain. Mirrors + ``_build_gateway_cmd_script`` (same env + argv via + ``_resolve_detached_python``). """ - pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) + python_exe_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) - prog_args = [pythonw_path, "-m", "hermes_cli.main"] + prog_args = [python_exe_path, "-m", "hermes_cli.main"] if profile_arg: prog_args.extend(profile_arg.split()) prog_args.extend(["gateway", "run"]) @@ -493,8 +506,9 @@ def _build_gateway_vbs_script( f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}", "End If", f"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}", - # Window style 0 = hidden; bWaitOnReturn False = detached/async. pythonw is - # GUI-subsystem so no console is ever created for the gateway either. + # Window style 0 = hidden; bWaitOnReturn False = detached/async. The + # console python's one console is created hidden and inherited by all + # descendants, so nothing ever flashes. f"sh.Run {_quote_vbs_string(command_line)}, 0, False", ] return "\r\n".join(lines) + "\r\n" @@ -700,60 +714,40 @@ def _install_startup_entry(script_path: Path) -> Path: return entry -def _derive_venv_pythonw(python_exe: str) -> str: - """Given a ``python.exe`` path, return the sibling ``pythonw.exe`` if present. - - ``pythonw.exe`` is the console-less variant. Using it for detached - daemons means there's no console handle to inherit from the spawning - shell, which is what lets the gateway survive a parent-shell exit on - Windows. Falls back to the original ``python.exe`` if the ``w`` variant - isn't there — caller must still set CREATE_NO_WINDOW in that case. - """ - p = Path(python_exe) - candidate = p.with_name(p.stem + "w" + p.suffix) - if candidate.exists(): - return str(candidate) - return python_exe - - -def _read_pyvenv_cfg(venv_dir: Path) -> dict[str, str]: - cfg_path = venv_dir / "pyvenv.cfg" - try: - lines = cfg_path.read_text(encoding="utf-8").splitlines() - except OSError: - return {} - parsed: dict[str, str] = {} - for raw in lines: - if "=" not in raw: - continue - key, value = raw.split("=", 1) - parsed[key.strip().lower()] = value.strip() - return parsed - - def _resolve_detached_python(python_exe: str) -> tuple[str, Path, list[str]]: - """Return (windowed_python, venv_dir, extra_pythonpath) for detached runs. + """Return (hidden_console_python, venv_dir, extra_pythonpath) for detached runs. - uv-created Windows venv launchers are special: ``venv\\Scripts\\pythonw.exe`` - starts hidden, but then respawns the base interpreter as console - ``python.exe``. That child opens a visible Windows Terminal tab. For uv - venvs, use the base ``pythonw.exe`` directly and put the repo + venv - site-packages on ``PYTHONPATH`` so imports still resolve without the venv - launcher. + Returns the venv's **console** ``python.exe`` — deliberately NOT + ``pythonw.exe``. Every detached launch path pairs this interpreter with a + hidden-console mechanism (``CREATE_NO_WINDOW`` creationflags, or + ``WScript.Shell.Run`` window style 0), so the daemon owns a single hidden + console that all of its console-subsystem descendants (git, gh, cmd, node, + wmic, powershell, …) inherit instead of each allocating a visible flashing + one. A GUI-subsystem ``pythonw.exe`` daemon has NO console, which is what + made every descendant spawn flash (#54220/#56747) and forced the endless + per-call-site CREATE_NO_WINDOW sweep. Root cause isolated + A/B verified + on Windows 11 by the desktop backend fix (commit aa2ae36c3f). + + Two historical premises behind the old pythonw selection were re-tested on + current Windows in that fix and did not hold up: + + - uv venv launcher: ``venv\\Scripts\\python.exe`` under ``CREATE_NO_WINDOW`` + re-execs the base interpreter *windowless* — the child inherits the + shim's hidden console, so no conhost flashes (the #52239 concern). The + historical "CREATE_NO_WINDOW cannot suppress the second window" + observations were made while ``DETACHED_PROCESS`` was in the flag + bundle, where MSDN specifies CREATE_NO_WINDOW is IGNORED — the hide bit + was dead, not ineffective. The base-interpreter + PYTHONPATH-overlay + detour is therefore unnecessary; the venv shim resolves imports itself. + - Console python restores stdout/stderr, so daemon logs flow normally. + + ``extra_pythonpath`` is always empty now; the tuple shape is kept so the + call sites (argv builders, cmd/vbs renderers, restart-spec rewriter, + gateway watcher) stay unchanged. """ p = Path(python_exe) venv_dir = p.parent.parent - windowed = _derive_venv_pythonw(python_exe) - - cfg = _read_pyvenv_cfg(venv_dir) - home = cfg.get("home", "") - if "uv" in cfg and home: - base_pythonw = Path(home) / "pythonw.exe" - site_packages = venv_dir / "Lib" / "site-packages" - if base_pythonw.exists() and site_packages.exists(): - return (str(base_pythonw), venv_dir, [str(site_packages)]) - - return (windowed, venv_dir, []) + return (python_exe, venv_dir, []) def _prepend_pythonpath(env_overlay: dict[str, str], entries: list[str]) -> None: @@ -812,22 +806,19 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: def windowless_gateway_restart_spec( run_argv: list[str], ) -> tuple[list[str], str, dict[str, str]]: - """Rewrite a console-``python.exe`` gateway argv into a windowless one. + """Return the (argv, cwd, env overlay) for a hidden-console gateway respawn. The post-update restart paths build their respawn command from - ``get_python_path()`` which returns the venv's console ``python.exe``. - On Windows — especially with uv-created venvs — launching that - interpreter (even with ``CREATE_NO_WINDOW``) leaves a persistent - console window: ``venv\\Scripts\\python.exe`` is a launcher shim that - re-execs the *base* console interpreter, which allocates its own - conhost. ``CREATE_NO_WINDOW`` cannot suppress that second window. - See ``_resolve_detached_python`` for the gory details. - - This mirrors what ``_build_gateway_argv`` / ``_spawn_detached`` do for - a clean start: swap the interpreter for the windowless ``pythonw.exe`` - (base interpreter for uv venvs) and return the cwd + env overlay - (VIRTUAL_ENV, PYTHONPATH) the base interpreter needs to resolve the - ``hermes_cli`` package without the venv launcher's site config. + ``get_python_path()`` (the venv's console ``python.exe``). That is the + right interpreter: the watcher launches it with ``CREATE_NO_WINDOW`` + detach flags, so the respawned gateway owns a single hidden console that + all of its descendants inherit — nothing flashes (#54220/#56747; the old + pythonw.exe rewrite here produced a console-less gateway whose every + console-subsystem child allocated a visible conhost). This helper now + only normalizes the interpreter via ``_resolve_detached_python`` and + supplies the stable cwd + env overlay (HERMES_HOME, VIRTUAL_ENV, + PYTHONPATH) so the respawn doesn't depend on the watcher's transient + working directory. Returns ``(new_argv, working_dir, env_overlay)``. ``new_argv`` preserves every argument after the interpreter (``-m hermes_cli.main @@ -846,18 +837,17 @@ def windowless_gateway_restart_spec( python_exe = run_argv[0] rest = run_argv[1:] - # Only rewrite when the leading token actually looks like a python - # interpreter we can find a windowless sibling for. If a caller passed - # something else (a captured argv whose argv[0] is already pythonw, or a - # non-python launcher), leave it alone. + # Normalize the leading interpreter token and derive the venv layout. + # If a caller passed something other than a python path (a non-python + # launcher), leave the argv alone. try: - windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python( + hidden_console_python, venv_dir, extra_pythonpath = _resolve_detached_python( python_exe ) except Exception: return run_argv, "", {} - new_argv = [windowless_python, *rest] + new_argv = [hidden_console_python, *rest] working_dir = _stable_gateway_working_dir(PROJECT_ROOT) project_root = str(PROJECT_ROOT) @@ -883,13 +873,16 @@ def windowless_gateway_restart_spec( def _spawn_detached(script_path: Path | None = None) -> int: """Launch the gateway as a fully detached background process. - We spawn ``pythonw.exe -m hermes_cli.main gateway run`` - directly — NOT through a cmd.exe shim — because on Windows a cmd.exe - child inherits the parent session's console handle and tends to get - reaped when the spawning shell exits. pythonw.exe has no console, and - combined with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | - CREATE_NO_WINDOW + DEVNULL stdio + a fresh env, the resulting process - is independent of whichever shell started it. + We spawn ``python.exe -m hermes_cli.main gateway run`` directly — NOT + through a cmd.exe shim — because on Windows a cmd.exe child inherits the + parent session's console handle and tends to get reaped when the spawning + shell exits. With ``CREATE_NO_WINDOW`` the gateway gets its OWN hidden + console instead of inheriting ours, so it survives our shell closing, and + every console-subsystem descendant it spawns inherits that hidden console + instead of flashing a visible one (#54220/#56747 — this is why we don't + use console-less pythonw.exe here). Combined with + CREATE_NEW_PROCESS_GROUP + DEVNULL stdin + a fresh env, the resulting + process is independent of whichever shell started it. Arg ``script_path`` is accepted for API symmetry with older callers but ignored — we don't need it now that we go direct. @@ -903,10 +896,12 @@ def _spawn_detached(script_path: Path | None = None) -> int: # Inherit PATH etc. from the current env, overlay our required vars. env = {**os.environ, **env_overlay} - # DETACHED_PROCESS 0x00000008 — no console attached to child # CREATE_NEW_PROCESS_GROUP 0x00000200 — child gets its own group, won't # receive Ctrl+C from our group - # CREATE_NO_WINDOW 0x08000000 — belt-and-braces no-console flag + # CREATE_NO_WINDOW 0x08000000 — child owns a hidden console: + # detached from our console's + # lifetime AND inheritable by its + # descendants (no conhost flashes) # CREATE_BREAKAWAY_FROM_JOB 0x01000000 — escape any job object the # parent is in (prevents parent- # job teardown from reaping us; @@ -940,7 +935,8 @@ def _spawn_detached(script_path: Path | None = None) -> int: # CREATE_BREAKAWAY_FROM_JOB can fail with "access denied" when the # parent's job object doesn't permit breakaway (some Windows # Terminal configs). Retry without the breakaway flag — in most - # setups pythonw.exe + DETACHED_PROCESS is enough on its own. + # setups the hidden-console CREATE_NO_WINDOW spawn is enough on + # its own. flags_no_breakaway = windows_detach_flags_without_breakaway() with open(stray_log, "ab", buffering=0) as log_fh: proc = subprocess.Popen( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9ca6125fc57f..bae5c929775c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10458,7 +10458,7 @@ def _cold_start_windows_gateway_after_update() -> None: began, but an autostart entry (Scheduled Task / Startup-folder login item) is installed, signalling the user wants a gateway. Unlike the relaunch paths — which watch an old PID and respawn once it exits — this is a direct - fresh spawn via the same windowless ``pythonw`` + breakaway path that + fresh spawn via the same hidden-console + breakaway path that ``hermes gateway start`` uses (``gateway_windows._spawn_detached``). Best-effort and idempotent: re-checks that nothing is running first so a diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f73a77bb655d..5d45e731f26b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3742,15 +3742,16 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non def _dashboard_spawn_executable() -> str: - """Prefer pythonw.exe for detached dashboard actions on Windows.""" - if sys.platform != "win32": - return sys.executable - exe = sys.executable - if exe.lower().endswith("python.exe"): - pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe") - if os.path.isfile(pythonw): - return pythonw - return exe + """Interpreter for detached dashboard actions. + + Returns ``sys.executable`` on every platform. On Windows the spawn + below carries ``windows_detach_flags()`` (CREATE_NO_WINDOW), so the + console python owns a single hidden console that its own subprocess + spawns inherit — the action stays invisible without resorting to + console-less pythonw.exe, which would make every console-subsystem + descendant flash its own conhost (#54220/#56747). + """ + return sys.executable def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 0dc217d39d94..de959b7d1813 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -354,7 +354,11 @@ async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_p @pytest.mark.asyncio -async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tmp_path): +async def test_windows_detached_restart_watcher_keeps_console_python(monkeypatch, tmp_path): + """The restart watcher must run sys.executable (console python) under the + hidden-console detach kwargs — NOT swap in GUI-subsystem pythonw.exe, + which would leave the watcher console-less and make its descendants + flash visible conhosts (#54220/#56747).""" runner, _adapter = make_restart_runner() popen_calls = [] venv_dir = tmp_path / "venv" @@ -368,17 +372,11 @@ async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tm monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) import hermes_cli._subprocess_compat as subprocess_compat - import hermes_cli.gateway_windows as gateway_windows - monkeypatch.setattr( - gateway_windows, - "_resolve_detached_python", - lambda _python: (r"C:\Python311\pythonw.exe", venv_dir, [str(site_packages)]), - ) monkeypatch.setattr( subprocess_compat, "windows_detach_popen_kwargs", - lambda: {"creationflags": 0x08000008}, + lambda: {"creationflags": 0x08000200}, ) def fake_popen(cmd, **kwargs): @@ -391,9 +389,9 @@ async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tm assert len(popen_calls) == 1 cmd, kwargs = popen_calls[0] - assert cmd[0] == r"C:\Python311\pythonw.exe" + assert cmd[0] == r"C:\venv\Scripts\python.exe" assert cmd[-3:] == ["hermes", "gateway", "restart"] - assert kwargs["creationflags"] == 0x08000008 + assert kwargs["creationflags"] == 0x08000200 # ── Shutdown notification tests ────────────────────────────────────── diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index d52ad7d59da4..6b194dd1db89 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -72,8 +72,10 @@ def test_exec_schtasks_decodes_with_replace_errors(monkeypatch): assert captured["text"] is True -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.""" +def test_build_gateway_argv_keeps_venv_console_python_for_uv_venv(monkeypatch, tmp_path): + """No pythonw / base-interpreter detour: the venv console python.exe is + launched hidden (CREATE_NO_WINDOW) so descendants inherit its hidden + console instead of flashing their own (#54220/#56747).""" project = tmp_path / "project" scripts = project / "venv" / "Scripts" @@ -105,11 +107,10 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, argv, cwd, env_overlay = gateway_windows._build_gateway_argv() - assert argv[:3] == [str(base_pythonw), "-m", "hermes_cli.main"] + assert argv[:3] == [str(venv_python), "-m", "hermes_cli.main"] assert cwd == str(hermes_home.resolve()) 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) class TestStableWindowsGatewayWorkingDir: @@ -188,12 +189,14 @@ def _arrange_startup_fallback(monkeypatch, tmp_path, running_pids): return script_path, calls -def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch): - """Scheduled Task wrapper should launch pythonw once and avoid replace loops.""" +def test_gateway_cmd_script_uses_console_python_without_replace_or_start_churn(monkeypatch): + """Scheduled Task wrapper launches the console python once (hidden by the + .vbs window-style-0 chain, NOT console-less pythonw — see #54220/#56747) + and avoids replace loops.""" monkeypatch.setattr( gateway_windows, "_resolve_detached_python", - lambda exe: (exe.replace("python.exe", "pythonw.exe"), r"C:\\Hermes\\hermes-agent\\venv", []), + lambda exe: (exe, r"C:\\Hermes\\hermes-agent\\venv", []), ) content = gateway_windows._build_gateway_cmd_script( @@ -203,15 +206,23 @@ def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypa "--profile alice", ) - assert "pythonw.exe" in content + assert "python.exe" in content + assert "pythonw.exe" not in content assert "gateway run" in content assert "--replace" not in content assert "start \"\"" not in content assert "exit /b 0" in content -def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path): - """Scheduled Task wrapper should share the detached uv-venv workaround.""" +def test_gateway_launcher_scripts_keep_console_python_for_uv_venv(monkeypatch, tmp_path): + """The launcher must NOT detour to uv's base pythonw.exe. + + The old uv-venv workaround swapped in the base ``pythonw.exe`` + + PYTHONPATH overlay. That console-less daemon made every console-subsystem + descendant allocate a visible flashing conhost (#54220/#56747). The venv + console ``python.exe`` under the hidden-console launch chain is correct — + the uv shim re-execs the base interpreter windowless because the child + inherits the shim's hidden console.""" project = tmp_path / "project" scripts = project / "venv" / "Scripts" site_packages = project / "venv" / "Lib" / "site-packages" @@ -239,14 +250,16 @@ def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path): "", ) - assert str(base_pythonw) in content + assert str(venv_python) in content assert f'set "VIRTUAL_ENV={project / "venv"}"' in content - assert str(site_packages) in content + assert str(base_pythonw) not in content assert str(venv_pythonw) not in content -def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch): - """UAC handoff should not leave a second elevated cmd.exe window open.""" +def test_elevated_gateway_command_uses_hidden_console_python(monkeypatch): + """UAC handoff launches console python with SW_HIDE — a single hidden + console, not console-less pythonw (#54220/#56747), and no visible + elevated cmd.exe window left open.""" calls = [] class FakeShell32: @@ -259,7 +272,6 @@ def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch): monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None) monkeypatch.setattr(gateway_windows, "_current_profile_cli_args", lambda: ["--profile", "alice"]) - monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe")) monkeypatch.setattr(gateway_windows.sys, "executable", r"C:\Hermes\venv\Scripts\python.exe") monkeypatch.setattr(gateway_windows.ctypes, "windll", FakeWindll(), raising=False) @@ -268,7 +280,7 @@ def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch): assert len(calls) == 1 _hwnd, verb, executable, params, cwd, show = calls[0] assert verb == "runas" - assert executable.endswith("pythonw.exe") + assert executable == r"C:\Hermes\venv\Scripts\python.exe" assert "--profile alice gateway install --start-now --elevated-handoff" in params assert show == 0 assert cwd diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 2e6606ead5b8..58bbc007a50d 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -572,13 +572,35 @@ class TestSubprocessCompatHelpers: from hermes_cli import _subprocess_compat as sc monkeypatch.setattr(sc, "IS_WINDOWS", True) flags = sc.windows_detach_flags() - # CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW | - # CREATE_BREAKAWAY_FROM_JOB + # CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | CREATE_BREAKAWAY_FROM_JOB assert flags & 0x00000200, "missing CREATE_NEW_PROCESS_GROUP" - assert flags & 0x00000008, "missing DETACHED_PROCESS" assert flags & 0x08000000, "missing CREATE_NO_WINDOW" assert flags & 0x01000000, "missing CREATE_BREAKAWAY_FROM_JOB" + def test_windows_detach_flags_exclude_detached_process(self, monkeypatch): + """DETACHED_PROCESS must stay OUT of every detach bundle. + + Two reasons (the #54220/#56747 console-flash class): + 1. MSDN: CREATE_NO_WINDOW is IGNORED when combined with + DETACHED_PROCESS — the hide bit would be dead. + 2. A console-less daemon forces every console-subsystem descendant + (git, gh, cmd, node, …) to allocate its own visible console — a + flash per spawn that no per-call-site hide sweep can fully cover. + CREATE_NO_WINDOW instead gives the daemon one hidden console that + all descendants inherit (parent-console root cause isolated by + the desktop backend fix, commit aa2ae36c3f). + """ + from hermes_cli import _subprocess_compat as sc + monkeypatch.setattr(sc, "IS_WINDOWS", True) + assert not sc.windows_detach_flags() & 0x00000008, ( + "DETACHED_PROCESS must not be in windows_detach_flags(): it makes " + "CREATE_NO_WINDOW a no-op and re-creates the per-descendant " + "console flash (#54220/#56747)." + ) + assert not sc.windows_detach_flags_without_breakaway() & 0x00000008, ( + "DETACHED_PROCESS must not be in the no-breakaway fallback either." + ) + def test_windows_detach_flags_includes_breakaway_from_job(self, monkeypatch): """CREATE_BREAKAWAY_FROM_JOB is load-bearing for the GUI-driven update path. @@ -619,9 +641,10 @@ class TestSubprocessCompatHelpers: fallback = sc.windows_detach_flags_without_breakaway() # Fallback equals full minus the breakaway bit, nothing else changed. assert fallback == full & ~0x01000000 - # And the three "detach" bits we still need are present. + # And the detach bits we still need are present (hidden console, own + # process group — NOT console-less DETACHED_PROCESS, see + # test_windows_detach_flags_exclude_detached_process). assert fallback & 0x00000200, "fallback missing CREATE_NEW_PROCESS_GROUP" - assert fallback & 0x00000008, "fallback missing DETACHED_PROCESS" assert fallback & 0x08000000, "fallback missing CREATE_NO_WINDOW" @@ -1010,52 +1033,49 @@ class TestGatewayDetachedWatcherWindowsFlags: "matching gateway_windows._spawn_detached's fallback pattern." ) - def test_watcher_rewrites_console_python_to_windowless(self): - """The post-update respawn must NOT relaunch the gateway with the - venv's console ``python.exe``. + def test_watcher_threads_hidden_console_spec_into_respawn(self): + """The post-update respawn must route through + ``gateway_windows.windowless_gateway_restart_spec``. - Regression for the "terminal window stays open permanently after a - GUI update" report: ``_gateway_run_args_for_profile`` builds the - respawn argv from ``get_python_path()`` (console ``python.exe``). - On Windows, launching that interpreter — even under - CREATE_NO_WINDOW — leaves a persistent console window because uv's - venv launcher re-execs the base console interpreter. The watcher - must route the argv through - ``gateway_windows.windowless_gateway_restart_spec`` so it becomes - ``pythonw.exe`` with the cwd + PYTHONPATH overlay the base - interpreter needs. + The spec supplies the stable cwd + env overlay (HERMES_HOME, + VIRTUAL_ENV, PYTHONPATH) so the respawned gateway doesn't depend on + the watcher's transient working directory. (The interpreter itself + stays the venv's console ``python.exe``, launched hidden via + CREATE_NO_WINDOW — see the hidden-console rationale in + ``_subprocess_compat``.) Static check: the watcher build (in ``_spawn_gateway_restart_watcher``) - must invoke the rewrite helper and thread the cwd / env overlay into + must invoke the spec helper and thread the cwd / env overlay into the inlined respawn ``Popen``. """ root = Path(__file__).resolve().parents[2] text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") assert "windowless_gateway_restart_spec" in text, ( - "_spawn_gateway_restart_watcher must rewrite the respawn argv via " + "_spawn_gateway_restart_watcher must build the respawn via " "gateway_windows.windowless_gateway_restart_spec so the gateway " - "comes back as windowless pythonw.exe, not console python.exe." + "comes back with the stable cwd + env overlay." ) marker = "watcher = textwrap.dedent(" idx = text.find(marker) end = text.find(".strip()", idx) block = text[idx:end] - # The inlined respawn must apply the cwd + env overlay the base - # interpreter needs — without them the windowless pythonw can't - # import hermes_cli. + # The inlined respawn must apply the cwd + env overlay so the + # respawned gateway starts in the stable gateway working dir with + # the right venv context. assert '_popen_kwargs["cwd"]' in block, ( - "Inlined respawn must set cwd from the windowless spec so the " - "base interpreter starts in the stable gateway working dir." + "Inlined respawn must set cwd from the restart spec so the " + "gateway starts in the stable gateway working dir." ) assert '_popen_kwargs["env"]' in block, ( "Inlined respawn must overlay env (VIRTUAL_ENV / PYTHONPATH / " - "HERMES_HOME) so the windowless base pythonw resolves hermes_cli." + "HERMES_HOME) from the restart spec." ) class TestWindowlessGatewayRestartSpec: - """gateway_windows.windowless_gateway_restart_spec — the helper that - converts a console-python gateway argv into a windowless pythonw one.""" + """gateway_windows.windowless_gateway_restart_spec — supplies the + hidden-console respawn spec (normalized interpreter + stable cwd + env + overlay).""" def test_noop_on_non_windows(self): import hermes_cli.gateway_windows as gw @@ -1075,9 +1095,10 @@ class TestWindowlessGatewayRestartSpec: assert cwd == "" assert env == {} - def test_windows_rewrites_to_pythonw_and_preserves_tail(self): - """On Windows the interpreter is swapped for its windowless sibling - while every subsequent argument is preserved verbatim.""" + def test_windows_keeps_console_python_and_preserves_tail(self): + """On Windows the console interpreter is kept (hidden-console launch, + NOT a pythonw swap — #54220/#56747) while every subsequent argument + is preserved verbatim.""" import hermes_cli.gateway_windows as gw # Pre-import on the (Linux) host so the function's lazy @@ -1100,25 +1121,21 @@ class TestWindowlessGatewayRestartSpec: "--replace", ] - def fake_resolve(python_exe): - return ("C:/base/pythonw.exe", Path("C:/venv"), ["C:/venv/Lib/site-packages"]) - # Mock get_hermes_home too: the real one calls Path.resolve(), which # consults sysconfig and raises ModuleNotFoundError under the win32 # platform patch on a Linux host. with mock.patch.object(gw.sys, "platform", "win32"), mock.patch.object( - gw, "_resolve_detached_python", side_effect=fake_resolve - ), mock.patch.object( gw, "_stable_gateway_working_dir", return_value="C:/hermes" ), mock.patch( "hermes_cli.config.get_hermes_home", return_value="C:/hermes" ): new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv)) - assert new_argv[0] == "C:/base/pythonw.exe" + # Interpreter is kept as the console python — hidden-console launch, + # no pythonw swap. + assert new_argv[0] == "C:/venv/Scripts/python.exe" # Everything after the interpreter is byte-for-byte preserved. assert new_argv[1:] == argv[1:] assert cwd == "C:/hermes" assert env["VIRTUAL_ENV"] == str(Path("C:/venv")) assert "PYTHONPATH" in env - assert "site-packages" in env["PYTHONPATH"]