Skip to content

fix(utils): fall back when atomic_replace crosses filesystems (#17313) - #17322

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/atomic-replace-exdev-fallback-17313
Closed

fix(utils): fall back when atomic_replace crosses filesystems (#17313)#17322
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/atomic-replace-exdev-fallback-17313

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • atomic_replace now falls back to a sibling-temp + same-fs replace when os.replace raises OSError(EXDEV), so symlink-following writes survive when the resolved real path is on a different filesystem than the caller's temp.
  • Fixes the memory tool's [Errno 18] Invalid cross-device link failure 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 through atomic_replacetools/skill_manager_tool.py, tools/skills_sync.py, utils.atomic_json_write, and utils.atomic_yaml_write.

The bug

utils.atomic_replace was introduced in #16743 to keep symlinked deployment files (e.g. ~/.hermes/config.yaml → a profile package) intact across atomic writes. It calls os.path.realpath(target) and then os.replace(tmp, real_path).

When the symlink crosses a filesystem boundary — common in #17313's setup, where ~/.hermes/memories/MEMORY.md is 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.replace cannot move inodes between filesystems, so it raises OSError(EXDEV):

Failed to write memory file /root/.hermes/memories/MEMORY.md:
  [Errno 18] Invalid cross-device link:
  /root/.hermes/memories/.mem_wre8n6ee.tmp -> /opt/hermes-vault/hermes-memory/MEMORY.md

Every call site that reaches atomic_replace through 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 inner os.replace and:

  1. tempfile.mkstemp a sibling temp in the real target's directory (same filesystem as the destination).
  2. shutil.copyfile the original temp's content onto the sibling.
  3. os.replace(sibling_tmp, real_path) — now within a single filesystem, so the swap is atomic from any reader's perspective.
  4. os.unlink the original cross-device temp.

If the inner os.replace itself fails, the sibling temp is cleaned up before re-raising, so we don't leak .atomic_xdev_*.tmp files into the user's vault.

Same-filesystem writes hit the original fast path unchanged. Only EXDEV triggers the fallback — every other OSError propagates exactly as before, preserving existing error-handling tests in tests/gateway/test_weixin.py (which monkeypatch utils.os.replace to raise non-EXDEV errors).

Contract Protected

atomic_replace(tmp, target) invariant: writes the contents of tmp to the real file behind target, atomically from a reader's perspective, regardless of which filesystem tmp was created on.

Input scenario Pre-fix behavior Post-fix behavior
Same-fs, regular file Atomic os.replace Atomic os.replace (unchanged)
Same-fs, symlinked target Atomic os.replace on real path Atomic os.replace on real path (unchanged)
Cross-fs, symlinked target OSError(EXDEV) propagates Atomic replace via sibling temp
Inner replace fails (e.g. EACCES) n/a (only triggered by fallback) Sibling temp unlinked, original error re-raised
OSError != EXDEV from os.replace Propagates Propagates (unchanged)

Test plan

  • tests/test_atomic_replace_symlinks.py — 4 new cases on the helper:
    • test_atomic_replace_recovers_from_exdev — reporter's exact error path
    • test_atomic_replace_exdev_with_symlink_preserves_link — symlink + cross-fs, link survives
    • test_atomic_replace_propagates_non_exdev_oserror — non-EXDEV OSError still raises
    • test_atomic_replace_exdev_cleans_sibling_on_replace_failure — no leftover .atomic_xdev_* on inner-replace failure
  • tests/tools/test_memory_tool.py::TestMemoryToolCrossDeviceWrite — integration: MemoryStore.add round-trips through disk when the first os.replace raises EXDEV, with no leftover .mem_*.tmp or .atomic_xdev_*.tmp in the memories dir.
  • Adjacent suites confirmed clean: 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, exercises atomic_yaml_write), tests/gateway/test_weixin.py (42 tests, mocks utils.os.replace with non-EXDEV errors).
  • Regression guard: 4 of the 5 new tests fail on clean 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

…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>
Copilot AI review requested due to automatic review settings April 29, 2026 07:13

Copilot AI 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.

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 EXDEV handling in utils.atomic_replace that falls back to a sibling temp file in the real target directory, then os.replace within 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.replace raises EXDEV, 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.

Comment thread utils.py
Comment on lines +105 to +106
shutil.copyfile(tmp_str, sibling_tmp)
os.replace(sibling_tmp, real_path)

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists tool/memory Memory tool and memory providers tool/skills Skills system (list, view, manage) area/docker Docker image, Compose, packaging labels Apr 29, 2026

@Bartok9 Bartok9 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.

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:

  1. Symlink resolutionos.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.

  2. 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_failure pins the cleanup path nicely.

  3. Non-EXDEV propagationif exc.errno != errno.EXDEV: raise correctly re-raises other OSErrors (ENOSPC, EACCES, etc.) without entering the fallback.

  4. 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>
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot Finding addressed in commit cc1d12064:

Finding (line 106 — sibling temp not fsynced before swap): Real concern. The non-EXDEV path inherits the caller's fsync (atomic_json_write and friends fsync the original temp before calling atomic_replace), but the EXDEV fallback creates a new sibling via shutil.copyfile, so the original fsync no longer applies to the file that gets swapped in. Fixed by:

  1. After shutil.copyfile, open the sibling read-only and os.fsync its fd before os.replace.
  2. After the swap, best-effort os.fsync the destination directory so the rename itself is durable. Wrapped in try/except OSError because Windows raises and some FUSE mounts return EINVAL; the swap is the contract, dir-fsync is a bonus.

Two new regression tests:

  • test_atomic_replace_exdev_fsyncs_sibling_temp — spies on os.fsync (matches by st_ino) 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 completes.

Verified the regression guard catches the pre-fixup state: against the old code (no sibling fsync), the spy's fsynced_paths list stays empty and the assertion fails. With the fix, the sibling temp's name (starting with .atomic_xdev_) appears in the list. All 14 tests in tests/test_atomic_replace_symlinks.py pass under pytest-xdist.

@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 35 test job failures + 1 collection error on commit cc1d12064 are pre-existing baselines on clean origin/main (5a61c116e, run 25113879725). Zero failures intersect with touched code (utils.py::atomic_replace).

Touched-code regression check: focused tests pass — the EXDEV-fallback regression test passes locally.

Same baseline cluster as #17569, #17441, #17386, #17348:

  • test_credential_sources_registry_has_expected_steps — minimax-oauth not in test fixture (covered by fix: restore CI compatibility regressions #17334).
  • test_minimax_provider::test_normalize_converts_without_preserveMiniMax-M2.7 vs MiniMax-M2-7 dot-preservation drift after MiniMax model rename (commits 0b2f1bb27/9eb16025b).
  • test_session.py collection error — normalize_whatsapp_identifier removed; fix: restore CI compatibility regressions #17334 restores compat.
  • test_mcp_structured_content (5), test_clipboard::TestIsWsl (3), test_modal_sandbox_fixes (2), test_setup vercel — xdist state pollution / Linux-only.
  • test_protocol::test_session_resume_returns_hydrated_messages — DB-stub missing include_ancestors.

The atomic_replace EXDEV-fallback change touches no test paths. CI failures are 100% baseline noise.

@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue clean — branch is several thousand commits behind main and never picked up a review. Happy to reopen if the underlying fix is still useful.

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

Labels

area/docker Docker image, Compose, packaging P2 Medium — degraded but workaround exists tool/memory Memory tool and memory providers tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: memory tool fails with Invalid cross-device link (EXDEV)

4 participants