mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
d9165d7a67
commit
0dbf639bc8
9 changed files with 259 additions and 218 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue