Files
romm/backend/tests/utils/test_conversion_cache.py
T
Georges-Antoine AssiandClaude Opus 5.5 e232a52656 fix(docker): boot when the container runs as a non-default UID (#5189)
* fix(docker): boot when the container runs as a non-default UID

Unraid runs the container as 99:100, which can't write to the image's
/romm (owned by 1000, mode 755), only to the mounted subfolders. The
PSP pending dir at /romm/cache/retroarch_sync was created at import
time, so every gunicorn worker failed to boot.

- Buffer pending PSP save files under the user's assets folder
  (assets/users/<user>/retroarch/psp_pending/), beside the other
  RetroArch Cloud Sync files and outside the client-reachable blob
  categories.
- Move the zip and rom-converto download caches to /tmp/romm/cache,
  and point nginx's internal /cache/ alias there.
- Put gunicorn's control socket in /tmp/gunicorn instead of
  $HOME/.gunicorn, since HOME is / for that UID.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): honor ROMM_TMP_PATH for caches, explain an unwritable sync folder

- The download caches live under $ROMM_TMP_PATH/cache when it's set,
  so installs with a small Docker disk (Unraid's docker.img) can point
  them at a share. The entrypoint exports the same path for nginx's
  /cache/ alias.
- With ENABLE_SYNC_FOLDER_WATCHER on and /romm/sync not writable, /init
  warns and skips the watcher instead of dying on mkdir under errexit.
- The compose example lists the /romm/sync mount the sync features need.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): cover reading back and clearing buffered PSP files

Addresses Greptile's review on #5189.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply code-review findings to PSP pending buffer

Remove the emptied psp_pending folder after a merge, define the sync
folder path once in init, update the architecture doc's PSP and blob
locations, and drop a stale .gitignore entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): apply simplify findings to PSP pending cleanup

Clear the buffered folder with remove_directory, let the startup check
own creating the sync folder, and reword the test fixture docstring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): cap buffered PSP files and drop them on delete

Buffered files for an unresolved save folder now count against the
bundle limits, so a folder can't fill the assets share and then fail
every merge once it resolves. A DELETE also removes the buffered copy,
so a deleted file isn't merged back when the folder resolves.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): check the sync folder for push-pull sync too

ENABLE_SYNC_PUSH_PULL keeps SSH keys and device saves under /romm/sync
as well, so an unwritable folder now disables it with the watcher and
warns at boot instead of failing when the task runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): strip whitespace from env vars like the backend does

The backend strips every variable it reads, while the init script and
nginx templates used raw values, so a padded ROMM_TMP_PATH sent nginx's
/cache/ alias somewhere the backend never writes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(config): drop the CACHE_BASE_PATH comment

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): say which backend config strips env whitespace

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* revert(docker): drop the entrypoint env whitespace trimming

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply pr-ready code-review findings

Gate push-pull on its SSH keys path rather than a writable sync folder,
accept the same truthy values as the backend, and clear only the merged
PSP pending files so another worker's buffered file survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor: apply pr-ready simplify findings

Share one truthy pattern across init's flag checks (the watcher gates
now accept the same values as the backend), disable gunicorn's unused
control socket instead of placing it, derive the converts redirect from
its own cache path, and factor the unknown PSP folder in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): apply pr-ready humanizer pass to the sync mount note

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(fs): create handler base folders on first write, skip leftover temp files

FSHandler no longer mkdirs its base path when constructed, so a module-level
handler rooted at an unmounted or unwritable folder can't take down boot;
every write path already creates its parent folders. Blob listings skip
.romm_tmp_ files a crashed write left behind, so they are never merged into
a PSP bundle or advertised in the sync manifest. The prefix now lives in one
TEMP_FILE_PREFIX constant.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:48:50 -04:00

1039 lines
36 KiB
Python

import asyncio
import fcntl
import os
import shutil
import time
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager, nullcontext
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
import pytest
from adapters.services.rom_converto import (
RomConvertoBinaryNotFoundError,
RomConvertoOperationError,
RomConvertoTimeoutError,
resolve_operation,
)
from config import ROM_CONVERTO_CACHE_PATH
from config.config_manager import ConvertoConfig
from models.rom import Rom, RomFile
from utils import conversion_cache
from utils.conversion_cache import (
FAILED_FILE,
PARTIAL_DIR,
SERVE_GRACE_SECONDS,
FormatOutcome,
FormatResolution,
converted_file_path,
get_cached_converted,
get_or_convert,
get_redirect_path,
offered_download_formats,
parse_formats,
resolve_format_download,
)
def _rom_file(**overrides: Any) -> RomFile:
defaults: dict[str, Any] = {
"rom_id": 1,
"file_name": "game.iso",
"file_path": "psp/roms",
"file_size_bytes": 1000,
"last_modified": 1700000000.0,
}
defaults.update(overrides)
return RomFile(**defaults)
def _final_path(rom_file: RomFile) -> Path:
resolved = resolve_operation("psp", "chd", rom_file.file_name)
assert resolved is not None
return converted_file_path(1, rom_file, *resolved)
def _partial_path(final: Path) -> Path:
return final.parent / PARTIAL_DIR / final.name
def _seed_cached(rom_file: RomFile, *, age_seconds: float = 0) -> Path:
"""Write a converted copy for `rom_file`, last served `age_seconds` ago."""
final = _final_path(rom_file)
final.parent.mkdir(parents=True, exist_ok=True)
final.write_bytes(b"cached")
served = time.time() - age_seconds
os.utime(final, (served, served))
return final
@contextmanager
def _held_lock(key_dir: Path, *, shared: bool = False) -> Iterator[None]:
"""Hold a key dir lock while a request or worker uses it."""
fd = os.open(key_dir, os.O_RDONLY)
fcntl.flock(fd, fcntl.LOCK_SH if shared else fcntl.LOCK_EX)
try:
yield
finally:
os.close(fd)
@pytest.fixture(autouse=True)
def converto(mocker) -> ConvertoConfig:
"""The converto.* settings every cache function reads, editable per test."""
config = ConvertoConfig()
mocker.patch(
"utils.conversion_cache.cm.get_config",
return_value=SimpleNamespace(CONVERTO=config),
)
return config
@pytest.fixture
def cache_root(tmp_path, mocker):
mocker.patch.object(conversion_cache, "ROM_CONVERTO_CACHE_PATH", str(tmp_path))
return tmp_path
@pytest.fixture
def convert_outputs(mocker) -> list[Path]:
"""Fake the adapter: write each requested output and record its path."""
outputs: list[Path] = []
async def convert(operation, src, out) -> None:
outputs.append(out)
out.write_bytes(b"converted")
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
return outputs
class TestGetOrConvert:
async def test_unresolvable_returns_none_without_touching_disk(
self, cache_root, convert_outputs
):
f = _rom_file(file_name="game.zip")
assert await get_or_convert(1, f, "psp", "chd") is None
assert convert_outputs == []
assert not any(cache_root.iterdir())
async def test_cache_hit_returns_path_and_refreshes_mtime(
self, cache_root, convert_outputs
):
f = _rom_file()
final = _seed_cached(f, age_seconds=3600)
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.stat().st_mtime > time.time() - 60
assert convert_outputs == []
async def test_oversized_conversion_returns_none_without_converting(
self, cache_root, converto, convert_outputs, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
assert (
await get_or_convert(1, _rom_file(file_size_bytes=101), "psp", "chd")
is None
)
assert convert_outputs == []
async def test_admission_reserves_room_for_a_decompression(
self, cache_root, converto, convert_outputs, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
f = _rom_file(file_name="game.chd", file_size_bytes=30)
assert await get_or_convert(1, f, "psp", "iso") is None
assert convert_outputs == []
async def test_admission_evicts_an_older_copy_to_make_room(
self, cache_root, converto, convert_outputs, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
older = _seed_cached(_rom_file(file_name="older.iso"))
older.write_bytes(b"x" * 40)
served = time.time() - 300
os.utime(older, (served, served))
f = _rom_file(file_size_bytes=70)
assert await get_or_convert(1, f, "psp", "chd") == _final_path(f)
assert not older.parent.exists()
assert _final_path(f).read_bytes() == b"converted"
async def test_admission_does_not_evict_a_recently_served_copy(
self, cache_root, converto, convert_outputs, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
recent = _seed_cached(_rom_file(file_name="recent.iso"))
recent.write_bytes(b"x" * 80)
f = _rom_file(file_size_bytes=50)
assert await get_or_convert(1, f, "psp", "chd") is None
assert recent.read_bytes() == b"x" * 80
assert convert_outputs == []
@pytest.mark.parametrize(
("size_bytes", "blocked"),
[
pytest.param(101, "none", id="larger-than-cap"),
pytest.param(70, "grace", id="insufficient-outside-grace"),
pytest.param(70, "exclusive", id="conversion-locked"),
pytest.param(70, "shared", id="download-locked"),
],
)
async def test_impossible_admission_preserves_other_copies(
self, cache_root, converto, convert_outputs, mocker, size_bytes, blocked
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
older = _seed_cached(_rom_file(file_name="older.iso"))
protected = _seed_cached(_rom_file(file_name="protected.iso"))
older.write_bytes(b"x" * 40)
protected.write_bytes(b"x" * 50)
now = time.time()
os.utime(older, (now - 300, now - 300))
protected_age = 0 if blocked == "grace" else SERVE_GRACE_SECONDS * 2
os.utime(protected, (now - protected_age, now - protected_age))
holder = (
_held_lock(protected.parent, shared=blocked == "shared")
if blocked in ("exclusive", "shared")
else nullcontext()
)
with holder:
assert (
await get_or_convert(
1, _rom_file(file_size_bytes=size_bytes), "psp", "chd"
)
is None
)
assert older.read_bytes() == b"x" * 40
assert protected.read_bytes() == b"x" * 50
assert convert_outputs == []
async def test_conversion_evicts_the_least_recently_served_other_copy(
self, cache_root, converto, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
oldest = _seed_cached(_rom_file(file_name="old.iso"), age_seconds=300)
newer = _seed_cached(_rom_file(file_name="newer.iso"), age_seconds=200)
oldest.write_bytes(b"x" * 40)
newer.write_bytes(b"x" * 40)
now = time.time()
os.utime(oldest, (now - 300, now - 300))
os.utime(newer, (now - 200, now - 200))
f = _rom_file(file_size_bytes=10)
async def convert(op, src, out) -> None:
out.write_bytes(b"converted" * 6)
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
final = await get_or_convert(1, f, "psp", "chd")
assert final == _final_path(f)
assert final is not None
assert final.read_bytes() == b"converted" * 6
assert not oldest.parent.exists()
assert newer.exists()
assert conversion_cache.cache_size_bytes() <= 100
async def test_post_conversion_cleanup_failure_still_serves_the_copy(
self, cache_root, convert_outputs, mocker
):
f = _rom_file()
final = _final_path(f)
cleanup = conversion_cache.cleanup_stale_conversions
def fail_after_publication(*args, **kwargs):
if final.exists():
raise RuntimeError("cleanup failed")
return cleanup(*args, **kwargs)
mocker.patch.object(
conversion_cache,
"cleanup_stale_conversions",
side_effect=fail_after_publication,
)
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.read_bytes() == b"converted"
async def test_locked_key_dir_returns_none(self, cache_root, convert_outputs):
f = _rom_file()
key_dir = _final_path(f).parent
key_dir.mkdir(parents=True)
with _held_lock(key_dir):
assert await get_or_convert(1, f, "psp", "chd") is None
assert convert_outputs == []
async def test_concurrent_request_returns_none_while_one_converts(
self, cache_root, mocker
):
f = _rom_file()
started = asyncio.Event()
release = asyncio.Event()
async def convert(op, src, out) -> None:
started.set()
await release.wait()
out.write_bytes(b"converted")
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
first = asyncio.create_task(get_or_convert(1, f, "psp", "chd"))
await started.wait()
assert await get_or_convert(1, f, "psp", "chd") is None
release.set()
assert await first == _final_path(f)
async def test_queued_conversion_checks_budget_after_a_slot_opens(
self, cache_root, converto, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
mocker.patch.object(
conversion_cache, "_convert_semaphore", asyncio.Semaphore(1)
)
first_file = _rom_file(file_size_bytes=10)
second_file = _rom_file(file_name="second.iso", file_size_bytes=10)
second_dir = _final_path(second_file).parent
started = asyncio.Event()
release = asyncio.Event()
async def convert(op, src, out) -> None:
started.set()
await release.wait()
out.write_bytes(b"x" * 100)
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
first = asyncio.create_task(get_or_convert(1, first_file, "psp", "chd"))
await started.wait()
second = asyncio.create_task(get_or_convert(1, second_file, "psp", "chd"))
try:
while not second.done():
if second_dir.exists():
with conversion_cache._try_lock(second_dir) as locked:
if not locked:
break
await asyncio.sleep(0)
queued = not second.done()
finally:
release.set()
results = await asyncio.gather(first, second)
assert queued
assert list(results) == [_final_path(first_file), None]
assert _final_path(first_file).read_bytes() == b"x" * 100
assert not _final_path(second_file).exists()
async def test_writes_in_partial_dir_then_renames_into_place(
self, cache_root, convert_outputs
):
f = _rom_file()
final = _final_path(f)
assert await get_or_convert(1, f, "psp", "chd") == final
assert convert_outputs == [_partial_path(final)]
assert final.read_bytes() == b"converted"
assert [p.name for p in final.parent.iterdir()] == [final.name]
async def test_leading_dot_copy_survives_cleanup(self, cache_root, convert_outputs):
f = _rom_file(file_name=".hack - Infection (USA).iso")
final = _final_path(f)
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.name == ".hack - Infection (USA).chd"
assert conversion_cache.cleanup_stale_conversions() == 0
assert final.read_bytes() == b"converted"
assert get_cached_converted(1, f, "psp", "chd") == final
async def test_removes_all_a_crashed_runs_partial_outputs(
self, cache_root, convert_outputs
):
f = _rom_file()
final = _final_path(f)
final.parent.mkdir(parents=True)
partial = _partial_path(final)
partial.parent.mkdir()
for path in (
partial,
partial.with_name(f".{partial.name}.ABCDEF.tmp"),
partial.with_suffix(".bin"),
):
path.write_bytes(b"leftover")
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.read_bytes() == b"converted"
assert list(final.parent.iterdir()) == [final]
@pytest.mark.parametrize(
("error", "writes_partial"),
[
pytest.param(RomConvertoOperationError, False, id="nonzero-exit-no-output"),
pytest.param(RomConvertoOperationError, True, id="nonzero-exit-partials"),
pytest.param(RomConvertoTimeoutError, True, id="timeout-partials"),
],
)
async def test_failed_conversion_retries_only_after_marker_expires(
self, cache_root, converto, mocker, error, writes_partial
):
f = _rom_file()
final = _final_path(f)
marker = final.parent / FAILED_FILE
async def convert(op, src, out) -> None:
if writes_partial:
out.write_bytes(b"partial")
out.with_name(f".{out.name}.ABCDEF.tmp").write_bytes(b"scratch")
out.with_suffix(".bin").write_bytes(b"data")
raise error("boom")
convert_mock = mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
assert await get_or_convert(1, f, "psp", "chd") is None
assert list(final.parent.iterdir()) == [marker]
assert await get_or_convert(1, f, "psp", "chd") is None
convert_mock.assert_awaited_once()
expired = time.time() - converto.cache_ttl_hours * 3600 - 1
os.utime(marker, (expired, expired))
assert conversion_cache.cleanup_stale_conversions() == 1
async def succeed(op, src, out) -> None:
out.write_bytes(b"converted")
convert_mock.side_effect = succeed
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.read_bytes() == b"converted"
assert not marker.exists()
@pytest.mark.parametrize(
"stage",
[
pytest.param("mkdir", id="partial-dir-error"),
pytest.param("replace", id="publication-error"),
pytest.param("convert-os-error", id="subprocess-os-error"),
pytest.param("missing-binary", id="binary-not-found"),
],
)
async def test_transient_failure_retries_the_next_download(
self, cache_root, mocker, stage
):
f = _rom_file()
final = _final_path(f)
failed = False
async def convert(op, src, out) -> None:
nonlocal failed
if not failed and stage in ("convert-os-error", "missing-binary"):
failed = True
error = (
RomConvertoBinaryNotFoundError
if stage == "missing-binary"
else OSError
)
raise error("temporary failure")
out.write_bytes(b"converted")
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
if stage in ("mkdir", "replace"):
write = Path.mkdir if stage == "mkdir" else os.replace
def fail_write_once(*args, **kwargs):
nonlocal failed
if not failed and (stage == "replace" or args[0].name == PARTIAL_DIR):
failed = True
raise OSError("temporary filesystem failure")
return write(*args, **kwargs)
mocker.patch(
"pathlib.Path.mkdir" if stage == "mkdir" else "os.replace",
side_effect=fail_write_once,
autospec=True,
)
assert await get_or_convert(1, f, "psp", "chd") is None
assert not (final.parent / FAILED_FILE).exists()
assert not (final.parent / PARTIAL_DIR).exists()
assert await get_or_convert(1, f, "psp", "chd") == final
assert final.read_bytes() == b"converted"
async def test_cancellation_removes_every_partial_output(self, cache_root, mocker):
f = _rom_file()
final = _final_path(f)
started = asyncio.Event()
async def convert(op, src, out) -> None:
out.write_bytes(b"partial")
out.with_name(f".{out.name}.ABCDEF.tmp").write_bytes(b"scratch")
out.with_suffix(".bin").write_bytes(b"data")
started.set()
await asyncio.Event().wait()
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
conversion = asyncio.create_task(get_or_convert(1, f, "psp", "chd"))
await started.wait()
conversion.cancel()
with pytest.raises(asyncio.CancelledError):
await conversion
assert not final.exists()
assert list(final.parent.iterdir()) == []
class TestOfferedDownloadFormats:
@staticmethod
def _request(*, authenticated: bool = True, kiosk_guest: bool = False) -> Any:
return SimpleNamespace(
user=SimpleNamespace(
is_authenticated=authenticated, is_kiosk_guest=kiosk_guest
)
)
@pytest.fixture(autouse=True)
def enabled(self, mocker, converto):
mocker.patch.object(conversion_cache, "ROM_CONVERTO_ENABLED", True)
converto.download_conversion_enabled = True
def test_lists_what_the_single_file_converts_to(self):
files = [_rom_file(file_name="game.chd")]
assert offered_download_formats("psp", files, self._request()) == [
"cso",
"iso",
"zso",
]
@pytest.mark.parametrize(
"request_kwargs",
[{"authenticated": False}, {"kiosk_guest": True}],
)
def test_nothing_for_a_caller_who_cannot_start_one(self, request_kwargs):
files = [_rom_file(file_name="game.chd")]
assert (
offered_download_formats("psp", files, self._request(**request_kwargs))
== []
)
def test_nothing_for_several_files(self):
files = [_rom_file(file_name="a.chd"), _rom_file(file_name="b.chd")]
assert offered_download_formats("psp", files, self._request()) == []
def test_nothing_with_download_conversion_off(self, converto):
converto.download_conversion_enabled = False
assert offered_download_formats("psp", [_rom_file()], self._request()) == []
def test_nothing_without_rom_converto(self, mocker):
mocker.patch.object(conversion_cache, "ROM_CONVERTO_ENABLED", False)
assert offered_download_formats("psp", [_rom_file()], self._request()) == []
class TestGetCachedConverted:
@pytest.mark.parametrize("file_name", ["game.zip", "game.iso"])
def test_returns_none_when_unresolvable_or_not_cached(self, cache_root, file_name):
f = _rom_file(file_name=file_name)
assert get_cached_converted(1, f, "psp", "chd") is None
@pytest.mark.parametrize("touch", [False, True])
def test_returns_the_copy_refreshing_mtime_only_when_asked(self, cache_root, touch):
f = _rom_file()
final = _seed_cached(f, age_seconds=3600)
assert get_cached_converted(1, f, "psp", "chd", touch=touch) == final
assert (final.stat().st_mtime > time.time() - 60) is touch
@pytest.mark.parametrize(
"touch",
[
pytest.param(False, id="lookup"),
pytest.param(True, id="download"),
],
)
def test_returns_none_while_key_dir_is_locked(self, cache_root, touch):
f = _rom_file()
final = _seed_cached(f)
with _held_lock(final.parent):
assert get_cached_converted(1, f, "psp", "chd", touch=touch) is None
@pytest.mark.parametrize(
"error",
[
pytest.param(FileNotFoundError, id="touch-not-found"),
pytest.param(PermissionError, id="permission-denied"),
],
)
def test_touch_failure_still_serves_the_copy(self, cache_root, mocker, error):
f = _rom_file()
final = _seed_cached(f)
mocker.patch("os.utime", side_effect=error)
assert get_cached_converted(1, f, "psp", "chd", touch=True) == final
assert final.read_bytes() == b"cached"
def test_lock_open_failure_returns_none(self, cache_root, mocker):
f = _rom_file()
_seed_cached(f)
mocker.patch("os.open", side_effect=PermissionError)
assert get_cached_converted(1, f, "psp", "chd") is None
@pytest.mark.parametrize(
"recreate",
[
pytest.param(False, id="removed"),
pytest.param(True, id="recreated"),
],
)
def test_does_not_serve_a_replaced_key_dir(self, cache_root, mocker, recreate):
f = _rom_file()
final = _seed_cached(f)
flock = fcntl.flock
def replace_dir(fd, mode):
flock(fd, mode)
shutil.rmtree(final.parent)
if recreate:
final.parent.mkdir()
final.write_bytes(b"replacement")
mocker.patch("fcntl.flock", side_effect=replace_dir)
assert get_cached_converted(1, f, "psp", "chd") is None
class TestHasRoomFor:
def test_unbounded_when_cap_is_zero(self, cache_root, converto):
converto.cache_max_size_gb = 0
assert conversion_cache.has_room_for(10 * conversion_cache.BYTES_PER_GB)
def test_counts_partial_output_against_the_cap(self, cache_root, converto, mocker):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
key_dir = cache_root / "1-a"
key_dir.mkdir()
(key_dir / "game.chd").write_bytes(b"x" * 40)
partial_dir = key_dir / PARTIAL_DIR
partial_dir.mkdir()
(partial_dir / "other.chd").write_bytes(b"x" * 20)
assert conversion_cache.has_room_for(40)
assert not conversion_cache.has_room_for(41)
@pytest.mark.parametrize(
"vanishes",
[
pytest.param("file", id="vanishing-file"),
pytest.param("dir", id="vanishing-dir"),
],
)
def test_size_walk_tolerates_vanishing_entries(self, cache_root, mocker, vanishes):
vanished = _seed_cached(_rom_file(file_name="vanished.iso"))
survivor = _seed_cached(_rom_file(file_name="survivor.iso"))
stat = os.stat
scandir = os.scandir
if vanishes == "file":
def stat_after_removal(path, *args, **kwargs):
if path == str(vanished):
vanished.unlink(missing_ok=True)
return stat(path, *args, **kwargs)
mocker.patch("os.stat", side_effect=stat_after_removal)
else:
def scan_after_removal(path):
if path == str(vanished.parent):
shutil.rmtree(vanished.parent)
return scandir(path)
mocker.patch("os.scandir", side_effect=scan_after_removal)
assert conversion_cache.cache_size_bytes() == len(b"cached")
assert survivor.read_bytes() == b"cached"
class TestGetRedirectPath:
def test_relative_to_converts_cache_path(self):
converted_path = Path(ROM_CONVERTO_CACHE_PATH) / "1-abc/Game.chd"
assert get_redirect_path(converted_path) == Path(
"/cache/converts/1-abc/Game.chd"
)
class TestCleanupStaleConversions:
def test_missing_root_is_noop(self, tmp_path, mocker):
mocker.patch.object(
conversion_cache, "ROM_CONVERTO_CACHE_PATH", str(tmp_path / "nope")
)
assert conversion_cache.cleanup_stale_conversions() == 0
def test_deletes_expired_and_keeps_fresh(self, cache_root):
expired = _seed_cached(_rom_file(file_name="old.iso"), age_seconds=25 * 3600)
fresh = _seed_cached(_rom_file(file_name="new.iso"))
assert conversion_cache.cleanup_stale_conversions() == 1
assert not expired.parent.exists()
assert fresh.exists()
def test_removes_partial_outputs_from_a_kept_dir(self, cache_root):
final = _seed_cached(_rom_file())
partial = _partial_path(final)
partial.parent.mkdir()
for path in (
partial,
partial.with_name(f".{partial.name}.ABCDEF.tmp"),
partial.with_suffix(".bin"),
):
path.write_bytes(b"leftover")
assert conversion_cache.cleanup_stale_conversions() == 0
assert final.read_bytes() == b"cached"
assert list(final.parent.iterdir()) == [final]
@pytest.mark.parametrize(
"leftover",
[
pytest.param(False, id="empty"),
pytest.param(True, id="partial"),
],
)
def test_deletes_dirs_without_a_served_copy(self, cache_root, leftover):
key_dir = cache_root / "1-e"
key_dir.mkdir()
if leftover:
partial_dir = key_dir / PARTIAL_DIR
partial_dir.mkdir()
(partial_dir / "game.chd").write_bytes(b"partial")
assert conversion_cache.cleanup_stale_conversions() == 1
assert not key_dir.exists()
def test_skips_a_dir_whose_conversion_is_running(self, cache_root):
key_dir = cache_root / "1-busy"
key_dir.mkdir()
partial_dir = key_dir / PARTIAL_DIR
partial_dir.mkdir()
(partial_dir / "game.chd").write_bytes(b"partial")
with _held_lock(key_dir):
assert conversion_cache.cleanup_stale_conversions() == 0
assert key_dir.exists()
def test_kept_copy_remains_available_during_cleanup(self, cache_root, mocker):
f = _rom_file()
final = _seed_cached(f)
files = conversion_cache._files
def serve_during_listing(key_dir):
listed = files(key_dir)
assert get_cached_converted(1, f, "psp", "chd") == final
return listed
mocker.patch.object(
conversion_cache, "_files", side_effect=serve_during_listing
)
assert conversion_cache.cleanup_stale_conversions() == 0
assert final.read_bytes() == b"cached"
def test_evicts_least_recently_served_over_the_cap(
self, cache_root, converto, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
dirs = {}
for name, age in (("old", 300), ("mid", 200), ("new", 100)):
final = _seed_cached(_rom_file(file_name=f"{name}.iso"))
final.write_bytes(b"x" * 40)
os.utime(final, (time.time() - age, time.time() - age))
dirs[name] = final.parent
assert conversion_cache.cleanup_stale_conversions() == 1
assert not dirs["old"].exists()
assert dirs["mid"].exists()
assert dirs["new"].exists()
@pytest.mark.parametrize(
"reserve_bytes",
[
pytest.param(0, id="post-conversion"),
pytest.param(70, id="admission"),
],
)
def test_size_eviction_preserves_failure_markers(
self, cache_root, converto, mocker, reserve_bytes
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
marker_dir = cache_root / "1-failed"
marker_dir.mkdir()
marker = marker_dir / FAILED_FILE
marker.touch()
served = time.time() - 3600
os.utime(marker, (served, served))
recent = _seed_cached(_rom_file())
recent.write_bytes(b"x" * 120)
assert conversion_cache.cleanup_stale_conversions(reserve_bytes) == 0
assert marker.exists()
assert recent.read_bytes() == b"x" * 120
@pytest.mark.parametrize(
("age_seconds", "kept"),
[
pytest.param(SERVE_GRACE_SECONDS // 2, True, id="within-grace"),
pytest.param(SERVE_GRACE_SECONDS * 2, False, id="after-grace"),
],
)
def test_size_eviction_respects_serve_grace(
self, cache_root, converto, mocker, age_seconds, kept
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
final = _seed_cached(_rom_file())
final.write_bytes(b"x" * 120)
served = time.time() - age_seconds
os.utime(final, (served, served))
assert conversion_cache.cleanup_stale_conversions() == (0 if kept else 1)
assert final.exists() is kept
def test_size_eviction_rechecks_a_copy_served_after_the_ttl_pass(
self, cache_root, converto, mocker
):
converto.cache_max_size_gb = 1
mocker.patch.object(conversion_cache, "BYTES_PER_GB", 100)
f = _rom_file()
final = _seed_cached(f)
final.write_bytes(b"x" * 120)
served = time.time() - 120
os.utime(final, (served, served))
iterdir = Path.iterdir
def serve_after_listing(path):
yield from iterdir(path)
if path == cache_root:
assert get_cached_converted(1, f, "psp", "chd", touch=True) == final
mocker.patch.object(
Path, "iterdir", side_effect=serve_after_listing, autospec=True
)
assert conversion_cache.cleanup_stale_conversions() == 0
assert final.read_bytes() == b"x" * 120
class TestParseFormats:
@pytest.mark.parametrize(
("raw", "expected"),
[
pytest.param("zso,iso", ("zso", "iso"), id="in-order"),
pytest.param(" ZSO , iso,zso ", ("zso", "iso"), id="trimmed-deduplicated"),
pytest.param(",,", (), id="empty"),
pytest.param("3ds,GCM", ("cci", "iso"), id="aliases"),
],
)
def test_parses_the_list(self, raw: str, expected: tuple[str, ...]):
assert parse_formats(raw) == expected
class TestResolveFormatDownload:
@pytest.fixture(autouse=True)
def enabled(self, converto, mocker):
converto.download_conversion_enabled = True
mocker.patch(
"utils.conversion_cache.rom_converto_service.is_enabled",
AsyncMock(return_value=True),
)
@staticmethod
def _rom() -> Rom:
return cast(Rom, SimpleNamespace(id=1, platform_slug="psp"))
async def test_a_listed_stored_format_wins_over_a_cached_copy(self, cache_root):
f = _rom_file()
_seed_cached(f)
resolution = await resolve_format_download(
self._rom(), f, ("chd", "iso"), allowed=True, start=True, touch=True
)
assert resolution == FormatResolution(FormatOutcome.ORIGINAL)
async def test_an_aliased_files_own_extension_serves_the_original(
self, cache_root, convert_outputs
):
rom = cast(Rom, SimpleNamespace(id=1, platform_slug="3ds"))
resolution = await resolve_format_download(
rom,
_rom_file(file_name="game.3ds"),
parse_formats("3ds,cia"),
allowed=True,
start=True,
touch=True,
)
assert resolution == FormatResolution(FormatOutcome.ORIGINAL)
assert convert_outputs == []
async def test_a_cached_copy_wins_over_an_earlier_uncached_format(
self, cache_root, convert_outputs
):
f = _rom_file()
final = _seed_cached(f)
resolution = await resolve_format_download(
self._rom(), f, ("cso", "chd"), allowed=True, start=True, touch=True
)
assert resolution == FormatResolution(FormatOutcome.CONVERTED, final)
assert convert_outputs == []
async def test_converts_to_the_first_listed_format_it_can_produce(
self, cache_root, convert_outputs
):
f = _rom_file()
resolution = await resolve_format_download(
self._rom(), f, ("rvz", "cso", "chd"), allowed=True, start=True, touch=True
)
assert resolution.outcome == FormatOutcome.CONVERTED
assert resolution.path is not None and resolution.path.suffix == ".cso"
assert [out.suffix for out in convert_outputs] == [".cso"]
async def test_skips_a_format_whose_conversion_failed(
self, cache_root, convert_outputs
):
f = _rom_file()
failed = _final_path(f).parent
failed.mkdir(parents=True)
(failed / FAILED_FILE).touch()
resolution = await resolve_format_download(
self._rom(), f, ("chd", "cso"), allowed=True, start=True, touch=True
)
assert resolution.path is not None and resolution.path.suffix == ".cso"
async def test_reports_pending_without_starting(self, cache_root, convert_outputs):
resolution = await resolve_format_download(
self._rom(), _rom_file(), ("chd",), allowed=True, start=False, touch=False
)
assert resolution.outcome == FormatOutcome.PENDING
assert convert_outputs == []
async def test_falls_back_to_a_later_format_when_a_conversion_fails(
self, cache_root, mocker
):
async def convert(operation, src, out) -> None:
if out.suffix == ".cso":
raise RomConvertoOperationError("bad image")
out.write_bytes(b"converted")
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
resolution = await resolve_format_download(
self._rom(),
_rom_file(),
("cso", "chd"),
allowed=True,
start=True,
touch=True,
)
assert resolution.path is not None and resolution.path.suffix == ".chd"
async def test_a_locked_key_dir_without_partial_output_is_pending(
self, cache_root, convert_outputs
):
key_dir = _final_path(_rom_file()).parent
key_dir.mkdir(parents=True)
with _held_lock(key_dir):
resolution = await resolve_format_download(
self._rom(), _rom_file(), ("chd",), allowed=True, start=True, touch=True
)
assert resolution.outcome == FormatOutcome.PENDING
assert convert_outputs == []
async def test_a_finished_conversion_is_served_while_a_download_holds_its_dir(
self, cache_root, mocker
):
f = _rom_file()
final = _final_path(f)
async def convert(operation, src, out) -> None:
out.write_bytes(b"converted")
async def convert_then_serve(*args) -> Path | None:
converted = await get_or_convert(*args)
stack.enter_context(_held_lock(final.parent, shared=True))
return converted
mocker.patch(
"utils.conversion_cache.rom_converto_service.convert", side_effect=convert
)
mocker.patch.object(
conversion_cache, "get_or_convert", side_effect=convert_then_serve
)
with ExitStack() as stack:
resolution = await resolve_format_download(
self._rom(), f, ("chd",), allowed=True, start=True, touch=True
)
assert resolution == FormatResolution(FormatOutcome.CONVERTED, final)
async def test_is_pending_without_starting_once_too_many_conversions_run(
self, cache_root, convert_outputs, mocker
):
mocker.patch.object(conversion_cache, "MAX_STARTED_CONVERSIONS", 0)
resolution = await resolve_format_download(
self._rom(), _rom_file(), ("chd",), allowed=True, start=True, touch=True
)
assert resolution.outcome == FormatOutcome.PENDING
assert convert_outputs == []