Skip to content

fix(files): fall back to link+unlink when the filesystem rejects renameat2(RENAME_NOREPLACE) — same-volume moves fail on NFS - #944

Draft
kevinheneveld wants to merge 1 commit into
Listenarrs:canaryfrom
kevinheneveld:fix/nfs-no-replace-rename-fallback
Draft

fix(files): fall back to link+unlink when the filesystem rejects renameat2(RENAME_NOREPLACE) — same-volume moves fail on NFS#944
kevinheneveld wants to merge 1 commit into
Listenarrs:canaryfrom
kevinheneveld:fix/nfs-no-replace-rename-fallback

Conversation

@kevinheneveld

Copy link
Copy Markdown
Contributor

Summary

On a library stored on NFS 4.2, every same-volume single-file move through the pinned mover fails:

[ERR] FileMover: File mutation Failed: Move Mavericks-16.m4b -> Mavericks-16.m4b.
Reason: Could not publish a pinned filesystem entry relative to its owned directory.
System.ComponentModel.Win32Exception (22)

The Linux NFS client returns EINVAL for any renameat2 flag, including RENAME_NOREPLACE. Probed on the host: renameat2(RENAME_NOREPLACE) → EINVAL, plain renameat → ok, linkat → ok (and EEXIST when the destination exists). Affected user-facing flows: Split Collection transfers, Transfer Files, Organize/rename. The database reassignment succeeded while the file stayed put, e.g. "Transferred 10 file(s) (0 moved on disk)". Directory moves (AudiobookContentMoveService.MarkerlessRename) already classify 22/38/95 as "native rename unsupported" and fall back to a verified copy; single-file publication had no fallback.

Fix

PinnedDirectoryCreation.RenameNoReplaceLinuxCore: try renameat2(RENAME_NOREPLACE); on EINVAL/ENOSYS/EOPNOTSUPP emulate it with linkat + unlinkat:

  • linkat refuses an existing destination with EEXIST, so the no-replace contract holds without a check-then-act race.
  • The destination is the same inode, so the pinned identity verification after publication still passes.
  • If the filesystem cannot hard-link either (EPERM/EXDEV/EMLINK/EOPNOTSUPP), the original unsupported errno is returned so existing copy fallbacks still engage.
  • If unlinking the source fails, the new link is rolled back (ENOENT on the source counts as completed).

Both the probing path (TryRenameRelativeEntryNoReplaceLinux, used by PinnedFileEntry.MoveTo) and the throwing path (RenameRelativeEntry, used by MoveWithinParent / in-place renames) share the core. Directory renames keep their previous behavior (hard links don't apply; their copy fallback remains).

Tests

NoReplaceRenameFallbackTests (15 cases) drive the core with fake primitives: rename supported; EEXIST without fallback; EINVAL/ENOSYS/EOPNOTSUPP → link+unlink sequence; destination-exists refusal via link; hard links unsupported → original errno; unlink failure rolls back the link; vanished source after link = success; errno classification.

Verified on the affected NFS host after deploying to a fork build.

🤖 Generated with Claude Code

https://claude.ai/code/session_012A3PhZjAJkCP4Cvo9gC551

…meat2(RENAME_NOREPLACE)

Live case: a library on NFS 4.2. The Linux NFS client returns EINVAL for any
renameat2 flag, so every same-volume file move through the pinned mover —
split-collection transfers, transfer-files, organize/rename — failed with
"Could not publish a pinned filesystem entry relative to its owned directory"
(errno 22) and left files in place, while the database reassignments went
through. Directory moves already had a verified-copy fallback for this errno
class; single-file publication did not.

RenameNoReplaceLinuxCore now tries renameat2(RENAME_NOREPLACE) first and, on
EINVAL/ENOSYS/EOPNOTSUPP, emulates it with linkat + unlinkat: linkat refuses
an existing destination (EEXIST) so the no-replace contract holds without a
check-then-act race, and the destination keeps the same inode so pinned
identity proofs still verify. If the filesystem cannot hard-link either, the
original unsupported errno is returned so callers' copy fallbacks still
engage; if the source name cannot be unlinked, the new link is rolled back.
Both the probing (TryRenameRelativeEntryNoReplaceLinux) and throwing
(RenameRelativeEntry) paths share the core, so in-place renames are covered
too. Native primitives are injected as delegates; 15 unit tests cover the
sequencing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A3PhZjAJkCP4Cvo9gC551
kevinheneveld added a commit to kevinheneveld/Listenarr that referenced this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant