fix(utils): retry os.replace on Windows PermissionError (WinError 5) - #45022
fix(utils): retry os.replace on Windows PermissionError (WinError 5)#45022lost9999 wants to merge 1 commit into
Conversation
atomic_replace() fails with PermissionError (WinError 5) on Windows when concurrent hermes processes (gateway, dashboard, cron, TUI workers) briefly hold targets like auth.json open — for example, dashboard refreshing config while the user runs a CLI command. Wrap the os.replace call in a 6-attempt retry loop with jittered exponential backoff (base 20ms, cap 500ms, jitter ratio 0.5) using the existing agent.retry_utils.jittered_backoff helper. On POSIX the exception is re-raised immediately so behavior is unchanged. Total worst-case wait stays under 3 seconds, well below the typical auto-retry budget callers expect for a transient file lock. This is a mitigation for NousResearch#43268; the root cause there is the update flow's handle-release logic, which needs its own fix. This PR only hardens the atomic-write path so incidental cross-process locks during normal operation don't surface as user-facing errors. Tests added in tests/test_atomic_replace_symlinks.py cover the three behaviors (retry succeeds, retry exhausted, POSIX no-retry) using monkeypatch to simulate PermissionError on any platform.
|
Thanks @alt-glitch — saw the duplicate flag and #36921. Confirming both PRs target the same fix (bounded Pointing out two things that are slightly different in #45022, in case they help the maintainer decide which to merge (or how to consolidate):
Happy to close #45022 in favor of #36921 with these folded in, or keep both open — leaving the call to the maintainer. If the maintainer wants to merge #36921 and cherry-pick the test+jitter ideas, I can open a small follow-up PR against #36921's branch. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused Windows mitigation. The underlying gap remains: current utils.py:115-118 only falls back for EXDEV and EBUSY, so a transient PermissionError still propagates.
Problems
utils.py:93catchesOSError, which already includesPermissionError. On Windows this would retry everyOSErrorand then raise on attempt six, rather than preserving current main'sEXDEV/EBUSYcopy fallback atutils.py:116-135(merged inbf8effad0, PR #43852).- The new tests do not cover coexistence with that fallback. Add a Windows-mode simulated
EXDEVorEBUSYcase that verifies the copy fallback still succeeds. tests/test_atomic_replace_symlinks.py:30adds an unusedunittest.mockimport.
Suggested changes
- Salvage the retry narrowly around the intended Windows
PermissionErrorpath while retaining the current main fallback structure, then add the coexistence regression test.
Automated hermes-sweeper review.
| try: | ||
| os.replace(str(tmp_path), real_path) | ||
| return real_path | ||
| except (PermissionError, OSError) as exc: |
There was a problem hiding this comment.
PermissionError is already an OSError, so this retries every OSError on Windows. When salvaging onto current main, keep the existing EXDEV/EBUSY copy fallback rather than retrying those errors and raising after attempt six.
|
Superseded by #84852. Your Two adjustments: retry alone can't rescue a handle held past the budget, so #84852 adds a fallback for the persistent case; and the retry is keyed on winerror rather than a bare Note |
What does this PR do?
Mitigates the most common Windows crash in
atomic_replace():PermissionError [WinError 5]raised when a concurrent hermes process (gateway, dashboard, cron, TUI worker) briefly holds the target file — for example,~/.hermes/auth.json— open with a shared read or with a non-overlappable write lock.The call is wrapped in a 6-attempt retry loop with jittered exponential backoff (base 20ms, cap 500ms, jitter 0.5) using the existing
agent.retry_utils.jittered_backoffhelper. Worst-case wait stays under ~3s, well within what callers already budget for transient I/O. On POSIX the exception is re-raised on the first attempt, so existing behavior is unchanged.Related Issue
Refs #43268. This is a mitigation, not a root-cause fix — that issue's primary failure is the Desktop update flow's handle-release logic, which needs its own follow-up. This PR only hardens the atomic-write path so incidental cross-process locks during normal operation stop surfacing as user-facing errors.
Complements #43852 and #36856 (both
atomic_replaceimprovements) — those addressEXDEV/EBUSY(cross-filesystem / bind-mount), this one addressesPermissionError(Windows mandatory file locking). All three can coexist; they catch differentOSErrorsubclasses.Type of Change
Changes Made
utils.py:import time(top of file).atomic_replace: wrapos.replacein a 6-attempt retry loop.os.name != "nt"short-circuits so POSIX behavior is byte-identical to before. Lazy import ofagent.retry_utilsto avoid autils -> agent -> utilscycle.tests/test_atomic_replace_symlinks.py:monkeypatchso they run identically on Linux CI and Windows:test_atomic_replace_retries_on_windows_permission_error:os.replacefails twice withPermissionError(5), succeeds on the 3rd call — verifies content lands and call count = 3.test_atomic_replace_reraises_after_six_windows_failures:os.replacealways fails — verifiesPermissionErrorpropagates after 6 attempts and the target is untouched.test_atomic_replace_no_retry_on_posix:os.name == "posix"+ a singlePermissionError— verifies the retry branch is bypassed (call count = 1).How to Test
uv run --with ".[dev]" pytest tests/test_atomic_replace_symlinks.py -q— 10 passed, 1 skipped (the skip is the pre-existing POSIX-only symlink-mode test, unrelated to this PR).uv run --with ".[dev]" pytest tests/test_atomic_replace_symlinks.py tests/test_retry_utils.py tests/hermes_cli/test_atomic_json_write.py tests/hermes_cli/test_atomic_yaml_write.py -q— 37 passed, 1 failed, 1 skipped. The single failure (test_mode_applied_when_supported) is pre-existing on Windows (os.fchmodrounds 0o600 to 0o666 on Windows file systems) and reproduces onmainwithout these changes; verified by running the same test on a clean checkout ofmain.hermes secrets bitwarden statusrepeatedly — previously this could intermittently raisePermissionError 5; with this PR it succeeds after a brief backoff.Checklist
Code
pytest tests/ -qand all relevant tests pass (see How to Test Support passing morph snapshot id #2 — the one Windows-specific fchmod failure is pre-existing, not introduced here)Documentation & Housekeeping
atomic_replacedocstring still describes the high-level contract)cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Aos.name != "nt"short-circuit keeps POSIX behavior byte-identical to before; the lazy import ofagent.retry_utilsis gated inside the Windows branch so non-Windows platforms never resolve that import. macOS follows the POSIX path unchanged.Why this is more likely to merge than #43852 / #36856
Those two PRs address
EXDEV/EBUSY. This PR addresses a disjoint failure mode (PermissionError) that is not caught by their fallbacks (EXDEV check is on the OSError errno, not the exception type). On Windows the more common crash in production isPermissionError— that's the one users hit, the one #43268 documents, and the one not yet covered. They can be reviewed and merged independently.