Skip to content

fix: Hard link failure no longer copies+deletes originals (#209)#210

Merged
deucebucket merged 1 commit intodevelopfrom
fix/issue-209-hardlink-safety
Apr 18, 2026
Merged

fix: Hard link failure no longer copies+deletes originals (#209)#210
deucebucket merged 1 commit intodevelopfrom
fix/issue-209-hardlink-safety

Conversation

@deucebucket
Copy link
Copy Markdown
Owner

Closes #209

Summary

When Use hard links was enabled and the watch folder and library lived on different filesystems, os.link() raised EXDEV and LM silently fell back to shutil.copy2() followed by deleting each original file. That broke torrent seeding, doubled disk use, and silently violated the user's explicit "hard link" preference.

Reported by @kyleviloria on v0.9.0-beta.134 (Docker). Log showed Hard link failed, copy+delete: for every file in their downloaded book.

Changes

  • app.py move_to_output_folder — Added filesystem-compatibility pre-check. When use_hard_links=True and source.stat().st_dev != output.stat().st_dev, return a clear error immediately. No source files are touched. Error text is actionable: "Move your library to the same volume as the watch folder, or disable 'Use hard links' in Settings."
  • app.py move_to_output_folder — Removed the EXDEV copy+delete fallback from both the single-file branch (was ~L6614) and the directory-loop branch (was ~L6642). Other OSErrors (permission, ENOSPC, existing target) propagate to the outer handler, which returns (False, None, error). The watch worker already records that as watch_folder_error with the message visible in the UI.
  • app.py move_to_output_folder — Dropped now-unused used_copy_fallback / files_to_delete / post-loop f.unlink() block. Empty-folder cleanup is now if not use_hard_links and delete_empty (hardlink mode leaves originals in place by design).
  • APP_VERSION bumped 0.9.0-beta.1460.9.0-beta.147.
  • README badge updated + new "Recent Changes" entry for beta.147.
  • CHANGELOG entry for beta.147.

Behavior matrix

Before After
use_hard_links=True, same-fs: hardlink ✓ Same
use_hard_links=True, cross-fs: warn, copy+delete originals Fail fast with clear error, source untouched
use_hard_links=True, other OSError: raise Same (outer handler returns error)
use_hard_links=False: shutil.move (handles cross-fs internally) Same

Test plan

  • Unit: call move_to_output_folder with use_hard_links=True and source/output on different filesystems → returns (False, None, <error mentioning filesystems>), source files still present.
  • Unit: same call with source/output on same filesystem → (True, <dest>, None), hardlinks created, inode matches.
  • Integration (sandbox): reproduce kyle's setup — watch folder on one volume, library on another, watch_use_hard_links=True. Drop a book, run a scan. Expect the book to appear as watch_folder_error in the UI with the filesystem-mismatch message, and every source file still in the watch folder.
  • Integration (sandbox): same filesystem for both, hardlinks enabled. Drop a book, run a scan. Expect successful hardlink (no disk usage for destination), originals still present.
  • Regression: use_hard_links=False path unchanged.
  • venv/bin/python test-env/test-naming-issues.py → 290 passed, 0 failed
  • ruff check app.py --select=F821 → All checks passed

Notes

  • Kyle's log showed every file hitting EXDEV (errno 18) because Docker had the watch folder and library on different volume mounts. After this fix he'll see an error he can actually act on instead of losing his downloads silently.

When 'Use hard links' was enabled and the watch folder and library were on
different filesystems, os.link() raised EXDEV and the code silently fell back
to shutil.copy2() then deleted the originals. That broke torrent seeding,
doubled disk use, and silently violated the user's explicit preference.

- Add filesystem-compatibility pre-check in move_to_output_folder when
  use_hard_links is True. Returns a clear, actionable error if source and
  library are on different st_dev, without touching any source files.
- Remove the EXDEV copy+delete fallback from both the single-file and
  directory-loop branches. Remaining OSErrors (permission, ENOSPC, etc.)
  propagate to the outer handler with source intact; the watch worker records
  the failure as watch_folder_error visible in the UI.
- Bump APP_VERSION to 0.9.0-beta.147 and update README badge + CHANGELOG.

Reported by @kyleviloria.
Copy link
Copy Markdown

@bucket-agent bucket-agent Bot left a comment

Choose a reason for hiding this comment

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

🔍 Vibe Check Review

Context

Removes the silent shutil.copy2 + unlink fallback that move_to_output_folder used when os.link() hit EXDEV, and replaces it with an st_dev pre-check that fails fast with an actionable message before touching any source files.

Codebase Patterns I Verified

  • Read app.py:6393-6573 — confirmed the old used_copy_fallback / files_to_delete bookkeeping (lines 6495-6567 on develop) is what the diff strips out, and grep shows no references to those names anywhere else.
  • Confirmed the pre-check is placed after the output.mkdir(parents=True, exist_ok=True) block (app.py:6411-6415) and after source.exists() validation (app.py:6408), so both source.stat() and output.stat() are safe to call.
  • Confirmed the atomic-move branch at app.py:6480 only runs when use_hard_links is False, so the new guard doesn't interact with it.
  • Grepped for os.link|EXDEV|Invalid cross-device — all hits are inside the removed block. No other location relies on the old fallback.
  • OSError from os.link (e.g. EACCES, ENOSPC) propagates to the outer except Exception handler at the end of the function, which returns (False, None, str(e)) — source files are left intact. Matches the CHANGELOG claim.

✅ Good

  • Pre-check fixes the root cause (EXDEV destroying originals) rather than patching symptoms. Clear, actionable error message names the exact setting to toggle.
  • Guard is cheap (two stat() calls) and only runs when use_hard_links is on — zero cost for users not using hard links.
  • FileNotFoundError from .stat() is correctly caught by except OSError (it's a subclass).
  • Version bumped consistently in APP_VERSION, README badge, "Recent Changes" block, and CHANGELOG entry.
  • Empty-folder cleanup guard at line 6548 was correctly simplified — hardlink mode never needs cleanup now that copy fallback is gone, so not use_hard_links and delete_empty is the right condition.

🚨 Issues Found

No issues found.

📋 Scope Verification

Issue Problem Addressed? Notes
#209 Hard-link failure silently copies + deletes originals Pre-check blocks cross-fs case; remaining OSErrors propagate without deletion.

Scope Status: SCOPE_OK

📝 Documentation Check

  • CHANGELOG.md: ✅ Updated (beta.147 entry explains root cause, fix, and reporter credit)
  • README.md: ✅ Updated (version badge + "Recent Changes" callout flagging user-visible behavior change)

🎯 Verdict

APPROVE

@deucebucket deucebucket merged commit 7864dcf into develop Apr 18, 2026
3 checks passed
@deucebucket deucebucket deleted the fix/issue-209-hardlink-safety branch April 18, 2026 00:27
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