fix(install): never strand hermes.exe when a Windows update fails - #89144
fix(install): never strand hermes.exe when a Windows update fails#89144hsearcy wants to merge 1 commit into
Conversation
Nit: — Reviewed by Hermes AI reviewer (reviewer-f2) |
On Windows the updater renames the live `hermes*.exe` shims aside (`hermes.exe.old.<unix-ms>`) so uv can write replacements. When that quarantine succeeds but the install then fails, the recovery path could leave the install with no `hermes` on PATH at all — unrecoverable in place, because the command that would repair it IS `hermes update` (NousResearch#75584). Restoring a quarantined shim happens at three sites: the updater, the early-recovery installer, and the startup sweep's orphan rescue. Each was a single un-retried rename whose OSError was swallowed in silence, while the OUTBOUND quarantine rename already retried a lock. That is backwards — a failed quarantine merely aborts an update, a failed restore removes `hermes` from PATH — and the two sites that had messages had already drifted apart. - `_early_recovery.restore_quarantined_shims()` is now the single implementation: retry ladder, one recovery message, returns the pairs it could not restore. It lives in the stdlib-only module that both `main` and `_install_repair` already import, so the layers cannot drift again. A pair is not a failure when the original reappeared or the quarantine file vanished — two processes sweeping the same orphan must not produce a spurious error. - `_cleanup_quarantined_exes` unlinked every `*.exe.old.*` on each invocation. When the original shim was already missing, that .old file was the ONLY surviving copy — deleting it converted a one-rename recovery into a full reinstall. It now rescues the orphan through the shared helper instead, and leaves anything inside a 15-minute grace window alone so it cannot destroy a concurrent update's in-flight quarantine. - Ordering is by the PARSED `.old.<unix-ms>` stamp, not the raw filename. Lexicographic ordering only tracks recency while every stamp shares a digit width; a stray `.old.999` sorts above a 13-digit epoch-ms stamp and would be the copy rescued onto the live shim name. - Names whose suffix does not parse as int-ms are not ours: never rescued, never deleted. The sweep should not destroy files whose provenance it cannot establish, and they are not produced by the quarantiner. The stamp is read from the filename rather than st_mtime because `rename` preserves the original shim's mtime, which records when uv wrote the shim — days earlier, in general — not when it was quarantined. A regression test pins that distinction. Messages go to stderr: the sweep runs on EVERY hermes invocation and `hermes acp` speaks JSON-RPC on stdout. Scope note: `_quarantine_running_hermes_exe` is deliberately byte-identical to main here. Why the outbound rename fails in the first place (the launcher holding its own image without FILE_SHARE_DELETE) is NousResearch#88121's subject; this is the net underneath, covering the case where quarantine SUCCEEDS and the install dies afterwards. The two touch disjoint functions and can merge in either order. Reproduced and verified on Windows 11 (26200), Python 3.11.15: stranded the shims, confirmed a normal `hermes` invocation now rescues the orphan instead of deleting it, and confirmed an exhausted rescue prints the recovery command. 16 new tests; 35 pass across the four quarantine suites.
72c00fc to
5cf1b2c
Compare
|
Thanks — all four points were right, and I've taken all of them plus the nit. Force-pushed: the branch is rebased onto current 1. Rescue rename had no ladder and no diagnostic — fixed. You're right that this was the worst instance of the exact bug the PR is about: the rescue fires when the shim is already gone from PATH, so giving up there strands the user just as surely as deleting the file. It now routes through the same helper as the update-time restore, so it gets the ladder and the same recovery message. Covered by 2. Lexicographic "newest first" — fixed. Confirmed: One note on the framing: I don't think this is an escalation boundary, since anything able to write 3. Unparseable → ancient → deleted — fixed, and I went further than suggested.
If you'd rather have a last-resort rescue from a foreign name when the shim is missing and there's no parseable candidate, say so and I'll add it — it's a one-line policy change. 4. Tests — added all three.
Plus helper-contract tests (returns the failed pairs; never clobbers a fresh shim). 16 tests in the file now, 35 across the four quarantine suites. Nit: shared helper — done. It went into While doing that I settled the stdout/stderr divergence you flagged: everything reports on stderr now. The sweep runs on every hermes invocation, and Verification
Regression check, patched vs. pristine Same twelve failing IDs in both runs, so nothing here regresses — they fail on an unmodified checkout ( |
|
Salvaged and merged in PR #92810 (merge 503d863) — your commit landed with authorship intact, unchanged. This was the strongest PR in the batch: the shared stdlib-only restore ladder, orphan rescue over deletion, the filename-stamp-not-mtime insight, and the .old.999 lexicographic trap were all exactly right, and your 347 lines of tests made the salvage trivial. We added an A/B repro on a real windows-latest runner as final proof: merge-base code STRANDED the shim (sweep deleted the only copy, hermes gone from PATH), your code RESCUED it in the identical scenario on the same runner — plus 30/30 across the quarantine suites on Windows and a Linux real-file E2E. Thanks for an exemplary contribution. |
What does this PR do?
On Windows the updater renames the live
hermes*.exeshims aside (hermes.exe.old.<unix-ms>) so uv can write replacements. When that quarantine succeeds but the install then fails, two gaps in the recovery path can leave the install with nohermeson PATH at all — which is unrecoverable in place, because the command that would repair it ishermes update.I hit this on a real install: all three shims were renamed aside at 22:34:33, the install died, nothing put them back, and from that point every
hermes updateand every press of the desktop app's Update button failed instantly — both shell out to a binary that no longer existed. Recovery required manually renaming the.oldfiles back.Two defects, both in the recovery path:
_restore_quarantined_exes— the safety-critical direction — got a single attempt whoseOSErrorwas swallowed in silence, while the outbound quarantine rename gets four attempts with backoff. That is backwards: a failed quarantine merely aborts an update; a failed restore removeshermesfrom PATH. The same handle that blocks the outbound rename blocks the inbound one._cleanup_quarantined_exesunconditionallyunlink()ed every*.exe.old.*on each hermes invocation. When the original shim was already missing, that.oldfile was the only surviving copy — deleting it converts a one-rename recovery into a full reinstall. It also races a concurrent in-flight update (the desktop Update button and a shellhermes updatehit this), destroying the quarantine that update's own restore was about to rename back.Scope note (please read before triaging as a duplicate)
This deliberately does not touch
_quarantine_running_hermes_exe— that function is byte-identical tomainin this branch.Why the rename fails in the first place (the launcher holding its own image without
FILE_SHARE_DELETE) is being handled by #88121, which refuses before mutation and prints thepython -m hermes_cli.main updateescape hatch. I verified that escape hatch works on my machine — it is what finally completed my update.This PR is the net underneath that: it covers the other branch of the fork, where quarantine succeeds and the install dies afterwards for an unrelated reason (network,
ENOTEMPTY, a kill). #88121 does not touch either function I change here, so the two are disjoint and can merge in either order.Related Issue
Related to #75584 — specifically the "
hermes.exeis gone fromvenv/Scripts" half of that report. Thenpm ENOTEMPTYand Playwright failures described there are separate problems and are not addressed here, so I have not marked itFixes.Type of Change
Changes Made
hermes_cli/main.py—_restore_quarantined_exesretries on the same backoff ladder as the quarantine rename and, on persistent failure, prints the literalmovecommand to recover._cleanup_quarantined_exesrescues orphaned shims instead of deleting them, and honours a 15-minute grace window for quarantines that may belong to a concurrent update.hermes_cli/_install_repair.py— same hardening for the early-recovery copy of_restore_quarantined_exes. Its warning goes to stderr, because that module runs in the early path andhermes acpspeaks JSON-RPC on stdout.tests/hermes_cli/test_quarantine_orphan_rescue.py— 8 new regression tests.One implementation detail worth flagging
The grace window keys off the
.old.<unix-ms>stamp in the filename, notst_mtime.renamepreserves the original shim's mtime, which records when uv wrote the shim — days earlier, in general — not when it was quarantined. On my install the stranded files readAug 17 17:57for a quarantine that happened at22:34. An mtime-based check would sweep a live quarantine immediately.test_cleanup_age_comes_from_filename_not_mtimepins this.How to Test
Reproduce the strand (Windows):
hermes doctor..oldfile — the shim is gone for good, reinstall required.hermes-acp.exeis present again.✖ FAILED to restore hermes.exe ...plus the exactmovecommand, instead of exiting silently with the shim renamed aside.Automated:
pytest tests/hermes_cli/test_quarantine_orphan_rescue.py \ tests/hermes_cli/test_quarantine_noop_restore.py \ tests/hermes_cli/test_quarantine_forensic_logging.py \ tests/hermes_cli/test_update_concurrent_quarantine.py -q27 passed (19 existing + 8 new).
scripts/check-windows-footguns.pyis clean on all three files.Checklist
Code
fix(scope):,feat(scope):, etc.)_restore_quarantined_exesor_cleanup_quarantined_exes(fix(update): hard-stop when Windows hermes.exe shim stays locked #68821 only calls the former; the function itself is unchanged there)Documentation & Housekeeping
docs/, docstrings) — docstrings on both changed functions explain the failure mode; no user-facing docs affectedcli-config.yaml.exampleif I added/changed config keys — N/A, no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A_is_windows()-gated; non-Windows behavior is unchangedScreenshots / Logs
The strand as it happened, from
venv/Scriptsafter a failed update — nohermes.exe, only the quarantined copies:With this change, the next
hermesinvocation renames those back instead of deleting them.