fix(utils): handle Windows sharing violations in atomic_replace - #57777
fix(utils): handle Windows sharing violations in atomic_replace#57777lEWFkRAD wants to merge 2 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing this to the shared write helper. The current-main premise remains valid: utils.py:114-118 only falls back for EXDEV/EBUSY, while gateway status reads use path.read_text() at gateway/status.py:439-448 and turn-boundary persistence is best-effort at gateway/run.py:4575-4579.
Problems
utils.py:109classifies every WindowsPermissionError/EACCESas a sharing violation. This cannot distinguish ordinary access-denied failures fromERROR_SHARING_VIOLATION; please narrow the retry/copy recovery to the specific sharing-violation WinError.utils.py:162-164keeps retrying if a retry returnsEXDEVorEBUSY, although the adjacent comment says those errors never clear on retry. Break to the existing copy fallback for those errors instead.tests/test_atomic_replace_symlinks.py:483-491only models genericEACCES; add coverage that access denied still propagates and that a retry transitioning toEXDEV/EBUSYdoes not consume the remaining sharing retries.
The PR base and current main have identical blobs for both changed files, so this should remain a small salvage after those corrections. This is an automated hermes-sweeper review.
CPython opens files without FILE_SHARE_DELETE on Windows, so os.replace onto a target that any concurrent process holds a read handle on fails with ERROR_SHARING_VIOLATION (PermissionError/EACCES). atomic_replace only caught EXDEV/EBUSY, so the write was silently dropped and the temp file orphaned - hit at every turn boundary by gateway_state.json (_persist_active_agents writer vs read_runtime_status readers), and latent in every atomic_json_write/atomic_yaml_write caller. Treat the Windows sharing-violation class as fallback-eligible: retry the atomic rename briefly (violations from transient readers clear in milliseconds), then fall back to the existing copy/fsync/unlink path so the write lands. POSIX behavior is unchanged - EACCES there means directory permissions and still propagates. Fixes NousResearch#57775 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
689c8dd to
f587f14
Compare
|
Independent confirmation of this failure mode on Environment: Windows 11 (10.0.26200), Python 3.11, hermes-agent 0.19.0. User-visible failureThis surfaces as a hard error toast in the desktop app, not a silently dropped write: Note WinError 5 ( The same-process detailBoth failures carry the same writer PID (28472) with different temp UUIDs, so they are two distinct write attempts from one process. That process is the desktop app's single Python backend: No second Hermes process was involved. The gateway was a separate PID and idle. So a reader thread and a writer thread inside that one 95-thread process are colliding on That matters for the fix: TriggerReliable on this install: open several sessions in quick succession in the desktop app (clicking down the session history list). Each click runs an agent init, each init touches the auth store on its own thread, and inits that overlap collide. The result is a session that fails to initialize and is unusable until the click is retried. Minimal repro, no Hermes involvedimport os, tempfile, errno
d = tempfile.mkdtemp()
target = os.path.join(d, "auth.json")
tmp1, tmp2 = os.path.join(d, "t.a"), os.path.join(d, "t.b")
for p in (target, tmp1, tmp2):
open(p, "w").write("{}\n")
os.replace(tmp1, target) # CONTROL: no reader open
print("control: ok")
reader = open(target, "r") # mirrors _load_auth_store -> read_text()
reader.read()
try:
os.replace(tmp2, target)
except OSError as e:
print(f"test: errno={e.errno} ({errno.errorcode.get(e.errno)}) winerror={e.winerror}")
print(f"in (EXDEV, EBUSY)? {e.errno in (errno.EXDEV, errno.EBUSY)}")One ordinary reader is sufficient. No antivirus, no second process. Adding a Defender exclusion for the Hermes directory changed nothing, consistent with the cause being in-process readers. Two differences from the
|
|
Could the P3 on this PR be revisited? The issue it fixes (#57775) is P2, and from a user's seat this is not cosmetic: it makes sessions impossible to open. I want to be precise about the impact, because I think the original framing may be what led to the lower priority. #57775 describes the failure as writes being silently dropped by callers that swallow the exception. On the
Why I think this warrants more than P3:
I am not arguing the diagnosis in #57775 is wrong. It is exactly right, and this PR looks like the correct fix. My point is narrower: the user-facing severity on the auth path is higher than "dropped write" suggests, and the fix being P3 while the defect is P2 leaves a blocking bug open with a tested patch already written. For what it is worth, I have applied the equivalent change locally and it resolves the failure. A standalone test of the logic, unpatched versus patched, on Windows 11 / Python 3.11: The persistent-reader row is the one that matters for this PR specifically: retry alone does not rescue it, which is what distinguishes this approach from #45022. Happy to provide anything else useful. Thanks for the work on this. |
|
Superseded by #84852. Your retry-then-fallback architecture is the right shape and #84852 keeps it — it is the only approach in this cluster that handles both a transient reader and one that outlives the retry budget. Credited as a co-author. Two things surfaced while verifying on real Windows (11 build 26200, CPython 3.11) that needed a reshape rather than a patch: The error code is 5, not 32. A held target handle reports So the primary predicate never fired for the bug, and every real case fell through to the The #84852 stops guessing: contention and genuine denial take the same bounded path, and a real denial is re-raised unchanged with its temp file intact. One more thing worth flagging, since it affected the review: the three Windows tests here gated with Thanks for filing #57775 with a clean reproduction — the diagnosis in that issue was correct throughout. |
What does this PR do?
Stops
atomic_replace()from silently dropping writes on Windows when another process holds a read handle on the target file.CPython opens files without
FILE_SHARE_DELETEon Windows, soos.replaceonto a file with any concurrent reader fails withERROR_SHARING_VIOLATION→PermissionError(EACCES). The helper's copy fallback only caughtEXDEV/EBUSY, so the exception propagated (and was swallowed by most callers), the update was lost, and the freshly-written.tmpfile was orphaned. This is hit routinely on an active Windows gateway:gateway/run.py::_persist_active_agentsrewritesgateway_state.jsonat every turn boundary whilegateway/status.py::read_runtime_statusreaders poll the same file — and it is latent in every otheratomic_json_write/atomic_yaml_writecall site (config, auth, cron state, …).The fix treats the Windows sharing-violation class as fallback-eligible, in two stages:
EXDEV/EBUSYskip the retry loop (they never clear on retry) and go straight to the copy fallback as before, so cross-device deployments pay no new latency. POSIX behavior is unchanged:EACCESthere means directory permissions, the copy fallback would fail identically, and the error still propagates.Related Issue
Fixes #57775
Type of Change
Changes Made
utils.py: added_is_windows_sharing_error()/_replace_error_allows_fallback()predicates and a bounded retry loop (_SHARING_RETRY_ATTEMPTS×_SHARING_RETRY_DELAY_S) inatomic_replace(); WindowsPermissionError/EACCES now retries the rename, then uses the existing copy fallback. Docstring updated.tests/test_atomic_replace_symlinks.py:test_atomic_replace_windows_held_read_handle_falls_back_to_copy— real handle held for the whole call (Windows): copy fallback lands the content, no orphaned.tmp.test_atomic_replace_windows_transient_reader_succeeds_via_retry— real handle released from the retry loop's sleep hook (Windows, deterministic): the atomic rename wins andshutil.copyfileis asserted unreached.test_atomic_json_write_windows_concurrent_reader— end-to-endgateway_state.jsonscenario throughatomic_json_write(Windows).test_atomic_replace_sharing_violation_simulated_retry_then_copy— cross-platform pin of the retry count (1 +_SHARING_RETRY_ATTEMPTS) before the copy runs, via monkeypatchedos.replace.test_atomic_replace_eacces_propagates_on_posix— pins that POSIX EACCES still propagates unchanged.test_atomic_replace_other_oserror_propagates— switched its sentinel errno from EACCES (now fallback-eligible on Windows) to ENOSPC, which is non-fallback on every platform.How to Test
pytest tests/test_atomic_replace_symlinks.py -qon Linux/macOS — the simulated retry/fallback and POSIX-propagation tests run; Windows-specific ones skip.ERROR_SHARING_VIOLATIONs.PermissionErrorand orphans a.tmp):Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — targeted files pass on Windows except pre-existing native-Windows environment failures unrelated to this change (symlink-privilegeWinError 1314without Developer Mode, and a POSIXchmod 0o600assertion); full details in the test run notes aboveDocumentation & Housekeeping
docs/, docstrings) — docstring updatedcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A🤖 Generated with Claude Code