Skip to content

Commit 405f678

Browse files
authored
Merge pull request #3388 from rommapp/hardlink-resources-gamelist
feat(fs): hardlink import/export assets, harden sync init
2 parents f84796d + 584f35b commit 405f678

10 files changed

Lines changed: 417 additions & 21 deletions

File tree

backend/handler/filesystem/base_handler.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
from starlette.datastructures import UploadFile
1717

1818
from config.config_manager import config_manager as cm
19+
from logger.logger import log
1920
from models.base import FILE_NAME_MAX_LENGTH
20-
from utils.filesystem import iter_directories, iter_files
21+
from utils.filesystem import iter_directories, iter_files, link_or_copy_file
2122

2223
TAG_REGEX = re.compile(r"\(([^)]+)\)|\[([^]]+)\]")
2324
EXTENSION_REGEX = re.compile(r"\.(([a-z]+\.)*\w+)$")
@@ -148,13 +149,22 @@ class Asset(Enum):
148149

149150

150151
class FSHandler:
151-
def __init__(self, base_path: str):
152+
def __init__(self, base_path: str, tolerate_missing_base: bool = False):
152153
self.base_path = Path(base_path).resolve()
153154
self._locks: dict[str, asyncio.Lock] = {}
154155
self._lock_mutex = asyncio.Lock()
155156

156-
# Create base directory synchronously during initialization
157-
self.base_path.mkdir(parents=True, exist_ok=True)
157+
# Create base directory synchronously during initialization.
158+
try:
159+
self.base_path.mkdir(parents=True, exist_ok=True)
160+
except OSError:
161+
if not tolerate_missing_base:
162+
raise
163+
164+
log.warning(
165+
f"Could not create or access {self.base_path}; "
166+
"feature will be unavailable until the directory is writable."
167+
)
158168

159169
async def _get_file_lock(self, file_path: str) -> asyncio.Lock:
160170
"""Get or create a lock for a specific file path."""
@@ -487,13 +497,21 @@ async def stream_file(self, file_path: str):
487497

488498
return await open_file(full_path, "rb")
489499

490-
async def copy_file(self, source_full_path: Path, dest_path: str) -> None:
500+
async def copy_file(
501+
self,
502+
source_full_path: Path,
503+
dest_path: str,
504+
allow_link: bool = False,
505+
) -> None:
491506
"""
492507
Copy a file from source to destination.
493508
494509
Args:
495510
source_full_path: Absolute path to the source file
496511
dest_path: Relative path to the destination file
512+
allow_link: Try a hardlink first and fall back to
513+
a copy when the link isn't possible (cross-device,
514+
unsupported filesystem, etc.)
497515
498516
Raises:
499517
FileNotFoundError: If source file does not exist
@@ -518,7 +536,10 @@ async def copy_file(self, source_full_path: Path, dest_path: str) -> None:
518536
# Create destination directory if needed
519537
dest_parent_anyio_path = AnyioPath(str(dest_full_path.parent))
520538
await dest_parent_anyio_path.mkdir(parents=True, exist_ok=True)
521-
shutil.copy2(str(source_full_path), str(dest_full_path))
539+
if allow_link:
540+
link_or_copy_file(source_full_path, dest_full_path)
541+
else:
542+
shutil.copy2(str(source_full_path), str(dest_full_path))
522543

523544
async def move_file_or_folder(self, source_path: str, dest_path: str) -> None:
524545
"""

backend/handler/filesystem/resources_handler.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ async def _store_cover(
129129
log.warning(f"Cover file not found: {url_cover}")
130130
return None
131131
dest_path = f"{cover_file}/{size.value}.png"
132-
await self.copy_file(resolved, dest_path)
132+
# Small-size covers get resized in place, which would mutate
133+
# the user's source image if the destination were a hardlink.
134+
await self.copy_file(resolved, dest_path, allow_link=False)
133135

134136
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
135137
self.image_converter.convert_to_webp(
@@ -302,7 +304,9 @@ async def _store_screenshot(self, rom: Rom, url_screenhot: str, idx: int):
302304
if resolved is None or not await AnyioPath(resolved).exists():
303305
log.warning(f"Screenshot file not found: {url_screenhot}")
304306
return None
305-
await self.copy_file(resolved, f"{screenshot_path}/{idx}.jpg")
307+
await self.copy_file(
308+
resolved, f"{screenshot_path}/{idx}.jpg", allow_link=True
309+
)
306310
except Exception as exc:
307311
log.error(f"Unable to copy screenshot file {url_screenhot}: {str(exc)}")
308312
return None
@@ -416,7 +420,9 @@ async def _store_manual(self, rom: Rom, url_manual: str):
416420
if resolved is None or not await AnyioPath(resolved).exists():
417421
log.warning(f"Manual file not found: {url_manual}")
418422
return None
419-
await self.copy_file(resolved, f"{manual_path}/{rom.id}.pdf")
423+
await self.copy_file(
424+
resolved, f"{manual_path}/{rom.id}.pdf", allow_link=True
425+
)
420426
except Exception as exc:
421427
log.error(f"Unable to copy manual file {url_manual}: {str(exc)}")
422428
return None
@@ -555,7 +561,7 @@ async def store_media_file(self, url_media: str, dest_path: str) -> None:
555561
try:
556562
resolved = _resolve_local_file_uri(url_media)
557563
if resolved is not None and await AnyioPath(resolved).exists():
558-
await self.copy_file(resolved, dest_path)
564+
await self.copy_file(resolved, dest_path, allow_link=True)
559565
except Exception as exc:
560566
log.error(f"Unable to copy media file {url_media}: {str(exc)}")
561567
return None

backend/handler/filesystem/sync_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class FSSyncHandler(FSHandler):
1212
"""Filesystem handler for sync folder operations (File Transfer mode)."""
1313

1414
def __init__(self) -> None:
15-
super().__init__(base_path=SYNC_BASE_PATH)
15+
super().__init__(base_path=SYNC_BASE_PATH, tolerate_missing_base=True)
1616

1717
def build_incoming_path(
1818
self, device_id: str, platform_slug: str | None = None

backend/tasks/manual/cleanup_orphaned_resources.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ async def run(self) -> dict[str, int]:
8787
platform_dirs: set[int] = {
8888
int(entry.name)
8989
async for entry in roms_resources_dir.iterdir()
90-
if await entry.is_dir()
90+
if entry.name.isdigit() and await entry.is_dir()
9191
}
9292

9393
rom_dirs_by_platform: dict[int, set[int]] = {}
@@ -97,7 +97,7 @@ async def run(self) -> dict[str, int]:
9797
rom_dirs_by_platform[platform_dir] = {
9898
int(entry.name)
9999
async for entry in platform_dir_path.iterdir()
100-
if await entry.is_dir()
100+
if entry.name.isdigit() and await entry.is_dir()
101101
}
102102

103103
cleanup_stats.update(

backend/tests/handler/filesystem/test_base_handler.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import errno
23
import shutil
34
import tempfile
45
from io import BytesIO
@@ -515,3 +516,119 @@ async def create_and_remove_dir(dir_name):
515516
dirs = await handler.list_directories(".")
516517
for i in range(5):
517518
assert f"test_dir_{i}" not in dirs
519+
520+
async def test_copy_file_does_not_hardlink_by_default(
521+
self, handler: FSHandler, sample_file_content
522+
):
523+
"""copy_file defaults to allow_link=False: the destination is a real
524+
copy, so mutating it won't affect the source. Callers must opt in to
525+
hardlinking explicitly."""
526+
source_full = handler.base_path / "source.bin"
527+
source_full.write_bytes(sample_file_content)
528+
529+
await handler.copy_file(source_full, "dest.bin")
530+
531+
dest_full = handler.base_path / "dest.bin"
532+
assert dest_full.read_bytes() == sample_file_content
533+
assert source_full.stat().st_ino != dest_full.stat().st_ino
534+
535+
async def test_copy_file_allow_link_true_hardlinks(
536+
self, handler: FSHandler, sample_file_content
537+
):
538+
"""allow_link=True: when source and dest are on the same filesystem,
539+
the destination is a hardlink to the source (same inode)."""
540+
source_full = handler.base_path / "source.bin"
541+
source_full.write_bytes(sample_file_content)
542+
543+
await handler.copy_file(source_full, "dest.bin", allow_link=True)
544+
545+
dest_full = handler.base_path / "dest.bin"
546+
assert dest_full.read_bytes() == sample_file_content
547+
assert source_full.stat().st_ino == dest_full.stat().st_ino
548+
549+
async def test_copy_file_allow_link_false_does_real_copy(
550+
self, handler: FSHandler, sample_file_content
551+
):
552+
"""allow_link=False (explicit) produces a real copy — content matches
553+
but inodes differ. This matches the default but is exercised explicitly
554+
to lock in the contract."""
555+
source_full = handler.base_path / "source.bin"
556+
source_full.write_bytes(sample_file_content)
557+
558+
await handler.copy_file(source_full, "dest.bin", allow_link=False)
559+
560+
dest_full = handler.base_path / "dest.bin"
561+
assert dest_full.read_bytes() == sample_file_content
562+
assert source_full.stat().st_ino != dest_full.stat().st_ino
563+
564+
async def test_copy_file_allow_link_falls_back_to_copy_on_exdev(
565+
self, handler: FSHandler, sample_file_content
566+
):
567+
"""When allow_link=True and os.link raises EXDEV (cross-filesystem),
568+
copy_file transparently falls back to a real copy."""
569+
source_full = handler.base_path / "source.bin"
570+
source_full.write_bytes(sample_file_content)
571+
572+
with patch(
573+
"utils.filesystem.os.link",
574+
side_effect=OSError(errno.EXDEV, "Cross-device link"),
575+
):
576+
await handler.copy_file(source_full, "dest.bin", allow_link=True)
577+
578+
dest_full = handler.base_path / "dest.bin"
579+
assert dest_full.read_bytes() == sample_file_content
580+
assert source_full.stat().st_ino != dest_full.stat().st_ino
581+
582+
async def test_copy_file_nonexistent_source(self, handler: FSHandler):
583+
"""Missing source must raise FileNotFoundError regardless of link mode."""
584+
with pytest.raises(FileNotFoundError, match="Source file not found"):
585+
await handler.copy_file(handler.base_path / "missing.bin", "dest.bin")
586+
587+
588+
class TestFSHandlerTolerateMissingBase:
589+
"""Tests for the tolerate_missing_base flag, which lets optional features
590+
(like the sync folder) come up degraded instead of crashing the whole app
591+
when the base path can't be created."""
592+
593+
def test_default_raises_on_mkdir_failure(self):
594+
"""Default behavior: an OSError from mkdir propagates, so misconfigured
595+
critical paths (resources, library) still fail loudly at startup."""
596+
with patch.object(
597+
Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied")
598+
):
599+
with pytest.raises(PermissionError):
600+
FSHandler("/some/unwritable/path")
601+
602+
def test_tolerate_missing_base_swallows_oserror(self, tmp_path, caplog):
603+
"""With tolerate_missing_base=True, mkdir failure is logged but does
604+
not raise — the handler instance is still constructed so module-level
605+
imports don't crash."""
606+
target = tmp_path / "missing"
607+
608+
with patch.object(
609+
Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied")
610+
):
611+
handler = FSHandler(str(target), tolerate_missing_base=True)
612+
613+
# Handler exists and base_path is set, even though the directory
614+
# could not be created.
615+
assert handler.base_path == target.resolve()
616+
assert not handler.base_path.exists()
617+
618+
def test_tolerate_missing_base_still_creates_when_possible(self, tmp_path):
619+
"""tolerate_missing_base=True should not change behavior when mkdir
620+
succeeds — the directory must still be created normally."""
621+
target = tmp_path / "new_dir"
622+
assert not target.exists()
623+
624+
handler = FSHandler(str(target), tolerate_missing_base=True)
625+
626+
assert handler.base_path.exists()
627+
assert handler.base_path.is_dir()
628+
629+
def test_tolerate_missing_base_reraises_non_oserror(self, tmp_path):
630+
"""Only OSError gets swallowed; programming errors (e.g. a RuntimeError
631+
from corrupted state) must still surface."""
632+
with patch.object(Path, "mkdir", side_effect=RuntimeError("unexpected")):
633+
with pytest.raises(RuntimeError, match="unexpected"):
634+
FSHandler(str(tmp_path / "x"), tolerate_missing_base=True)

backend/tests/handler/filesystem/test_sync_handler.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Tests for filesystem sync handler."""
22

3+
import errno
34
import os
45
import shutil
56
import tempfile
67
from pathlib import Path
8+
from unittest.mock import patch
79

810
import pytest
911

@@ -133,3 +135,21 @@ def test_remove_incoming_file_outside_base_raises(
133135
def test_remove_incoming_file_nonexistent(self, handler: FSSyncHandler):
134136
# Should not raise for nonexistent files
135137
handler.remove_incoming_file("/nonexistent/path/file.sav")
138+
139+
140+
class TestFSSyncHandlerStartup:
141+
"""Sync is an optional feature: if /romm/sync isn't writable (bad mount,
142+
wrong ownership), the app must still boot. Failures should surface when
143+
sync is actually used, not at module-import time."""
144+
145+
def test_init_does_not_raise_when_base_path_unwritable(self):
146+
"""Regression test for the PermissionError on /romm/sync at boot.
147+
FSSyncHandler must construct successfully even when mkdir fails."""
148+
with patch.object(
149+
Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied")
150+
):
151+
handler = FSSyncHandler()
152+
153+
assert handler is not None
154+
# base_path is set even though the directory wasn't created.
155+
assert isinstance(handler.base_path, Path)

0 commit comments

Comments
 (0)