Conversation
Correct diagnosis and clean fix: distinguishing the structural self-lock (Windows maps the executable of every running process, so the updater inside the venv it must rename can never succeed, retries included) from the transient locks the generic scan handles — and deferring before provisioning so no orphan candidate generation leaks — is exactly right. The escape-hatch guidance honestly avoids promising retries will help, POSIX is properly no-op'd, and the test matrix covers defer-before-provision, fail-open for outside interpreters, the platform gate, and the
|
|
Thanks for the review — all three points addressed. 1. Assertion-message idiom — fixed.
Unforced, the test passes as before: 2. psutil unconditional import — agreed, left as-is. It is the module's only psutil import and it sits inside the existing fail-open 3. Escape-hatch Thanks again for the careful read. |
…venv On Windows, hermes update launched from the install's own venv can never complete the managed-runtime repair: Windows keeps the image of the updater's venv\Scripts\python.exe (and the waiting hermes.exe launcher ancestor) mapped until exit, so the park rename in _cut_over_candidate always fails with ERROR_ACCESS_DENIED. The pre-flight holder scan deliberately excludes the calling process and its ancestors, so the guard passes and the repair burns its retries on a structurally unwinnable rename - forever, since the failure is non-fatal and the 'next update will retry' message is misleading for this case. Detect the self-lock (sys.executable or a launcher ancestor inside the live venv) before provisioning and defer with actionable guidance instead of walking into the doomed cutover. The deferral happens pre-provisioning, so the incomplete generation-* leftovers the reporter observed are no longer produced. No-op off Windows: POSIX renames work while the updater maps the tree. Mirrors the existing _defer_update_for_self_lock pattern. Regression tests prove the fix bites: neutralized guard -> repair proceeds to provisioning (red); restored guard -> deferred before provisioning (green). Verified on Windows 11 against a real venv. Closes NousResearch#93032
The previous form was `mock_install.assert_not_called(), ("msg")` — a bare
tuple expression whose parenthetical never surfaces as a failure message.
Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic
actually appears when the guard regresses (review feedback on NousResearch#93163).
461415c to
0d4a63a
Compare
|
Salvaged onto current Extra proof added on top: the on-demand windows-latest lane ( Checked overlap with merged #99525: that PR handles the Desktop updater resume side ( This PR will be closed with credit once #99711 merges. |
The previous form was `mock_install.assert_not_called(), ("msg")` — a bare
tuple expression whose parenthetical never surfaces as a failure message.
Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic
actually appears when the guard regresses (review feedback on #93163).
The previous form was `mock_install.assert_not_called(), ("msg")` — a bare
tuple expression whose parenthetical never surfaces as a failure message.
Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic
actually appears when the guard regresses (review feedback on #93163).
|
Merged via PR #99711 — your two commits were cherry-picked onto current main with your authorship preserved in git log, plus the on-demand windows-latest lane executed your regression suite green (run 33431300968). Thanks for the precise structural-lock diagnosis and fix! |
The previous form was `mock_install.assert_not_called(), ("msg")` — a bare
tuple expression whose parenthetical never surfaces as a failure message.
Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic
actually appears when the guard regresses (review feedback on NousResearch#93163).
The previous form was `mock_install.assert_not_called(), ("msg")` — a bare
tuple expression whose parenthetical never surfaces as a failure message.
Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic
actually appears when the guard regresses (review feedback on NousResearch#93163).
What does this PR do?
On Windows,
hermes updatelaunched from the install's own venv (the standard layout) can never complete the managed-runtime repair. The cutover must renamevenv→venv.stale.runtime-<token>, but Windows refuses to rename a directory containing an executable mapped by a running process — and the updater itself is executingvenv\Scripts\python.exe(with thevenv\Scripts\hermes.exelauncher still mapped as its parent). The pre-flight holder scan (_windows_runtime_holders→_detect_venv_python_processes) deliberately excludes the calling process and its ancestors, so the guard reports "no holders", the repair provisions a whole new runtime generation, and then_rename_with_retryburns its 5 retries against a lock that cannot be released while the updater lives. The failure is non-fatal, sohermes updateretries forever without converging — while the misleading "The nexthermes updatewill retry" text implies it will.This PR adds a Windows self-lock pre-flight that is checked before provisioning:
_windows_runtime_self_lock(live)inhermes_cli/managed_uv.pydetects the one holder the generic scan is blind to — the calling process itself (sys.executableunder the live venv) or a launcher ancestor (venv\Scripts\hermes.exe) started from the venv.repair_vulnerable_runtimedefers with an actionable message instead of staging a candidate for a doomed cutover: it names the mapped executable, explains that retrying from inside the venv cannot converge, and points at the escape hatch (run the updater from an interpreter outside the venv).skipped(like the existing holders guard), so no caller contract changes, and happens before provisioning — the incompletegeneration-*leftovers the reporter observed are no longer produced.This mirrors the existing
_defer_update_for_self_lock()pattern used by the dependency-sync path, which already bails out cleanly when the updater itself holds the lock.Root cause
repair_vulnerable_runtime()provisions a fixed runtime, then_cut_over_candidate()parks the live venv via_rename_with_retry(live, backup)— anyOSErrorbecomes"could not park the existing venv".hermes updateruns fromvenv\Scripts\python.exe/hermes.exe, so the rename always fails withERROR_ACCESS_DENIEDand the retries can never help while the process lives._windows_runtime_holders()delegates to_detect_venv_python_processes(), which explicitly excludes the calling process and its ancestors (skip.add(os.getpid())) — correct for the dependency-sync path (a fresh child process doesn't map the.pydfiles), but it makes the guard blind to exactly the holder that matters for a whole-venv rename.hermes.exebefore install, detect venv python workers) cover only the dependency-sync path. The runtime-repair cutover had no equivalent self-lock handling.Evidence
sys.executablepointed inside the live venv and no other holders, the pre-fix repair walks straight into provisioning and cutover (regression test was red against the pre-fix code — see the sabotage check below), matching the reporter'scould not park the existing venv: [WinError 5]and the two observed consecutive failures._windows_runtime_self_lock(<our real venv>)→Truewith detail naming...\Scripts\python.exe; an unrelated venv →False.self_locked, self_detail = False, ""),test_self_lock_defers_repair_before_provisioningFAILS (repair proceeds to provisioning); with the fix restored it passes — the regression test bites.venv.rollback-bak-*from the report is not produced by any current code (grep: zero references; leftover of older updater releases), and the incompletegeneration-*class stops being produced by this bug because the deferral happens before provisioning. No cleanup sweep added — out of scope.Test plan
TestWindowsRuntimeSelfLock(4 tests, host-independent via mocks, run on Windows locally and on POSIX CI):skippeddeferral, no provisioning, no.hermes-runtimedir, output has actionable guidance and no "will retry";venv\Scriptslauncher ancestor is detected as a self-lock.pytest tests/hermes_cli/test_managed_uv.py— 26 passed; the 7 failures on this Windows host are pre-existing Windows-layout failures of POSIX-oriented tests (verified identical at origin/main, untouched by this diff).pytest tests/hermes_cli/test_scan_venv_blockers.py tests/hermes_cli/test_update_orphan_backend_reap.py— 47 passed.pytest tests/hermes_cli/test_update_self_lock.py tests/hermes_cli/test_update_shim_self_lock.py tests/hermes_cli/test_update_venv_health.py tests/hermes_cli/test_update_secret_import_lock.py— 55 passed.ruff checkon both files — clean.Type of Change
Related
pyvenv.cfginstead of renaming; this PR's deferral is complementary: it stops the doomed rename and gives honest guidance, while fix(update): repoint Windows managed runtime without renaming live venv #88836 would actually complete the repair on Windows)Review round 1 — Enough1122 feedback (2026-08-24)
test_self_lock_defers_repair_before_provisioningusedmock_install.assert_not_called(), ("…"): a bare tuple expression whose parenthetical never becomes a failure message. Nowassert mock_install.call_count == 0, ("a self-locked updater must not provision a candidate it can never cut over"). Verified both directions with the guard forced to fail: the old form raises only the bare mock error (Expected '_install_safe_python_generation' to not have been called. Called 1 times.), the new form surfaces the message. Unforced, the test passes as before.try/except Exceptionwrapper (managed_uv.py:1078-1097), so a missing psutil skips the ancestor scan instead of failing the repair. Left as-is.cd {root}— no change, verified. The printedrootisPath(project_root) if project_root is not None else _PROJECT_ROOT, with_PROJECT_ROOT = Path(__file__).resolve().parents[1]— the checkout containinghermes_cli/, not the HERMES_HOME data dir. Neither production call site (managed_uv.py:232,:369) passesproject_root, so the printed directory always containshermes_cli/.hermes_cli/main.pyregisters theupdatesubcommand and runsmain()underif __name__ == "__main__", so<system Python> -m hermes_cli.main updateresolves the package from the printed cwd. Guidance stands as written.Closes #93032