fix(windows): hidden-console daemons — extend the parent-console fix to every detached spawn path (#70205)

Extends the desktop backend's root-cause fix (aa2ae36c3f) to all remaining
console-less parent launch paths. The Windows console-flash class
(#54220/#56747) is governed by the PARENT's console: a DETACHED_PROCESS or
pythonw.exe daemon has no console, so every console-subsystem descendant
(git, gh, cmd, node, wmic, powershell) allocates its own visible conhost —
one flash per spawn, unreachable by any per-call-site CREATE_NO_WINDOW
sweep. Worse, MSDN specifies CREATE_NO_WINDOW is IGNORED when combined
with DETACHED_PROCESS, so the hide bit in the old detach bundle was dead.

Changes:
- _subprocess_compat: drop DETACHED_PROCESS from windows_detach_flags()
  and windows_detach_flags_without_breakaway(); the daemon now owns a
  single hidden console (CREATE_NO_WINDOW) that all descendants inherit.
- gateway_windows: _resolve_detached_python() returns the venv console
  python.exe (no pythonw/base-interpreter detour — the uv-shim flash
  premise only held while DETACHED_PROCESS was masking the hide bit);
  UAC handoff launches console python under SW_HIDE; cmd/vbs launchers
  render console python (vbs runs it window-style 0).
- gateway/run.py: restart watcher keeps sys.executable instead of
  swapping in GUI-subsystem pythonw.
- web_server: dashboard actions spawn sys.executable (already carries
  windows_detach_flags()).

Tests updated to pin the new invariants, including an explicit
DETACHED_PROCESS-must-stay-out regression guard.
This commit is contained in:
Teknium 2026-07-23 11:13:14 -07:00 committed by GitHub
parent d9165d7a67
commit 0dbf639bc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 259 additions and 218 deletions

View file

@ -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():

View file

@ -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

View file

@ -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":

View file

@ -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(

View file

@ -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

View file

@ -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:

View file

@ -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 ──────────────────────────────────────

View file

@ -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

View file

@ -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"]