* 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>
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
"""Tests for the strict gateway command-line matcher.
|
|
|
|
Regression guard for the Windows ``hermes gateway restart`` silent-outage bug:
|
|
the previous loose substring match (``"... gateway" in cmdline``) false-matched
|
|
``gateway status``/``dashboard`` siblings and unrelated processes such as
|
|
``python -m tui_gateway``, which let ``restart()`` race a still-draining old
|
|
process and ``status``/``start`` report false positives.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from gateway.status import (
|
|
gateway_spawn_intent_subcommand as spawn_intent,
|
|
looks_like_gateway_command_line as matches,
|
|
looks_like_gateway_runtime_command_line as matches_runtime,
|
|
)
|
|
|
|
|
|
ACCEPT = [
|
|
"pythonw.exe -m hermes_cli.main gateway run",
|
|
r"C:\Users\me\hermes\venv\Scripts\pythonw.exe -m hermes_cli.main gateway run",
|
|
"python -m hermes_cli.main --profile work gateway run",
|
|
"python -m hermes_cli.main gateway run --replace",
|
|
"python -m hermes_cli/main.py gateway run",
|
|
"python gateway/run.py",
|
|
"hermes-gateway.exe",
|
|
"hermes gateway", # bare `hermes gateway` defaults to run
|
|
"hermes gateway run",
|
|
# profile selector AFTER the `gateway` token (argv is profile-position
|
|
# agnostic — _apply_profile_override strips --profile/-p anywhere)
|
|
"hermes gateway --profile work run",
|
|
"python -m hermes_cli.main gateway -p work run",
|
|
"hermes gateway --profile=work run",
|
|
# a profile literally NAMED "gateway"
|
|
"hermes -p gateway gateway run",
|
|
"python -m hermes_cli.main --profile gateway gateway run",
|
|
# quoted Windows paths with spaces (shlex-aware tokenization)
|
|
r'"C:\Program Files\Hermes\hermes-gateway.exe"',
|
|
r'"C:\Program Files\Hermes\gateway\run.py" run',
|
|
r'"C:\Program Files\Py\pythonw.exe" -m hermes_cli.main gateway run',
|
|
]
|
|
|
|
REJECT = [
|
|
"python -m tui_gateway", # unrelated module
|
|
"python -m hermes_cli.main gateway status", # other subcommand
|
|
"python -m hermes_cli.main gateway restart",
|
|
"python -m hermes_cli.main gateway stop",
|
|
"python -m hermes_cli.main --profile x dashboard", # non-gateway subcommand
|
|
"some random python -m mygateway thing",
|
|
"",
|
|
None,
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", ACCEPT)
|
|
def test_accepts_real_gateway_run(cmd):
|
|
assert matches(cmd) is True
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", REJECT)
|
|
def test_rejects_non_gateway_run(cmd):
|
|
assert matches(cmd) is False
|
|
|
|
|
|
# ``python -c <src> <old_pid> <gateway argv…>`` — the detached restart watcher
|
|
# (hermes_cli.gateway._spawn_gateway_restart_watcher). Its trailing argv is the command it will
|
|
# spawn LATER, so reading identity off it made the updater's post-relaunch liveness poll vouch for
|
|
# the watcher instead of a gateway (#107002).
|
|
INLINE_SOURCE_REJECT = [
|
|
'python -c "import time; time.sleep(1)" 14980 python -m hermes_cli.main gateway run',
|
|
r'"C:\Users\me\hermes\venv\Scripts\python.exe" -c "import os" 14980 '
|
|
r'"C:\Users\me\hermes\venv\Scripts\python.exe" -m hermes_cli.main gateway run',
|
|
'python -u -c "import os" 14980 python -m hermes_cli.main --profile work gateway run',
|
|
'python -uc "import os" 14980 hermes gateway run',
|
|
# Options that take a SEPARATE operand must not end the option walk before ``-c`` (the operand
|
|
# is not the start of the program's own argv). The repo itself spawns ``-I -S -B -X utf8 …``
|
|
# (hermes_cli/_old_updater.py, _update_takeover.py), so this shape is not hypothetical.
|
|
'python -X utf8 -c "import os" 14980 python -m hermes_cli.main gateway run',
|
|
'python -W ignore -c "import os" 14980 python -m hermes_cli.main gateway run',
|
|
'python --check-hash-based-pycs always -c "import os" 14980 hermes gateway run',
|
|
'python -I -S -B -X utf8 -c "import os" 14980 python -m hermes_cli.main gateway run',
|
|
# ``-q`` (quiet) takes NO operand, unlike ``-Q``; a case-folded walk would skip past the ``-c``.
|
|
'python -q -c "import os" 14980 python -m hermes_cli.main gateway run',
|
|
]
|
|
|
|
|
|
# Real gateways whose interpreter carries operand-taking options must STILL be recognised — the
|
|
# value-aware walk must not over-reject. Mirror image of INLINE_SOURCE_REJECT.
|
|
INTERPRETER_OPTION_ACCEPT = [
|
|
"python -X utf8 -m hermes_cli.main gateway run",
|
|
"python -W ignore -m hermes_cli.main gateway run",
|
|
"python -q -m hermes_cli.main gateway run",
|
|
"python -I -S -B -X utf8 -m hermes_cli.main gateway run",
|
|
"python --check-hash-based-pycs always -m hermes_cli.main gateway run",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", INTERPRETER_OPTION_ACCEPT)
|
|
def test_accepts_gateway_behind_operand_taking_interpreter_options(cmd):
|
|
assert matches(cmd) is True
|
|
|
|
|
|
# The repo's own non-gateway ``-X utf8`` spawn shapes must stay unmatched.
|
|
@pytest.mark.parametrize(
|
|
"cmd",
|
|
[
|
|
"python -I -S -B -X utf8 /tmp/update_takeover.py",
|
|
"python -X utf8 -E script.py",
|
|
],
|
|
)
|
|
def test_operand_taking_options_do_not_manufacture_a_gateway(cmd):
|
|
assert matches(cmd) is False
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", INLINE_SOURCE_REJECT)
|
|
def test_rejects_interpreter_running_inline_source(cmd):
|
|
assert matches(cmd) is False
|
|
assert matches_runtime(cmd) is False
|
|
|
|
|
|
# Spawn INTENT is the mirror image of process identity: the same wrapper that must not be read as a
|
|
# live gateway MUST still be recognised as "launching this eventually produces a gateway runtime".
|
|
# tests/_fixtures/live_system_guard.py relies on it — without this, the autouse guard stopped
|
|
# blocking the detached restart watcher and real gateways leaked out of the test run.
|
|
@pytest.mark.parametrize("cmd", INLINE_SOURCE_REJECT)
|
|
def test_spawn_intent_sees_through_the_inline_source_wrapper(cmd):
|
|
assert spawn_intent(cmd) == "run"
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", ACCEPT)
|
|
def test_spawn_intent_matches_plain_gateway_run(cmd):
|
|
assert spawn_intent(cmd) == "run"
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", REJECT)
|
|
def test_spawn_intent_rejects_non_gateway_commands(cmd):
|
|
assert spawn_intent(cmd) != "run"
|
|
|
|
|
|
def test_spawn_intent_keeps_read_only_subcommands_spawnable():
|
|
"""The guard only blocks run/start/restart; a ``-c``-wrapped ``gateway status`` must stay
|
|
launchable (tests/test_live_system_guard_self_test.py asserts it passes through)."""
|
|
cmd = 'python -c "import sys; print(sys.argv[1:])" -m hermes_cli.main gateway status'
|
|
assert spawn_intent(cmd) == "status"
|
|
|
|
|
|
def test_spawn_intent_ignores_inline_source_without_a_gateway_argv():
|
|
assert spawn_intent('python -c "import time; time.sleep(1)" 14980') is None
|
|
|
|
|
|
# Atomic Hermes' bundled desktop runner (regression for #22418): it shares
|
|
# HERMES_HOME with the CLI and must be recognised as a gateway so
|
|
# ``gateway run --replace`` enters the replace/lock-handoff path instead of
|
|
# colliding with the desktop runner's still-held scoped locks.
|
|
ATOMIC_DESKTOP = (
|
|
"/Applications/Atomic Hermes.app/Contents/Resources/python-server/python "
|
|
"/Applications/Atomic Hermes.app/Contents/Resources/python-server/desktop-gateway.py"
|
|
)
|
|
|
|
|
|
def test_accepts_atomic_desktop_gateway():
|
|
assert matches(ATOMIC_DESKTOP) is True
|
|
assert matches_runtime(ATOMIC_DESKTOP) is True
|
|
|
|
|