Repository navigation
Conversation
…erpreter A launcher generated from a shell whose PATH puts a newer standalone Python ahead of the venv baked that system interpreter into gateway.cmd/.vbs (get_python_path fell back to sys.executable when no PM store Python was committed). The detached gateway then spawned under 3.14 while the checkout's wheels live in its 3.11 venv: pydantic_core._pydantic_core (cp311) fails to import and the process crash-loops ~5x until the start verifier gives up — nothing inside gateway.run can recover, the imports die first. Two halves, one contract: - get_python_path() now falls back to the checkout's own venv interpreter (project_venv_dir) instead of sys.executable; the committed PM store Python still wins when recorded, so PM installs are never hijacked back onto a legacy in-tree venv. - run_gateway() re-execs under the committed interpreter before 'from gateway.run import start_gateway' binds any third-party code, repairing launchers already on disk from before this fix. The plan replays the -m hermes_cli.main argv verbatim, console-script trampolines (no -m) are left alone, and a HERMES_GATEWAY_PYTHON_REEXEC marker prevents loops. Non-Windows and no-committed-interpreter boots proceed exactly as before. Fixes NousResearch#123185
|
🤖 Hermes Agent automated review: PR reviewed. Diff analyzed (280 lines changed). Check CI status and manual review recommended. |
arkheioncorp
left a comment
There was a problem hiding this comment.
Review: fix(gateway): boot Windows gateway under the checkout's committed interpreter
Verdict: APPROVE
Findings
This fixes #123185 — Windows gateway crash-looping when launched under a newer system Python that wins the PATH race, causing ABI-mismatched wheel imports (pydantic_core._pydantic_core).
What the fix does:
get_python_path()now prefers the committed store Python, then the checkout venv interpreter, only falling back tosys.executableas last resort_reexec_gateway_under_committed_python()runs early inrun_gateway()before heavy imports bind- On Windows, if the gateway boots under the wrong interpreter, it re-execs under the committed one via
subprocess.call(Windows spawn-and-exit contract) - Guard env var
HERMES_GATEWAY_PYTHON_REEXECprevents re-execution loops
Strengths:
- The re-execution is Windows-only and early — no impact on other platforms
sys.orig_argvreplay preserves-m hermes_cli.mainshape; console-script trampolines (no-m) are left alone- Comprehensive test coverage in
test_gateway_python_reexec.pycovering: venv preference, store python precedence, reexec plan shapes, noop cases, and the full reexec path
No security or logic concerns. The fix is well-scoped and well-tested.
| store = resolve_store_python(PROJECT_ROOT) | ||
| if store is not None: | ||
| return str(store) | ||
| venv = project_venv_dir(PROJECT_ROOT) |
There was a problem hiding this comment.
[Bug] (blocking) This fallback also changes systemd and launchd, although the reported race and runtime repair are Windows-specific. With no committed PM store Python, project_venv_dir(PROJECT_ROOT) accepts any existing in-tree venv/ or .venv/ before considering the interpreter that owns the current Nix/developer runtime. That conflicts with _launchers.py and _prepare_service_launcher(), which preserve external ownership when resolve_store_python() is absent. For example, a developer can run service installation from a valid Nix Python while an old checkout venv/ directory remains; this branch silently rewrites the service to the stale venv and can make the next gateway boot fail. Please keep no-store POSIX launchers on their external interpreter and scope the legacy in-tree fallback to the Windows case it repairs.
There was a problem hiding this comment.
Scoped in b9c62e1 — the in-tree-venv fallback is now Windows-only in both get_python_path() and _committed_gateway_interpreter(). With no committed store Python, POSIX launcher generation keeps the running interpreter as the unit's owner (the _launchers.py / _prepare_service_launcher() ownership rule), so a developer installing from a Nix runtime can no longer have systemd/launchd units silently rewritten onto a stale checkout venv/. The PATH race this PR repairs is a Windows shape; POSIX keeps external ownership. Regression: test_get_python_path_posix_keeps_running_interpreter_over_stale_venv pins a Nix sys.executable winning over a present in-tree venv.
| "Gateway started under %s but this checkout's interpreter is %s; re-execing " | ||
| "(ABI-mismatched imports crash-loop otherwise, #123185)", | ||
| sys.executable, target) | ||
| sys.stdout.flush() |
There was a problem hiding this comment.
[Bug] (blocking) Legacy Windows launchers can reach this repair under pythonw.exe, where sys.stdout and sys.stderr are None. These unconditional calls then raise AttributeError before subprocess.call() starts the committed interpreter, so the recovery path fails for a launcher shape the repository still explicitly handles in gateway_windows._resolve_detached_python(). A reviewer regression with both streams set to None failed here before the child was invoked.
There was a problem hiding this comment.
Fixed in b9c62e1 — flushing moved into _flush_reexec_streams(), which skips None streams (and survives a stream that raises; flushing is best-effort, the child matters). Legacy pythonw.exe launchers with sys.stdout/sys.stderr None no longer die with AttributeError before subprocess.call() starts the committed interpreter. Regression: test_reexec_flush_tolerates_console_less_streams exercises both-None and broken-stream shapes.
| # os.execv on Windows spawns-and-exits, detaching any console wait on us; subprocess.call | ||
| # keeps the child on our console instead — matching _early_recovery.relaunch_after_restore's | ||
| # Windows exec contract (detached runs own a hidden console; foreground runs keep ours). | ||
| sys.exit(subprocess.call(argv, env=env)) |
There was a problem hiding this comment.
[Bug] (blocking) hermes_cli.main arms the startup watchdog before it imports and dispatches this command, but this parent waits for the long-lived gateway child without disarming its own handle. Only the child reaches the normal gateway disarm point. A reviewer regression observed the parent watchdog still armed at this subprocess.call() seam. After the configured startup timeout, the parent can call os._exit(75) while the child is healthy, returning a failure to a foreground caller or supervisor and breaking ownership of that gateway process.
There was a problem hiding this comment.
Fixed in b9c62e1 — _reexec_gateway_under_committed_python() now calls disarm_startup_watchdog() (the stdlib-only module's process-wide disarm) before the subprocess.call() wait, since this parent never reaches the gateway child's own disarm point inside GatewayRunner. The child re-arms through main's normal path in the new interpreter and disarms at its event loop. Regression: test_reexec_disarms_the_parent_startup_watchdog arms a real handle (arm_startup_watchdog(timeout_s=3600)), runs the guard with a stubbed child spawn, and asserts handle.disarmed is True at the spawn seam.
| # os.execv on Windows spawns-and-exits, detaching any console wait on us; subprocess.call | ||
| # keeps the child on our console instead — matching _early_recovery.relaunch_after_restore's | ||
| # Windows exec contract (detached runs own a hidden console; foreground runs keep ours). | ||
| sys.exit(subprocess.call(argv, env=env)) |
There was a problem hiding this comment.
[Bug] (non-blocking) When the stale launcher is the legacy console-less pythonw.exe form, this plain subprocess.call() starts console python.exe without the hidden-console flags used by current Windows launchers. The repository's gateway_windows._resolve_detached_python() and _spawn_detached() contract pairs console Python with CREATE_NO_WINDOW specifically to prevent visible console windows. This repair path bypasses that contract and can flash or leave a console visible during recovery.
There was a problem hiding this comment.
Fixed in b9c62e1 — the recovery child is spawned through _reexec_spawn_kwargs(), which adds CREATE_NO_WINDOW on Windows (the single hidden-console flag _resolve_detached_python()'s contract pairs with console python; no detach/new-process-group bits because this synchronous call waits on the child and forwards its exit status). A detached pythonw.exe launcher's recovery no longer flashes or leaves a console visible. Regression: test_reexec_spawn_kwargs_hide_console_on_windows_only pins the flag present with is_windows=True and absent otherwise.
…-exec parent safe Review fixes for NousResearch#123206 (ehz0ah), all four findings: - Blocking (systemd/launchd rewritten): the no-store fallback to the checkout's venv interpreter in get_python_path() and _committed_gateway_interpreter() is now Windows-only. POSIX launcher generation keeps the running Nix/developer interpreter as the unit's owner, matching _launchers.py and _prepare_service_launcher() — a stale in-tree venv/ can no longer hijack a service installed from an external runtime. The PATH race the PR repairs is a Windows shape. - Blocking (pythonw.exe None streams): stream flushing before the handoff moved into _flush_reexec_streams(), which skips None streams — legacy console-less launchers run with sys.stdout/sys.stderr None and the unconditional flush raised AttributeError before the child started. - Blocking (parent watchdog): _reexec_gateway_under_committed_python() disarms the process-wide startup watchdog before the subprocess.call() wait. hermes_cli.main arms it before dispatch and only the child ever reached the gateway's disarm point, so the parent could os._exit(75) a healthy gateway at the configured timeout. - Non-blocking (console flash): the recovery child (console python.exe under a pythonw.exe launcher) is spawned with CREATE_NO_WINDOW via _reexec_spawn_kwargs() — the hidden-console pairing _resolve_detached_python() documents, without detach bits since this call waits on the child and forwards its exit status. Platform gating is injected as data (is_windows parameters), so the decision logic tests unmarked on any host; only genuine Windows-layout resolution stays behind @pytest.mark.platforms("windows"). Tests: the split POSIX/Windows get_python_path ownership tests, watchdog-disarm regression through the real armed handle, None/broken-stream flush tolerance, and the hidden-console spawn kwargs.
Reviewed at head What I want to flag is narrower and is about the test, not the code. Two of the three test files' worth of machinery in
Unverified / please confirm:
|
The placement test called the guard helper directly, so deleting run_gateway's guard line — or sliding it below the heavy gateway.run import — left it green. The rewritten test drives run_gateway with gateway.run stubbed by a PEP 562 module that records attribute access, proving the guard fires before the import binds; the real-guard end-to-end shape stays pinned on the Windows runner.
|
Fixed in 239cca6 — point 1 addressed the way you suggested; point 2 resolved by exactly that fix. Point 1 (placement untested): Point 2 (dead monkeypatches): the four guard neutralizations are live again — Shape of the split: the placement test is host-agnostic (guard stubbed to fire, heavy module stubbed) so the ordering contract runs on every lane, not only Windows — worth having since the line numbers you cited sit in shared code. The Windows-marked test is renamed Minor 3 (fixture bin-dir): fair reading; the host-agnostic placement test removes the POSIX/Windows layout ambiguity from the load-bearing path, and the Windows-layout resolution stays covered by the windows-marked tests on a real runner. Local: |
|
Re-verified at The def __getattr__(self, name):
type(self).accessed = True
raise ImportError(f"gateway.run.{name} bound before the re-exec guard fired")That is a real improvement over the version I reviewed, which asserted on behaviour further downstream and so could pass with the guard line moved as long as nothing observable changed. Here, deleting the guard line or sliding it below the import makes the fetch happen first, 24 lines between the guard and the first heavy import, with nothing that touches Point 2 is correct and the framing matters: those four neutralizations are load-bearing setup, not dead code. Keeping the Windows-marked test for the real guard is the right division of labour. The host-agnostic test stubs the guard to fire and proves ordering; the On minor 3, your reading is the fair one and the resolution is the right shape: the placement contract is host-agnostic and no longer depends on bin-dir layout, while the POSIX/Windows layout resolution stays covered by the windows-marked tests on a real runner. Moving that coverage to where the platform matters is better than trying to make one test cover both. 9 passed + 2 windows-skips locally with the neighbouring suites at 46 passed / 11 skipped and no regressions is the right verification for a change whose entire value is in the order of two statements. Nothing outstanding from me. I am not a maintainer and my review is not maintainer approval. |
|
Leaving this open for a maintainer. #123996 fixes #122183 by keeping the pre-PM in-tree venv off My read from the triage: on a PM install, |
|
@kshitijk4poor — thanks for the follow-up. I noted that #123996 landed as |
What does this PR do?
Fixes the PATH-race half of the Windows gateway ABI crash-loop: a gateway launcher generated from a shell whose PATH puts a newer standalone Python (e.g. 3.14) ahead of the checkout's venv baked that system interpreter into
gateway.cmd/gateway.vbs, becauseget_python_path()fell back tosys.executablewhen no PM store Python was committed. The detached gateway then booted under 3.14 while every compiled wheel in the checkout lives in its 3.11 venv →ModuleNotFoundError: pydantic_core._pydantic_coreon every boot, ~5 rapid deaths, then the start verifier gives up.Two halves, one contract:
get_python_path()(hermes_cli/gateway.py) now falls back to the checkout's own venv interpreter (project_venv_dir) instead ofsys.executable. A committed PM store Python still wins when recorded, so PM installs are never hijacked back onto a legacy in-tree venv (the other half of this failure class, tracked separately in [Windows] Gateway on the managed store Python (3.14) overlays the in-tree 3.11 venv site-packages → hosted_room_worker dies on pydantic_core ABI mismatch #122736 / Windows gateway picks legacy 3.11 venv over PM-committed 3.14 venv, breaking all binary extensions #122325).run_gateway()re-execs under the committed interpreter beforefrom gateway.run import start_gatewaybinds any third-party code. This repairs launchers already on disk from before this fix without waiting for a regeneration. The re-exec plan replays the-m hermes_cli.mainargv verbatim (flags/profile args pass through), leaves console-script trampolines (no-m) alone since those already embed the right interpreter, and sets aHERMES_GATEWAY_PYTHON_REEXECmarker so the child can never loop. Non-Windows boots and installs with no resolvable committed interpreter proceed exactly as before.The guard runs at the top of
run_gateway(), which is provably ahead of the crash: the reporter's traceback showshermes_cli.gateway(and its pure-Python deps) import fine under the wrong interpreter — the failure is logged by thegateway.runlogger, i.e. duringgateway/run.py's heavy import graph, which only binds after this point.Related Issue
Fixes #123185
Type of Change
Changes Made
hermes_cli/gateway.py:get_python_path()prefers the checkout's venv interpreter oversys.executablewhen no PM store Python is committedhermes_cli/gateway.py: new_committed_gateway_interpreter()/_gateway_reexec_plan()(pure, cross-host testable) +_reexec_gateway_under_committed_python()(Windows executor) called at the top ofrun_gateway()tests/hermes_cli/test_gateway_python_reexec.py: regression tests (red on base, green with fix)How to Test
Windows host with a standalone Python newer than the checkout's pinned venv on PATH ahead of
venv\Scripts:hermes gateway restart→ "no stable gateway process detected",gateway.logshows repeatedModuleNotFoundError: pydantic_core._pydantic_core.venv\Scripts\python.exe(re-exec path), and a regeneratedgateway.cmd/.vbsembeds the venv interpreter directly (generator path).run_gateway()unchanged (guard returns immediately);./venv/bin/python3 -m pytest tests/hermes_cli/test_gateway_python_reexec.py -q→ 5 passed, 1 skipped (Windows-only executor test skips on POSIX hosts).Checklist
tests/hermes_cli/test_gateway.py,test_gateway_service.py,test_gateway_windows.py,test_old_updater_additional_shims.py(168 passed),test_update_launch_completion.py,test_source_launcher_publication.py,tests/test_managed_runtime_resolution.py(48 passed)WIP status note (first version): logic is complete and unit/regression-tested cross-host; the Windows executor path itself could not be exercised live from this macOS workstation (no Windows host available in this environment) — the repo's
wine2elive lane or a manual check on a Windows machine with a newer system Python on PATH would provide the live receipt. Everything else (plan logic, generator fallback, no-loop marker, non-Windows no-op) is covered by the added tests.