fix(utils): fall back when atomic_replace crosses filesystems (#17313) - #17322
fix(utils): fall back when atomic_replace crosses filesystems (#17313)#17322briandevans wants to merge 2 commits into
Conversation
…search#17313) `atomic_replace` follows symlinks by `os.path.realpath`-ing the target so managed deployments that symlink ~/.hermes/<file> into a vault keep their links intact (NousResearch#16743). When that vault is mounted on a different filesystem, the resolved real path is no longer on the same device as the caller's temp file, so `os.replace(tmp, real)` raises `OSError(EXDEV)` — in NousResearch#17313 the memory tool surfaces this as `Failed to write memory file …: [Errno 18] Invalid cross-device link`. The fix lifts cross-device handling into the helper so every site already on `atomic_replace` benefits — `tools/memory_tool.py:_write_file`, `tools/skill_manager_tool.py`, `tools/skills_sync.py`, and `utils.atomic_json_write` / `atomic_yaml_write`. On EXDEV we copy the content onto a sibling temp inside the real target's directory and atomically replace within that filesystem, so the swap remains atomic from readers' perspective even though the original move crossed devices. Non-EXDEV `OSError`s still propagate untouched, and same-filesystem writes hit the original fast path with no behavior change. Tests: 4 new cases on the helper (recovery, symlink + EXDEV, non-EXDEV propagation, sibling cleanup on inner-replace failure) plus a memory-tool integration test that reproduces the reporter's error path end-to-end. All four helper tests fail on clean origin/main with the exact `[Errno 18] Invalid cross-device link` symptom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds cross-filesystem robustness to utils.atomic_replace so atomic writes continue to work when a symlink resolves onto a different mount/device (EXDEV), addressing memory-tool failures in Docker/NAS/vault setups (#17313).
Changes:
- Add
EXDEVhandling inutils.atomic_replacethat falls back to a sibling temp file in the real target directory, thenos.replacewithin that filesystem. - Add unit tests covering the EXDEV fallback behavior and cleanup guarantees.
- Add an integration-style test ensuring the memory tool write path succeeds when the first
os.replaceraisesEXDEV, with no temp-file leaks.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| utils.py | Implements EXDEV fallback in atomic_replace using sibling temp + copy + same-fs replace. |
| tests/tools/test_memory_tool.py | Adds a regression test that simulates EXDEV on first replace for memory persistence and temp cleanup. |
| tests/test_atomic_replace_symlinks.py | Adds focused unit tests for EXDEV fallback behavior, symlink preservation, error propagation, and cleanup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| shutil.copyfile(tmp_str, sibling_tmp) | ||
| os.replace(sibling_tmp, real_path) |
There was a problem hiding this comment.
In the EXDEV fallback path, the content is copied into a newly created sibling temp file and then swapped into place, but the sibling file is never fsync’d after shutil.copyfile. Callers like atomic_json_write, atomic_yaml_write, and MemoryStore._write_file already fsync the original temp file to provide crash-safety/durability; this fallback currently drops that guarantee for exactly the cross-device scenario we’re fixing.
Consider fsyncing the sibling temp after the copy (and ideally fsyncing the destination directory after os.replace) so the fallback preserves the same durability semantics as the non-EXDEV path.
| shutil.copyfile(tmp_str, sibling_tmp) | |
| os.replace(sibling_tmp, real_path) | |
| shutil.copyfile(tmp_str, sibling_tmp) | |
| sibling_fd = os.open(sibling_tmp, os.O_RDONLY) | |
| try: | |
| os.fsync(sibling_fd) | |
| finally: | |
| os.close(sibling_fd) | |
| os.replace(sibling_tmp, real_path) | |
| dir_fd = os.open(real_dir, os.O_RDONLY) | |
| try: | |
| os.fsync(dir_fd) | |
| except OSError: | |
| pass | |
| finally: | |
| os.close(dir_fd) |
Bartok9
left a comment
There was a problem hiding this comment.
Solid fix. The EXDEV fallback correctly copies to a sibling temp on the target filesystem and atomically replaces there — keeps the write atomic on the destination side while handling cross-device boundaries. A few things I verified:
-
Symlink resolution —
os.path.realpath()on the target ensures the sibling temp lands in the real directory, not the symlink's parent. This matches the reporter's Docker volume mount scenario. -
Cleanup — Original temp is unlinked after the copy, sibling temp is cleaned up on failure. The test
test_atomic_replace_exdev_cleans_sibling_on_replace_failurepins the cleanup path nicely. -
Non-EXDEV propagation —
if exc.errno != errno.EXDEV: raisecorrectly re-raises other OSErrors (ENOSPC, EACCES, etc.) without entering the fallback. -
Memory tool integration test — Good to see an end-to-end test through
MemoryStore.add()confirming the fix works at the tool level, not just the utility level.
One minor note: the fallback uses shutil.copyfile() which copies content only (no metadata/permissions). For MEMORY.md this is fine since the temp file was just created, but if atomic_replace is ever used for files where permissions matter, a shutil.copy2() might be safer. Not blocking though — the current behavior matches what os.replace() does (no permission change on the destination).
Copilot review on NousResearch#17322 caught that the cross-device fallback path introduced in this PR copies via ``shutil.copyfile`` and then ``os.replace``s the sibling into place — but never ``fsync``s the sibling, so the durability that ``atomic_json_write`` / ``atomic_yaml_write`` / ``MemoryStore._write_file`` paid for on the original temp is silently lost in exactly the cross-device scenario this PR exists to fix. The fix: after ``shutil.copyfile`` to the sibling, open the sibling read-only and ``os.fsync`` its fd before ``os.replace``. After the swap, also best-effort ``os.fsync`` the destination directory so the rename itself is durable. Directory ``fsync`` is a best-effort durability barrier — Windows raises and some FUSE mounts return ``EINVAL``, so the dir-fsync ``OSError`` is swallowed and the swap result is the contract. Tests: - ``test_atomic_replace_exdev_fsyncs_sibling_temp`` — spies on ``os.fsync`` and asserts the sibling temp was fsynced before the swap. - ``test_atomic_replace_exdev_dir_fsync_failure_is_swallowed`` — forces directory fsync to raise ``EINVAL``; the swap still succeeds. - All existing 12 tests in the file continue to pass. Regression-guard verified manually: the same fsync-spy test fails against the pre-fixup code (no sibling fsync, list stays empty). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@copilot Finding addressed in commit Finding (line 106 — sibling temp not fsynced before swap): Real concern. The non-EXDEV path inherits the caller's fsync (
Two new regression tests:
Verified the regression guard catches the pre-fixup state: against the old code (no sibling fsync), the spy's |
|
CI audit — all 35 Touched-code regression check: focused tests pass — the EXDEV-fallback regression test passes locally. Same baseline cluster as #17569, #17441, #17386, #17348:
The atomic_replace EXDEV-fallback change touches no test paths. CI failures are 100% baseline noise. |
|
Closing to keep the queue clean — branch is several thousand commits behind |
Summary
atomic_replacenow falls back to a sibling-temp + same-fs replace whenos.replaceraisesOSError(EXDEV), so symlink-following writes survive when the resolved real path is on a different filesystem than the caller's temp.[Errno 18] Invalid cross-device linkfailure on Docker / NAS / managed-vault setups (Bug: memory tool fails with Invalid cross-device link (EXDEV) #17313). Same fix transparently covers every other call site already routed throughatomic_replace—tools/skill_manager_tool.py,tools/skills_sync.py,utils.atomic_json_write, andutils.atomic_yaml_write.The bug
utils.atomic_replacewas introduced in #16743 to keep symlinked deployment files (e.g.~/.hermes/config.yaml→ a profile package) intact across atomic writes. It callsos.path.realpath(target)and thenos.replace(tmp, real_path).When the symlink crosses a filesystem boundary — common in #17313's setup, where
~/.hermes/memories/MEMORY.mdis symlinked into/opt/hermes-vault/hermes-memory/on a separate mount — the temp file lives on one device while the real file lives on another.os.replacecannot move inodes between filesystems, so it raisesOSError(EXDEV):Every call site that reaches
atomic_replacethrough a symlink to a different mount hits the same path: memory writes, skill manager edits, JSON/YAML config writes.The fix
Catch
OSError(errno=EXDEV)from the inneros.replaceand:tempfile.mkstempa sibling temp in the real target's directory (same filesystem as the destination).shutil.copyfilethe original temp's content onto the sibling.os.replace(sibling_tmp, real_path)— now within a single filesystem, so the swap is atomic from any reader's perspective.os.unlinkthe original cross-device temp.If the inner
os.replaceitself fails, the sibling temp is cleaned up before re-raising, so we don't leak.atomic_xdev_*.tmpfiles into the user's vault.Same-filesystem writes hit the original fast path unchanged. Only
EXDEVtriggers the fallback — every otherOSErrorpropagates exactly as before, preserving existing error-handling tests intests/gateway/test_weixin.py(which monkeypatchutils.os.replaceto raise non-EXDEV errors).Contract Protected
atomic_replace(tmp, target)invariant: writes the contents oftmpto the real file behindtarget, atomically from a reader's perspective, regardless of which filesystemtmpwas created on.os.replaceos.replace(unchanged)os.replaceon real pathos.replaceon real path (unchanged)OSError(EXDEV)propagatesEACCES)OSError != EXDEVfromos.replaceTest plan
tests/test_atomic_replace_symlinks.py— 4 new cases on the helper:test_atomic_replace_recovers_from_exdev— reporter's exact error pathtest_atomic_replace_exdev_with_symlink_preserves_link— symlink + cross-fs, link survivestest_atomic_replace_propagates_non_exdev_oserror— non-EXDEVOSErrorstill raisestest_atomic_replace_exdev_cleans_sibling_on_replace_failure— no leftover.atomic_xdev_*on inner-replace failuretests/tools/test_memory_tool.py::TestMemoryToolCrossDeviceWrite— integration:MemoryStore.addround-trips through disk when the firstos.replaceraisesEXDEV, with no leftover.mem_*.tmpor.atomic_xdev_*.tmpin the memories dir.test_atomic_replace_symlinks.py(16 tests, all pass),tests/tools/test_memory_tool.py(30 tests),tests/hermes_cli/test_config.py(52 tests, exercisesatomic_yaml_write),tests/gateway/test_weixin.py(42 tests, mocksutils.os.replacewith non-EXDEV errors).origin/main(58a6171bf) with the reporter's exact error:RuntimeError: Failed to write memory file …: [Errno 18] Invalid cross-device link. The fifth (non-EXDEV propagation) passes on baseline because that path was already correct.Related
atomic_replacesymlink-following) — that PR made the helper symlink-aware but didn't account for the symlink resolving to a different filesystem.atomic_replace/ EXDEV / memory-tool atomic-write paths.