From 03156afc7b2f4f5028a7c3cba06df4e301fde452 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 26 Sep 2026 23:04:53 +0800 Subject: [PATCH] fix(cron): degrade to the caller's interpreter when selected_venv raises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _posix_cron_python_invocation called selected_venv(repo) unguarded; the four RuntimeError cases it is documented to raise would escape through _script_argv (which runs before _run_job_script's try), crash the tick, and leave the execution row in running forever — the exact half-migrated install the test docstring already claimed was supported. Mirror the Windows bootstrap's degrade-don't-crash contract: warn and run on the caller's interpreter. Adds the raising-selection case to the POSIX invocation test (red on the previous head), a sealed-payload caveat on the e2e test's hand-written .pth, and a pointer from the Windows invocation docstring to its POSIX counterpart. (cherry picked from commit 8d5b3b5e9727d612d91b2a7485e0f4bb245358f3) --- cron/scheduler_script.py | 27 +++++++++++++++++++++++---- tests/cron/test_cron_script.py | 21 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index 6b7ce22f80..843be875b0 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -126,7 +126,10 @@ def _posix_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str]] and crash (#123440). Lazy installs are disabled for script children so a script importing ``hermes_bootstrap`` off the store-record venv cannot republish launchers (#123440). Installs without a committed store (source checkouts, pre-PM venvs) keep the caller's - interpreter.""" + interpreter. A broken committed selection degrades to the caller's interpreter too: + ``selected_venv`` is documented as a raising function, and this runs before + ``_run_job_script``'s ``try``, so an escaping error would crash the tick and leave the + execution row in ``running`` forever.""" from hermes_cli._launchers import resolve_store_python repo = Path(__file__).resolve().parents[1] @@ -135,7 +138,18 @@ def _posix_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str]] from pm.environments import selected_venv, venv_bin_dir - venv_python = venv_bin_dir(selected_venv(repo)) / "python" + try: + venv = selected_venv(repo) + except (RuntimeError, OSError) as exc: + # Degrade, don't crash — same contract as the Windows bootstrap's unresolvable-venv + # fallback below (a silent fallback would make the misconfiguration undiagnosable). + logger.warning( + "POSIX cron script: cannot select the dependency venv (%s); running on the " + "caller's interpreter", + exc, + ) + return python_exe, {} + venv_python = venv_bin_dir(venv) / "python" if not venv_python.is_file(): return python_exe, {} return str(venv_python), {"HERMES_DISABLE_LAZY_INSTALLS": "1"} @@ -144,7 +158,9 @@ def _posix_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str]] def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str]]: """Hidden, output-capable Python invocation for Windows cron scripts. ``pythonw.exe`` loses captured output; uv venv launchers can re-exec the base console python and flash a window - even with CREATE_NO_WINDOW, so run the base python directly with venv paths overlaid in env.""" + even with CREATE_NO_WINDOW, so run the base python directly with venv paths overlaid in env. + Off-Windows callers are routed to ``_posix_cron_python_invocation`` (venv interpreter + selection, no env overlay).""" if sys.platform != "win32": return _posix_cron_python_invocation(python_exe) @@ -362,7 +378,10 @@ def _script_argv(path: Path) -> tuple[Optional[list[str]], dict[str, str], Optio python_exe, env_overlay = _windows_cron_python_invocation(sys.executable) if env_overlay.get("PYTHONPATH"): # The bootstrap exists to give PYTHONPATH overlays .pth processing (editable installs); - # non-PYTHONPATH overlays (the POSIX venv-interpreter path) pass through plain. + # non-PYTHONPATH overlays (the POSIX venv-interpreter path) pass through plain. Both + # overlay producers that exist today always set PYTHONPATH (the managed-store branch + # sets only it, the uv re-exec sets it alongside VIRTUAL_ENV); a future overlay-only + # producer must revisit this gate or it silently loses .pth processing. return _windows_cron_bootstrap_argv(python_exe, env_overlay, str(path)), env_overlay, None return [python_exe, str(path)], env_overlay, None diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 708738bfc9..10621f718f 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -313,7 +313,9 @@ class TestRunJobScript: inherited overlay makes foreign-interpreter children import the store's 3.14 extension modules first (#123440). No committed store → the caller's interpreter passes through untouched (pre-PM venvs, source checkouts), as does a store whose venv python - vanished (half-migrated install must not crash the scheduler).""" + vanished (half-migrated install must not crash the scheduler) or whose committed + selection record is broken (``selected_venv`` raises → degrade, not propagate — the + call site runs before ``_run_job_script``'s ``try``).""" from cron import scheduler_script venv = tmp_path / "selected-venv" / "bin" @@ -337,6 +339,17 @@ class TestRunJobScript: {}, ) + def _broken_selection(repo): + raise RuntimeError( + "dependency environment is missing or outside this install" + ) + + monkeypatch.setattr("pm.environments.selected_venv", _broken_selection) + assert scheduler_script._posix_cron_python_invocation(sys.executable) == ( + sys.executable, + {}, + ) + monkeypatch.setattr( "hermes_cli._launchers.resolve_store_python", lambda repo: None ) @@ -398,7 +411,11 @@ class TestRunJobScript: deps = dependency_site(fake_venv) deps.mkdir(parents=True) (deps / "probe_pkg.py").write_text("VALUE = 42\n", encoding="utf-8") - # Editable-style repo exposure, as a real PM venv carries for the checkout. + # Editable-style repo exposure, as a real PM venv carries for the checkout. Caveat: + # a real generation venv gets its repo pointer from uv's editable install of the + # generated workspace (not a hand-written .pth), and sealed-payload installs prune + # editable .pth files outright because the payload wires the repo snapshot itself + # (pm/environment.py prune_site_pth) — do not generalize this .pth shape to payloads. repo = Path(__file__).resolve().parents[2] (deps / "zz_repo.pth").write_text(f"{repo}\n", encoding="utf-8")