* fix(update): stop reading gateway identity off the restart watcher's argv (#107002) The detached restart watcher is spawned as `python -c <watcher source> <old_pid> <python> -m hermes_cli.main gateway run`. Its trailing argv is the command it will spawn LATER, but the canonical matchers read identity straight off the joined command line, so the watcher itself was classified as a live `gateway run` process — the documented "never infer process identity from argv substrings" bug class, on the exact surface `hermes update` uses to verify a post-update relaunch. Also budget the post-relaunch liveness poll against the watcher's own deadline: the watcher respawns the gateway only after the PID it was handed exits, so a 30 s window can expire before the relaunch it verifies was scheduled to start. * test(windows-live): run the gateway-ancestor harness parent from a script file A `python -c <src>` parent is an interpreter running inline source and carries no readable Hermes identity, so it is no longer a gateway to any classifier — the harness's own comment already said a realistic gateway argv is not a -c blob. * test(windows-live): share one sleeper SCRIPT across the live process-topology fixtures Four live Windows E2E files stood processes up as `python -c "sleep" <hermes argv tail>`. That shape no longer carries a readable Hermes identity, so the fixtures stopped standing in for the gateways they simulate. One shared sleeper script replaces the -c spelling. * fix(gateway): drop the duplicated _INLINE_SOURCE_FLAG_RE definition The constant was emitted twice around command_line_runs_inline_source. Same pattern both times, so behaviour is unchanged — but one definition is enough. * test(stderr-timestamp): run the gateway-lookalike children from script files Both lookalikes stood a gateway child up as `python -c <src> <gateway tail>`. That shape no longer carries a readable Hermes identity (#107002), so the wrapper correctly stopped treating them as gateway spawns and the tests failed. A `-c` tail is data for a program the inline source may spawn LATER, never the child's own identity — the real wrapper child is `python -m hermes_cli.main gateway run`, which has no `-c`. Running the stand-ins from a real script file restores what the tests mean to assert without depending on the misread. * test(windows-live): restore the tempfile import dropped with the local sleeper helper * test(windows-live): wait on the sleeper SCRIPT name, not its source text The live fixtures proved argv visibility by waiting for `time.sleep(120)` in the spawned process's command line. That string only ever appeared there because the sleeper was spelled `python -c "import time; time.sleep(120)"`; now that it runs from a file the source is in the file, so the probe timed out ("sleeper argv never visible") even though the argv was perfectly visible. Wait on the script name instead, exported as SLEEPER_MARKER next to the script so the probe and the spelling cannot drift apart again. * fix(tests,gateway): keep the live-system guard blocking -c-wrapped gateway spawns The #107002 identity fix made _gateway_command_subcommand return None for 'python -c <src> … -m hermes_cli.main gateway run'. tests/_fixtures/live_system_guard.py shares that matcher, so the autouse guard stopped blocking the detached restart watcher: real gateways leaked out of the e2e run and squatted the webhook port. Add gateway.status.gateway_spawn_intent_subcommand — the spawn-intent mirror of the identity matcher, peeling the inline-source wrapper token-wise and re-running the same canonical matcher on each suffix (still no substring matching) — and point the guard at it. Read-only subcommands stay spawnable. * fix(gateway): make the inline-source option walk value-aware so -X utf8 -c is not read as a gateway The walk that decides whether a command line is an interpreter running inline source (`python -c <src> ...`) treated every token starting with `-` as a flag and the first non-flag token as the end of the option block. CPython options that take a SEPARATE operand (-X/-W/-Q, --check-hash-based-pycs, --jit) break that model: the operand was mistaken for the end of the block, so the walk never reached the -c behind it and the watcher was read as a live gateway again -- exactly the #107002 misclassification, one shape further out. - Reuse the canonical operand sets from hermes_state_holders rather than hand-rolling a second copy (AGENTS.md: parser-derived flag sets). - Walk case-preserving tokens: operand-taking -Q/-W/-X must not be conflated with operand-less -q/-b, so callers no longer lowercase before the walk. - Handle clustered short options precisely (-uc is inline source, -Xc is -X c). - Replace the ad-hoc _INLINE_SOURCE_FLAG_RE rescan in gateway_spawn_intent_subcommand with the index the same walk returns; the regex could not find spellings the walk accepts and would raise StopIteration. Reported by an automated review on PR #121635 and reproduced here. Refs #107002 --------- Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
204 lines
8.5 KiB
Python
204 lines
8.5 KiB
Python
"""LIVE Windows E2E for retained lifecycle holder discovery (fleet-update #91277).
|
|
|
|
Runs ONLY on a real Windows host (the on-demand ``windows-venv-e2e.yml``
|
|
lane). Spawns REAL processes with realistic Hermes argv shapes and drives
|
|
the actual detection code against the live
|
|
process table — no mocked psutil, no faked cmdlines.
|
|
|
|
Each test documents which cluster issue it probes. Tests written BEFORE
|
|
the consolidation fix intentionally pin the CORRECT behavior, so on
|
|
unfixed main the buggy ones fail — that failure on the Windows runner is
|
|
the empirical premise-check for each issue:
|
|
|
|
#78089 — full command lines with long managed-runtime interpreter paths.
|
|
#87594 — ancestor-exclusion hides the gateway from the scan when the
|
|
updater is spawned BY the gateway (/update path).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.live_process_fixtures import sleeper_script_path
|
|
|
|
pytestmark = [
|
|
pytest.mark.platforms("windows"),
|
|
# ``_spawn`` sleepers carry a "gateway run" argv tail as inert data (the guard's real-gateway
|
|
# spawn check matches it); every child is ``_kill``ed by the test.
|
|
pytest.mark.spawns_gateway_lookalike,
|
|
]
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _spawn(args: list[str], cwd: Path | None = None, python: str | None = None) -> subprocess.Popen:
|
|
"""Spawn a real sleeper process whose argv carries the given tail.
|
|
|
|
``python <sleeper.py> <tail...>`` — the tail is inert data to the child
|
|
but fully visible to psutil cmdline scans, which is what the detection
|
|
code classifies on.
|
|
"""
|
|
proc = subprocess.Popen(
|
|
[python or sys.executable, sleeper_script_path(), *args],
|
|
cwd=str(cwd or PROJECT_ROOT),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
time.sleep(0.8) # let the process table settle
|
|
assert proc.poll() is None, "sleeper died at spawn"
|
|
return proc
|
|
|
|
|
|
def _detect() -> list[tuple[int, str, str]]:
|
|
from hermes_cli.update_cmd import _detect_venv_python_processes
|
|
|
|
return _detect_venv_python_processes()
|
|
|
|
|
|
def _kill(*procs: subprocess.Popen) -> None:
|
|
for proc in procs:
|
|
try:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestDetection:
|
|
def test_detects_hermes_argv_process(self):
|
|
"""Baseline: a live process running `-m hermes_cli.main serve` with
|
|
cwd under the install root is detected as a venv holder."""
|
|
proc = _spawn(["-m", "hermes_cli.main", "serve"])
|
|
try:
|
|
matches = _detect()
|
|
pids = [pid for pid, _, _ in matches]
|
|
assert proc.pid in pids, f"holder scan missed live process: {matches}"
|
|
cmdline = next(c for p, _, c in matches if p == proc.pid)
|
|
# Full cmdline, not a 120-char prefix (#78089 regression guard).
|
|
assert "hermes_cli.main" in cmdline
|
|
finally:
|
|
_kill(proc)
|
|
|
|
def test_foreign_python_not_detected(self):
|
|
"""A python process with no Hermes argv, cwd OUTSIDE the install AND an
|
|
interpreter outside the project venv must not be reported as a holder.
|
|
|
|
``sys.executable`` is the wrong sleeper here: the runner's ``uv run`` interpreter
|
|
lives in the checkout's ``.venv``, which ``project_venv_dir`` resolves since
|
|
7a94b1fbf77, so a ``sys.executable`` child IS a venv holder by design. The base
|
|
interpreter the venv was created from is the foreign python."""
|
|
|
|
from hermes_constants import project_venv_dir
|
|
|
|
base = getattr(sys, "_base_executable", None) or sys.executable
|
|
venv_dir = project_venv_dir(PROJECT_ROOT)
|
|
if venv_dir is not None and str(Path(base).resolve()).lower().startswith(
|
|
str(venv_dir.resolve()).lower()
|
|
):
|
|
pytest.skip("no interpreter outside the project venv available on this runner")
|
|
|
|
outside = Path(tempfile.mkdtemp())
|
|
proc = _spawn(["totally", "unrelated"], cwd=outside, python=base)
|
|
try:
|
|
pids = [pid for pid, _, _ in _detect()]
|
|
assert proc.pid not in pids
|
|
finally:
|
|
_kill(proc)
|
|
|
|
def test_long_runtime_path_gateway_detected_with_full_argv(self):
|
|
"""#78089: a gateway launched via a long managed-runtime interpreter
|
|
path must surface with its FULL argv so the pausable exemption can
|
|
see `gateway run` past the 120-char mark."""
|
|
# Pad the argv front so `gateway run` sits beyond 120 chars.
|
|
padding = os.path.join("C:\\", "Users", "x" * 90, ".hermes-runtime")
|
|
proc = _spawn([padding, "-m", "hermes_cli.main", "gateway", "run"])
|
|
try:
|
|
matches = _detect()
|
|
cmdline = next((c for p, _, c in matches if p == proc.pid), None)
|
|
assert cmdline is not None, "long-path gateway missed by scan"
|
|
assert "gateway run" in cmdline.lower(), (
|
|
f"argv truncated before `gateway run`: {cmdline!r}"
|
|
)
|
|
finally:
|
|
_kill(proc)
|
|
|
|
|
|
class TestAncestorExclusion:
|
|
"""#87594 — when the updater is a CHILD of the gateway (/update path),
|
|
ancestor-exclusion must not hide the gateway from the scan entirely:
|
|
the gateway must still be visible to the pause machinery."""
|
|
|
|
def test_gateway_parent_visible_to_child_scan(self, tmp_path):
|
|
# Simulate the /update topology: parent (gateway-argv process) spawns
|
|
# a child python that runs the REAL detection and reports whether it
|
|
# can see its gateway parent. The child's code lives in a FILE so the
|
|
# parent's cmdline stays realistic (a real gateway's argv is clean
|
|
# `... -m hermes_cli.main gateway run`, not a multi-line -c blob).
|
|
child_file = tmp_path / "child_scan.py"
|
|
child_file.write_text(
|
|
"import json, os, sys\n"
|
|
f"sys.path.insert(0, {str(PROJECT_ROOT)!r})\n"
|
|
"from hermes_cli.update_cmd import _detect_venv_python_processes\n"
|
|
"import psutil\n"
|
|
"from gateway.status import looks_like_gateway_command_line\n"
|
|
"# The venv shim makes every spawn a launcher/worker CHAIN, so the\n"
|
|
"# gateway is an ANCESTOR, not necessarily the direct parent —\n"
|
|
"# find it the same way the pause machinery would: by argv.\n"
|
|
"gw = [int(a.pid) for a in psutil.Process().parents()\n"
|
|
" if looks_like_gateway_command_line(' '.join(a.cmdline() or []))]\n"
|
|
"matches = _detect_venv_python_processes()\n"
|
|
"print(json.dumps({'gateway_ancestors': gw,"
|
|
" 'pids': [p for p, _, _ in matches]}))\n",
|
|
encoding="utf-8",
|
|
)
|
|
# The parent's code lives in a FILE too: a ``python -c <src>`` command line is an
|
|
# interpreter running inline source and carries no readable Hermes identity (#107002),
|
|
# so a ``-c`` parent would not be a gateway to any classifier.
|
|
parent_file = tmp_path / "parent_gateway.py"
|
|
parent_file.write_text(
|
|
"import subprocess, sys\n"
|
|
f"r = subprocess.run([sys.executable, {str(child_file)!r}],\n"
|
|
f" capture_output=True, text=True, cwd={str(PROJECT_ROOT)!r})\n"
|
|
"print(r.stdout.strip())\n"
|
|
"sys.stderr.write(r.stderr[-500:])\n",
|
|
encoding="utf-8",
|
|
)
|
|
# The parent's argv carries `gateway run` so it IS a gateway to any
|
|
# cmdline classifier; it runs the child synchronously.
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(parent_file),
|
|
"-m",
|
|
"hermes_cli.main",
|
|
"gateway",
|
|
"run",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(PROJECT_ROOT),
|
|
timeout=120,
|
|
)
|
|
import json
|
|
|
|
line = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "{}"
|
|
payload = json.loads(line)
|
|
assert payload, f"child scan produced no output: {result.stderr[-500:]}"
|
|
assert payload["gateway_ancestors"], (
|
|
f"harness broke: no gateway-argv ancestor found: {payload}"
|
|
)
|
|
# The gateway ancestor must be visible to the scan so the pause
|
|
# machinery can stop it (#87594). Blanket ancestor-exclusion hid it.
|
|
visible = set(payload["gateway_ancestors"]) & set(payload["pids"])
|
|
assert visible, (
|
|
"gateway ancestor invisible to venv scan — /update from the "
|
|
f"gateway can never pause it (#87594): {payload}"
|
|
)
|