Skip to content

fix(utils): retry os.replace on Windows PermissionError (WinError 5) - #45022

Closed
lost9999 wants to merge 1 commit into
NousResearch:mainfrom
lost9999:fix/atomic-replace-windows-permission-retry
Closed

fix(utils): retry os.replace on Windows PermissionError (WinError 5)#45022
lost9999 wants to merge 1 commit into
NousResearch:mainfrom
lost9999:fix/atomic-replace-windows-permission-retry

Conversation

@lost9999

Copy link
Copy Markdown
Contributor

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_backoff helper. 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_replace improvements) — those address EXDEV/EBUSY (cross-filesystem / bind-mount), this one addresses PermissionError (Windows mandatory file locking). All three can coexist; they catch different OSError subclasses.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • utils.py:
    • Add import time (top of file).
    • atomic_replace: wrap os.replace in a 6-attempt retry loop. os.name != "nt" short-circuits so POSIX behavior is byte-identical to before. Lazy import of agent.retry_utils to avoid a utils -> agent -> utils cycle.
  • tests/test_atomic_replace_symlinks.py:
    • 3 new tests, all using monkeypatch so they run identically on Linux CI and Windows:
      • test_atomic_replace_retries_on_windows_permission_error: os.replace fails twice with PermissionError(5), succeeds on the 3rd call — verifies content lands and call count = 3.
      • test_atomic_replace_reraises_after_six_windows_failures: os.replace always fails — verifies PermissionError propagates after 6 attempts and the target is untouched.
      • test_atomic_replace_no_retry_on_posix: os.name == "posix" + a single PermissionError — verifies the retry branch is bypassed (call count = 1).

How to Test

  1. 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).
  2. 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.fchmod rounds 0o600 to 0o666 on Windows file systems) and reproduces on main without these changes; verified by running the same test on a clean checkout of main.
  3. On a real Windows install: open a long-running hermes gateway, then in another shell run hermes secrets bitwarden status repeatedly — previously this could intermittently raise PermissionError 5; with this PR it succeeds after a brief backoff.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run pytest tests/ -q and 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)
  • I've added tests for my changes
  • I've tested on my platform: Windows 10 (Hermes Agent v0.16.0, multi-process deployment with gateway + dashboard + cron + TUI)

Documentation & Housekeeping

  • I've updated relevant documentation — N/A (no doc changes; the retry is internal and the existing atomic_replace docstring still describes the high-level contract)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — yes: the os.name != "nt" short-circuit keeps POSIX behavior byte-identical to before; the lazy import of agent.retry_utils is gated inside the Windows branch so non-Windows platforms never resolve that import. macOS follows the POSIX path unchanged.
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (internal helper)

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 is PermissionError — that's the one users hit, the one #43268 documents, and the one not yet covered. They can be reviewed and merged independently.

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.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have duplicate This issue or pull request already exists labels Jun 12, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Duplicate of #36921 — same bounded retry of transient PermissionError (WinError 5) around os.replace in atomic_replace. #36921 is the earlier-open version.

@lost9999

Copy link
Copy Markdown
Contributor Author

Thanks @alt-glitch — saw the duplicate flag and #36921. Confirming both PRs target the same fix (bounded PermissionError retry around os.replace in atomic_replace), and #36921 has the first-mover claim.

Pointing out two things that are slightly different in #45022, in case they help the maintainer decide which to merge (or how to consolidate):

  1. Uses agent.retry_utils.jittered_backoff rather than a hand-rolled time.sleep(delay); delay *= 2. The project already has a jittered helper and the test suite has test_retry_utils.py — this PR reuses it so concurrent gateway+dashboard+cron+TUI workers don't thunder-herd retry on a release. The hand-rolled version in fix(utils): retry transient atomic replace permission errors #36921 has identical worst-case latency but no jitter, so under load the retries will sync.

  2. Three test cases (success-after-2-failures, exhaustion-after-6, POSIX-no-retry) using monkeypatch so they run on Linux CI too. The Windows retry branch is gated on os.name == "nt" but the tests don't depend on it — they simulate PermissionError(5) on any platform. This may or may not be more than fix(utils): retry transient atomic replace permission errors #36921's single test (I haven't seen the full diff).

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 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:93 catches OSError, which already includes PermissionError. On Windows this would retry every OSError and then raise on attempt six, rather than preserving current main's EXDEV/EBUSY copy fallback at utils.py:116-135 (merged in bf8effad0, PR #43852).
  • The new tests do not cover coexistence with that fallback. Add a Windows-mode simulated EXDEV or EBUSY case that verifies the copy fallback still succeeds.
  • tests/test_atomic_replace_symlinks.py:30 adds an unused unittest.mock import.

Suggested changes

  • Salvage the retry narrowly around the intended Windows PermissionError path while retaining the current main fallback structure, then add the coexistence regression test.

Automated hermes-sweeper review.

Comment thread utils.py
try:
os.replace(str(tmp_path), real_path)
return real_path
except (PermissionError, OSError) as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #84852.

Your jittered_backoff reuse is carried into #84852 — decorrelating concurrent writers matters here because multiple Hermes processes hit the same files. Credited as a co-author.

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 PermissionError, since a genuine ACL denial raises the same exception type and shouldn't consume the budget silently.

Note except (PermissionError, OSError) is redundant — PermissionError is an OSError subclass, so the first clause never matches independently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

duplicate This issue or pull request already exists P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants