Skip to content

fix(utils): atomic_replace falls back to shutil on cross-fs EXDEV (#34252) - #34262

Closed
Bartok9 wants to merge 1 commit into
NousResearch:mainfrom
Bartok9:fix/atomic-replace-cross-fs-34252
Closed

fix(utils): atomic_replace falls back to shutil on cross-fs EXDEV (#34252)#34262
Bartok9 wants to merge 1 commit into
NousResearch:mainfrom
Bartok9:fix/atomic-replace-cross-fs-34252

Conversation

@Bartok9

@Bartok9 Bartok9 commented May 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #34252.

Problem

atomic_replace() uses os.replace() (rename(2) syscall) which fails with OSError(errno=EXDEV, 'Invalid cross-device link') when the temp file and the target sit on different filesystems. This happens when ~/.hermes/ is symlinked to a different mount point — a common deployment shape on Linux VPS hosts where /home is small and the data lives on /mnt/data/.

Reporter's impact

  • hermes config set / hermes model crash with [Errno 18]
  • Gateway fails to persist dedup state (feishu_seen_message_ids.json) silently
  • Channel directory state is lost on gateway restart
  • Users with cross-fs HERMES_HOME cannot use the agent at all

Fix

Catch OSError(EXDEV) and fall back to shutil.move (copy + unlink internally when crossing devices):

try:
    os.replace(str(tmp_path), real_path)
except OSError as exc:
    if exc.errno == errno.EXDEV:
        shutil.move(str(tmp_path), real_path)
    else:
        raise

Not strictly atomic across devices, but preserves the functional contract — the target ends up with the new content. Non-EXDEV OSErrors (permission denied, read-only filesystem, etc.) still propagate unchanged — the fallback is intentionally narrow so it can't mask other failure modes.

Tests

3 new tests in test_atomic_replace_symlinks.py:

Test Verifies
test_atomic_replace_falls_back_to_shutil_on_exdev The headline regression — EXDEV is caught, content lands
test_atomic_replace_reraises_non_exdev_errors Sanity — EACCES etc. still propagate
test_atomic_replace_happy_path_still_uses_os_replace Same-filesystem writes still use os.replace (atomicity preserved on the 99% case)
$ pytest tests/test_atomic_replace_symlinks.py
11 passed in 0.15s

All 11 tests pass (3 new + 8 existing — including the symlink-preservation invariants from #16743 that this fix carefully preserves).

Credit to @ccwssy for the reporter's surgical root-cause analysis and proposed diff.

Co-authored-by: Cursor cursoragent@cursor.com

…usResearch#34252)

Fixes NousResearch#34252. atomic_replace() uses os.replace() (rename(2) underneath)
which fails with OSError(errno=EXDEV, 'Invalid cross-device link') when
the temp file and the target sit on different filesystems. This happens
when ~/.hermes/ is symlinked to a different mount point — a common
deployment shape on Linux VPS hosts where /home is small and the data
lives on /mnt/data/.

Impact (per reporter):
- hermes config set / hermes model crash with [Errno 18]
- Gateway fails to persist dedup state (feishu_seen_message_ids.json) silently
- Channel directory state is lost on gateway restart
- Users with cross-fs HERMES_HOME cannot use the agent at all

Fix: catch OSError(EXDEV) and fall back to shutil.move (copy + unlink
internally when crossing devices). Not strictly atomic across devices,
but preserves the functional contract — the target ends up with the new
content. Non-EXDEV OSErrors (permission denied, read-only filesystem,
etc.) still propagate unchanged — the fallback is intentionally narrow.

Adds 3 regression tests:
- EXDEV fallback to shutil.move
- Non-EXDEV errors still propagate (sanity check)
- Happy path still uses os.replace (atomicity on same-fs preserved)

All 11 tests in test_atomic_replace_symlinks.py pass (3 new + 8 existing).

Co-authored-by: Cursor <cursoragent@cursor.com>
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists area/config Config system, migrations, profiles labels May 29, 2026
@konsisumer

Copy link
Copy Markdown
Contributor

Closing — deferring to #33508 by @OmarB97 which addresses the same. Reopen if that PR stalls.

@OmarB97

OmarB97 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Confirming the deferral noted above — #33508 covers the same EXDEV failure with copy2+unlink and carries the broader test set (including the symlink-target case and end-to-end atomic_yaml_write). I've linked issue #34252 from #33508 so it closes on merge, with credit to @ccwssy for the root-cause analysis. This one can be closed as a duplicate unless #33508 stalls.

@Bartok9

Bartok9 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded. Current main already implements the cross-device fallback this PR added — atomic_replace in utils.py now catches EXDEV/EBUSY from os.replace and falls back to shutil.copyfile + copystat + fsync + unlink (landed in #43852, "fix(utils): copy fallback for atomic replace across devices"). That covers the cross-fs case from #34252 plus EBUSY and symlink resolution, so this branch is now redundant. Thanks to whoever picked it up — no action needed here.

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

Labels

area/config Config system, migrations, profiles P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: atomic_replace() fails with EXDEV when HERMES_HOME is a cross-filesystem symlink

4 participants