mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
fix(sloppak): bound the unpack cache; add read_member_bytes() so callers stop unpacking whole songs (#950)
* fix(sloppak): bound the unpack cache, and add a way to read a song without unpacking it A tester's sloppak_cache reached 60 GB from an 1800-song library — his entire library, unpacked, none of it played. Stems are already-compressed audio, so an unpacked pack is ~1.1x its zip: the cache is a second, DECOMPRESSED copy of every song it touches. It had no size cap, no LRU, and no cleanup of any kind — not even when the song itself was deleted. Two halves: 1. resolve_source_dir() now evicts least-recently-used songs to stay under a cap (FEEDBACK_SLOPPAK_CACHE_MAX_MB, default 4 GB ≈ 130 songs of recency; 0 disables). The sweep runs on unpack — the only moment the cache grows — so it can't drift. An evicted song is dropped from _source_cache too: get_cached_source_dir() is the only thing media.py consults before falling back, so a stale path there would 404 every stem for the rest of the process instead of re-unpacking. get_cached_source_dir() now also verifies the dir still exists, which makes "just delete sloppak_cache/ to reclaim disk" safe advice. 2. read_member_bytes() reads ONE file out of a pack without unpacking it — the same trick read_cover_bytes() uses so the library grid doesn't explode every pack to show a cover. Unpacking a whole song to read a few KB of JSON is ~45x write amplification; doing it in a loop over the library is what produced the 60 GB. rig_builder's library-wide tone batch is the caller that did exactly that (fixed separately); this gives it, and everyone else, the right primitive. Eviction is concurrency-safe: unpacks run 2-at-a-time, so a dir being written is marked in-flight and the sweep skips it — checked and rmtree'd under one hold of the guard, and the marker is released even if the unpack raises (a leaked marker would make that dir permanently un-evictable). read_member_bytes normalizes both the requested path AND the archive's stored member names through safe_join, taking the last match — so './arrangements/x.json', backslash members from Windows tooling, and duplicate members that normalize to the same path all read back exactly as unpack-then-read did. Zip-slip is rejected before anything is opened. Tests: tests/test_sloppak_unpack_cache.py. All bite-tested (reverted each fix, watched it fail) — including one that was passing vacuously: a freshly-unpacked dir is the most-recently-used, so the LRU never reaches it and the in-flight race test proved nothing until the packs were sized to force the sweep that far. * test: split semicolon-joined statements (E702) CodeRabbit on #950. Style only; no behaviour change.
This commit is contained in:
+256
-28
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -81,6 +82,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
|||||||
_unpack_locks: dict[str, threading.Lock] = {}
|
_unpack_locks: dict[str, threading.Lock] = {}
|
||||||
_unpack_locks_guard = threading.Lock()
|
_unpack_locks_guard = threading.Lock()
|
||||||
|
|
||||||
|
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
|
||||||
|
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
|
||||||
|
# half-written directory and leave that resolver caching an incomplete song.
|
||||||
|
_unpacking: set[Path] = set()
|
||||||
|
_unpacking_guard = threading.Lock()
|
||||||
|
|
||||||
|
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
|
||||||
|
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
|
||||||
|
# every song it holds, and it used to grow without any bound at all. A tester
|
||||||
|
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
|
||||||
|
# one caller looped the library calling load_song(). Nothing ever deleted any of
|
||||||
|
# it — not even when the song itself was deleted.
|
||||||
|
#
|
||||||
|
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
|
||||||
|
# song I'm playing, and the last few I played" that this cache actually exists
|
||||||
|
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
|
||||||
|
def _unpack_cache_cap_bytes() -> int:
|
||||||
|
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
|
||||||
|
try:
|
||||||
|
mb = int(raw) if raw else 4096
|
||||||
|
except ValueError:
|
||||||
|
mb = 4096
|
||||||
|
return max(0, mb) * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_size(path: Path) -> int:
|
||||||
|
total = 0
|
||||||
|
for f in path.rglob("*"):
|
||||||
|
try:
|
||||||
|
if f.is_file():
|
||||||
|
total += f.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(path: Path) -> None:
|
||||||
|
"""Bump mtime so the LRU sweep below treats this song as recently used.
|
||||||
|
|
||||||
|
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
|
||||||
|
so without this the song you are actively playing looks as stale as one you
|
||||||
|
unpacked days ago — and a burst of unpacks could evict it mid-song.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.utime(path, None)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
|
||||||
|
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
|
||||||
|
|
||||||
|
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
|
||||||
|
certainly the one about to be played.
|
||||||
|
|
||||||
|
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
|
||||||
|
get_cached_source_dir() keeps handing out a path that no longer exists and
|
||||||
|
the media route 404s on every stem instead of re-unpacking (it only falls
|
||||||
|
back to resolve_source_dir when the cache returns None).
|
||||||
|
"""
|
||||||
|
cap = _unpack_cache_cap_bytes()
|
||||||
|
if cap <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
entries = []
|
||||||
|
total = 0
|
||||||
|
for d in root.iterdir():
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
size = _dir_size(d)
|
||||||
|
mtime = d.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
entries.append((mtime, size, d))
|
||||||
|
total += size
|
||||||
|
if total <= cap:
|
||||||
|
return
|
||||||
|
|
||||||
|
keep_resolved = keep.resolve() if keep else None
|
||||||
|
entries.sort(key=lambda e: e[0]) # oldest first
|
||||||
|
for _mtime, size, d in entries:
|
||||||
|
if total <= cap:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
if keep_resolved and d.resolve() == keep_resolved:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
# Check-and-delete under ONE hold of the guard. Releasing between the
|
||||||
|
# two would let a resolver mark this dest in-flight and start writing
|
||||||
|
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
|
||||||
|
# that blocks here simply proceeds afterwards — _unpack_zip recreates
|
||||||
|
# the directory anyway.
|
||||||
|
with _unpacking_guard:
|
||||||
|
if d in _unpacking:
|
||||||
|
continue # another thread is writing this
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
if d.exists():
|
||||||
|
continue # couldn't remove — don't claim the bytes back
|
||||||
|
total -= size
|
||||||
|
with _source_lock:
|
||||||
|
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
|
||||||
|
if cached_dir == d:
|
||||||
|
_source_cache.pop(fn, None)
|
||||||
|
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
|
||||||
|
d.name, size / 1e6)
|
||||||
|
except OSError:
|
||||||
|
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||||
@@ -145,10 +256,17 @@ def resolve_source_dir(
|
|||||||
re-unpacks if mtime/size changed, then returns that dir.
|
re-unpacks if mtime/size changed, then returns that dir.
|
||||||
|
|
||||||
Caches the resolution so subsequent calls are ~free.
|
Caches the resolution so subsequent calls are ~free.
|
||||||
|
|
||||||
|
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
|
||||||
|
song you are about to play. To read a *part* of a song (an arrangement, the
|
||||||
|
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
|
||||||
|
KB of JSON is ~45x write amplification, and doing it in a loop over the
|
||||||
|
library fills the disk with a decompressed copy of every song.
|
||||||
"""
|
"""
|
||||||
path = dlc_root / filename
|
path = dlc_root / filename
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
mtime, size = stat.st_mtime, stat.st_size
|
mtime, size = stat.st_mtime, stat.st_size
|
||||||
|
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
|
||||||
|
|
||||||
with _source_lock:
|
with _source_lock:
|
||||||
cached = _source_cache.get(filename)
|
cached = _source_cache.get(filename)
|
||||||
@@ -159,42 +277,76 @@ def resolve_source_dir(
|
|||||||
and cached_size == size
|
and cached_size == size
|
||||||
and cached_dir.exists()
|
and cached_dir.exists()
|
||||||
):
|
):
|
||||||
|
# Mark it recently-used before returning — see _touch().
|
||||||
|
if cached_dir != path:
|
||||||
|
_touch(cached_dir)
|
||||||
return cached_dir
|
return cached_dir
|
||||||
|
|
||||||
if path.is_dir():
|
try:
|
||||||
resolved = path
|
if path.is_dir():
|
||||||
else:
|
resolved = path
|
||||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
else:
|
||||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||||
dest = unpack_cache_root / _safe_id(filename)
|
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||||
with _unpack_lock_for(filename):
|
dest = unpack_cache_root / _safe_id(filename)
|
||||||
# Re-check the cache inside the per-file lock — a prior holder may
|
with _unpack_lock_for(filename):
|
||||||
# have just finished unpacking this exact (mtime, size).
|
# Re-check the cache inside the per-file lock — a prior holder may
|
||||||
with _source_lock:
|
# have just finished unpacking this exact (mtime, size).
|
||||||
cached = _source_cache.get(filename)
|
with _source_lock:
|
||||||
if (
|
cached = _source_cache.get(filename)
|
||||||
cached
|
if (
|
||||||
and cached[1] == mtime
|
cached
|
||||||
and cached[2] == size
|
and cached[1] == mtime
|
||||||
and cached[0].exists()
|
and cached[2] == size
|
||||||
):
|
and cached[0].exists()
|
||||||
resolved = cached[0]
|
):
|
||||||
else:
|
resolved = cached[0]
|
||||||
with _unpack_semaphore:
|
else:
|
||||||
_unpack_zip(path, dest)
|
# Shield `dest` from eviction from the moment we start writing
|
||||||
resolved = dest
|
# until it is safely in _source_cache. `keep` only shields it
|
||||||
|
# from OUR OWN sweep — a concurrent resolver sweeping with a
|
||||||
|
# different `keep` would delete it, and we would then cache and
|
||||||
|
# return a path that no longer exists. The `finally` below
|
||||||
|
# releases it on EVERY exit, including a failed unpack: leaving
|
||||||
|
# a dest marked in-flight would make it un-evictable forever.
|
||||||
|
with _unpacking_guard:
|
||||||
|
_unpacking.add(dest)
|
||||||
|
guarded = dest
|
||||||
|
with _unpack_semaphore:
|
||||||
|
_unpack_zip(path, dest)
|
||||||
|
resolved = dest
|
||||||
|
# The only moment this cache grows. Sweep here rather than on a
|
||||||
|
# timer so it can never drift far past the cap.
|
||||||
|
_evict_unpack_cache(unpack_cache_root, keep=dest)
|
||||||
|
|
||||||
with _source_lock:
|
with _source_lock:
|
||||||
_source_cache[filename] = (resolved, mtime, size)
|
_source_cache[filename] = (resolved, mtime, size)
|
||||||
return resolved
|
return resolved
|
||||||
|
finally:
|
||||||
|
if guarded is not None:
|
||||||
|
with _unpacking_guard:
|
||||||
|
_unpacking.discard(guarded)
|
||||||
|
|
||||||
|
|
||||||
def get_cached_source_dir(filename: str) -> Path | None:
|
def get_cached_source_dir(filename: str) -> Path | None:
|
||||||
"""Return the cached source dir for a sloppak if one is known."""
|
"""Return the cached source dir for a sloppak if one is known AND still there.
|
||||||
|
|
||||||
|
The existence check is load-bearing: callers (media.py) only fall back to
|
||||||
|
resolve_source_dir() when this returns None, so handing back a path that has
|
||||||
|
been evicted — or that the user deleted by hand to reclaim disk — would 404
|
||||||
|
every stem for the rest of the process instead of re-unpacking.
|
||||||
|
"""
|
||||||
with _source_lock:
|
with _source_lock:
|
||||||
cached = _source_cache.get(filename)
|
cached = _source_cache.get(filename)
|
||||||
return cached[0] if cached else None
|
if not cached:
|
||||||
|
return None
|
||||||
|
src = cached[0]
|
||||||
|
if not src.is_dir():
|
||||||
|
_source_cache.pop(filename, None)
|
||||||
|
return None
|
||||||
|
_touch(src)
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
# ── Manifest + song loading ───────────────────────────────────────────────────
|
# ── Manifest + song loading ───────────────────────────────────────────────────
|
||||||
@@ -233,6 +385,82 @@ def load_manifest(path: Path) -> dict:
|
|||||||
return _read_manifest_from_zip(path)
|
return _read_manifest_from_zip(path)
|
||||||
|
|
||||||
|
|
||||||
|
_ZIP_ROOT = Path("/_root").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_member_key(name: str) -> str | None:
|
||||||
|
"""Canonical lookup key for a zip member name, or None if it escapes the root.
|
||||||
|
|
||||||
|
Collapses './', 'a/../b' and backslash separators — the same normalization
|
||||||
|
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
|
||||||
|
for AND the names the archive actually stores must go through this, or a pack
|
||||||
|
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
|
||||||
|
"""
|
||||||
|
safe = safe_join(_ZIP_ROOT, name or "")
|
||||||
|
# None → escapes the root; == root → a degenerate name like "." or "a/..".
|
||||||
|
if safe is None or safe == _ZIP_ROOT:
|
||||||
|
return None
|
||||||
|
return safe.relative_to(_ZIP_ROOT).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def read_member_bytes(path: Path, rel: str) -> bytes | None:
|
||||||
|
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
|
||||||
|
|
||||||
|
For a zipped sloppak this opens that single member instead of unpacking the
|
||||||
|
archive — the same trick read_cover_bytes() uses to keep the library grid
|
||||||
|
from exploding every pack just to show a cover.
|
||||||
|
|
||||||
|
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
|
||||||
|
the lyrics, a tone blob) rather than a song you're about to play. The
|
||||||
|
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
|
||||||
|
pack — every stem — into the unpack cache. That is a ~45x write amplification
|
||||||
|
when all you wanted was a few KB of JSON, and looping the library on it
|
||||||
|
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
|
||||||
|
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
|
||||||
|
zip: the cache becomes a second, decompressed copy of everything it touches.
|
||||||
|
"""
|
||||||
|
rel = (rel or "").strip()
|
||||||
|
if not rel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
target = safe_join(path.resolve(), rel)
|
||||||
|
if target is None or not target.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return target.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
|
||||||
|
# open anything, and both sides of the comparison are normalized, so a
|
||||||
|
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
|
||||||
|
# way it did when we unpacked first.
|
||||||
|
member = _zip_member_key(rel)
|
||||||
|
if member is None:
|
||||||
|
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(str(path), "r") as zf:
|
||||||
|
# Match on the NORMALIZED stored name, and take the LAST match — the
|
||||||
|
# archive may store './x' or a backslash path (Windows tooling), and
|
||||||
|
# if it stores two names that normalize to the same file, _unpack_zip
|
||||||
|
# writes them in order so the last one wins. Reading the raw member by
|
||||||
|
# exact name would miss the first case and return the wrong bytes in
|
||||||
|
# the second. A pack has a handful of members; the scan is free.
|
||||||
|
info = None
|
||||||
|
for cand in zf.infolist():
|
||||||
|
if _zip_member_key(cand.filename) == member:
|
||||||
|
info = cand
|
||||||
|
if info is None or info.is_dir():
|
||||||
|
return None
|
||||||
|
with zf.open(info) as f:
|
||||||
|
return f.read()
|
||||||
|
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
||||||
|
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_COVER_MEDIA_TYPES = {
|
_COVER_MEDIA_TYPES = {
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
".png": "image/png", ".webp": "image/webp",
|
".png": "image/png", ".webp": "image/webp",
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""The unpack cache is bounded, and reading part of a song doesn't explode it.
|
||||||
|
|
||||||
|
`sloppak_cache/` holds every song ever unpacked, fully decompressed. Stems are
|
||||||
|
already-compressed audio, so an unpacked song is ~1.1x its zip — the cache is a
|
||||||
|
second copy of the library. It used to have no cap, no LRU, and no cleanup at
|
||||||
|
all: a tester reached 60 GB from an 1800-song library because one caller looped
|
||||||
|
the library calling load_song() (rig_builder's library-wide tone batch), which
|
||||||
|
unpacks the WHOLE pack — stems included — to read a few KB of tone JSON.
|
||||||
|
|
||||||
|
Pins, so neither half can silently come back:
|
||||||
|
- resolve_source_dir() evicts LRU songs to stay under the cap,
|
||||||
|
- it never evicts the song the caller just asked for,
|
||||||
|
- an evicted song is dropped from _source_cache too (otherwise the media route
|
||||||
|
keeps serving a path that no longer exists and 404s every stem instead of
|
||||||
|
re-unpacking),
|
||||||
|
- get_cached_source_dir() self-heals if the cache dir is deleted by hand,
|
||||||
|
- read_member_bytes() reads one file WITHOUT unpacking anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
|
||||||
|
|
||||||
|
STEM = b"\x00" * (400 * 1024) # 400 KB of "audio" — the bulk of a real pack
|
||||||
|
ARR = b'{"tones": {"definitions": [{"Key": "clean"}]}}'
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_pack(path, stem_bytes=STEM):
|
||||||
|
with zipfile.ZipFile(path, "w") as zf:
|
||||||
|
zf.writestr("manifest.yaml", yaml.safe_dump({
|
||||||
|
"title": path.stem,
|
||||||
|
"arrangements": [{"file": "arrangements/lead.json", "name": "Lead"}],
|
||||||
|
"stems": [{"id": "full", "file": "stems/audio.ogg"}],
|
||||||
|
}))
|
||||||
|
zf.writestr("arrangements/lead.json", ARR)
|
||||||
|
zf.writestr("stems/audio.ogg", stem_bytes)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_module_state():
|
||||||
|
# _source_cache is module state and would leak across tests.
|
||||||
|
importlib.reload(sloppak_mod)
|
||||||
|
yield
|
||||||
|
importlib.reload(sloppak_mod)
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_mb(monkeypatch, mb):
|
||||||
|
monkeypatch.setenv("FEEDBACK_SLOPPAK_CACHE_MAX_MB", str(mb))
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_does_not_unpack(tmp_path, monkeypatch):
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
pack = _zip_pack(dlc / "song.feedpak")
|
||||||
|
|
||||||
|
data = sloppak_mod.read_member_bytes(pack, "arrangements/lead.json")
|
||||||
|
|
||||||
|
assert data == ARR
|
||||||
|
assert list(cache.iterdir()) == [], (
|
||||||
|
"reading one member must not unpack the pack — this is the whole point: "
|
||||||
|
"load_song() would have written the 400 KB stem to disk to get 45 bytes of JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_missing_member_is_none(tmp_path):
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = _zip_pack(dlc / "song.feedpak")
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "arrangements/nope.json") is None
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpack_cache_evicts_lru_to_stay_under_cap(tmp_path, monkeypatch):
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 1) # 1 MB — holds ~2 of our 400 KB packs
|
||||||
|
|
||||||
|
for i in range(6):
|
||||||
|
_zip_pack(dlc / f"song{i}.feedpak")
|
||||||
|
|
||||||
|
for i in range(6):
|
||||||
|
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||||
|
|
||||||
|
total = sum(f.stat().st_size for f in cache.rglob("*") if f.is_file())
|
||||||
|
assert total <= 1 * 1024 * 1024, (
|
||||||
|
f"unpack cache ran to {total/1e6:.1f} MB against a 1 MB cap — this is the "
|
||||||
|
"unbounded growth that reached 60 GB in the field"
|
||||||
|
)
|
||||||
|
# The most recent song must survive; the oldest must not.
|
||||||
|
names = {d.name for d in cache.iterdir()}
|
||||||
|
assert "song5.feedpak" in names, "the song just resolved must never be evicted"
|
||||||
|
assert "song0.feedpak" not in names, "the least-recently-used song should go first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eviction_drops_the_source_cache_entry(tmp_path, monkeypatch):
|
||||||
|
"""An evicted song must not keep being handed out by get_cached_source_dir().
|
||||||
|
|
||||||
|
media.py only falls back to resolve_source_dir() when this returns None. If a
|
||||||
|
stale path survives, every stem 404s for the rest of the process instead of
|
||||||
|
re-unpacking — a silently broken song, not a slow one.
|
||||||
|
"""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 1)
|
||||||
|
|
||||||
|
for i in range(6):
|
||||||
|
_zip_pack(dlc / f"song{i}.feedpak")
|
||||||
|
for i in range(6):
|
||||||
|
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||||
|
|
||||||
|
evicted = sloppak_mod.get_cached_source_dir("song0.feedpak")
|
||||||
|
assert evicted is None, "an evicted song must be dropped from _source_cache"
|
||||||
|
|
||||||
|
# ...and asking for it again just re-unpacks it. Self-healing, not broken.
|
||||||
|
again = sloppak_mod.resolve_source_dir("song0.feedpak", dlc, cache)
|
||||||
|
assert (again / "stems" / "audio.ogg").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_source_dir_self_heals_after_manual_delete(tmp_path, monkeypatch):
|
||||||
|
"""Telling a user to delete sloppak_cache/ to reclaim disk must be safe."""
|
||||||
|
import shutil
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 0) # eviction off — isolate the delete
|
||||||
|
_zip_pack(dlc / "song.feedpak")
|
||||||
|
|
||||||
|
src = sloppak_mod.resolve_source_dir("song.feedpak", dlc, cache)
|
||||||
|
assert sloppak_mod.get_cached_source_dir("song.feedpak") == src
|
||||||
|
|
||||||
|
shutil.rmtree(src) # the user clears the folder
|
||||||
|
|
||||||
|
assert sloppak_mod.get_cached_source_dir("song.feedpak") is None, (
|
||||||
|
"a path that no longer exists must not be served — the caller would 404 "
|
||||||
|
"every stem instead of re-unpacking"
|
||||||
|
)
|
||||||
|
assert (sloppak_mod.resolve_source_dir("song.feedpak", dlc, cache)
|
||||||
|
/ "stems" / "audio.ogg").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_of_zero_disables_eviction(tmp_path, monkeypatch):
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 0)
|
||||||
|
for i in range(4):
|
||||||
|
_zip_pack(dlc / f"song{i}.feedpak")
|
||||||
|
for i in range(4):
|
||||||
|
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||||
|
assert len(list(cache.iterdir())) == 4, "cap 0 must mean 'never evict'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_normalizes_non_canonical_names(tmp_path):
|
||||||
|
"""A manifest may name a member './arrangements/lead.json' — valid, and it
|
||||||
|
resolved fine once unpacked. Reading the zip member by the raw string would
|
||||||
|
KeyError and silently report no tones. Same trap read_cover_bytes already hit."""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = _zip_pack(dlc / "song.feedpak")
|
||||||
|
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "./arrangements/lead.json") == ARR
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "stems/../arrangements/lead.json") == ARR
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "arrangements\\lead.json") == ARR
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_rejects_zip_slip(tmp_path):
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = _zip_pack(dlc / "song.feedpak")
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "../../etc/passwd") is None
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "/etc/passwd") is None
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, ".") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_eviction_never_deletes_an_in_flight_unpack(tmp_path, monkeypatch):
|
||||||
|
"""Two unpacks run concurrently (_UNPACK_MAX_CONCURRENCY = 2). One finishing
|
||||||
|
must not rmtree the other's half-written dir — that resolver would then cache
|
||||||
|
an incomplete song and serve a broken pack.
|
||||||
|
|
||||||
|
Sized so the sweep genuinely has to reach the in-flight directory: each pack
|
||||||
|
is ~700 KB against a 1 MB cap, so once `keep` is protected the sweep must
|
||||||
|
delete EVERY other dir to get under the cap — including the one being written.
|
||||||
|
(A naive version of this test passes even without the guard, because a
|
||||||
|
freshly-created dir is the most-recently-used and the sweep never gets to it.)
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
big = b"\x00" * (700 * 1024)
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 1)
|
||||||
|
for i in range(3):
|
||||||
|
_zip_pack(dlc / f"song{i}.feedpak", stem_bytes=big)
|
||||||
|
|
||||||
|
victim = cache / "song2.feedpak"
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
real_unpack = sloppak_mod._unpack_zip
|
||||||
|
|
||||||
|
def slow_unpack(zip_path, dest):
|
||||||
|
real_unpack(zip_path, dest) # dir now exists — "half written"
|
||||||
|
if dest == victim:
|
||||||
|
started.set()
|
||||||
|
release.wait(5) # hold it open while the other sweeps
|
||||||
|
|
||||||
|
monkeypatch.setattr(sloppak_mod, "_unpack_zip", slow_unpack)
|
||||||
|
|
||||||
|
t = threading.Thread(target=sloppak_mod.resolve_source_dir,
|
||||||
|
args=("song2.feedpak", dlc, cache))
|
||||||
|
t.start()
|
||||||
|
assert started.wait(5), "victim unpack did not start"
|
||||||
|
|
||||||
|
# song0 lands and sweeps: keep=song0, cache holds song0+song2 = 1.4 MB > 1 MB,
|
||||||
|
# so the sweep MUST try to delete song2 — which is still being written.
|
||||||
|
sloppak_mod.resolve_source_dir("song0.feedpak", dlc, cache)
|
||||||
|
in_flight_survived = victim.is_dir()
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
t.join(5)
|
||||||
|
|
||||||
|
assert in_flight_survived, (
|
||||||
|
"eviction deleted a directory another thread was still unpacking into — "
|
||||||
|
"that resolver caches an incomplete song and serves a broken pack"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_finds_backslash_members(tmp_path):
|
||||||
|
"""Windows-authored packs store members as 'arrangements\\lead.json'.
|
||||||
|
_unpack_zip() normalizes those on extract, so unpack-then-read found them.
|
||||||
|
An exact getinfo() would not — and we'd silently report the song has no tones."""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = dlc / "win.feedpak"
|
||||||
|
with zipfile.ZipFile(pack, "w") as zf:
|
||||||
|
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "w"}))
|
||||||
|
zf.writestr("arrangements\\lead.json", ARR) # backslash member name
|
||||||
|
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_finds_non_canonical_STORED_names(tmp_path):
|
||||||
|
"""The archive itself may store './arrangements/lead.json'. _unpack_zip()
|
||||||
|
normalizes stored names on extract, so unpack-then-read resolved it. Both the
|
||||||
|
requested path and the stored name must be normalized, or the tones vanish."""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = dlc / "odd.feedpak"
|
||||||
|
with zipfile.ZipFile(pack, "w") as zf:
|
||||||
|
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "o"}))
|
||||||
|
zf.writestr("./arrangements/lead.json", ARR) # stored non-canonically
|
||||||
|
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_member_bytes_matches_unpack_last_write_wins(tmp_path):
|
||||||
|
"""If a pack stores two names that normalize to the same file, _unpack_zip
|
||||||
|
writes them in order and the LAST one is what ends up on disk. Reading the
|
||||||
|
raw member by exact name would hand back the first — stale arrangement data
|
||||||
|
that no unpacked read would ever have produced."""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
pack = dlc / "dupe.feedpak"
|
||||||
|
with zipfile.ZipFile(pack, "w") as zf:
|
||||||
|
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "d"}))
|
||||||
|
zf.writestr("arrangements/lead.json", b'{"tones": {"definitions": [{"Key": "STALE"}]}}')
|
||||||
|
zf.writestr("./arrangements/lead.json", ARR) # normalizes to the same path
|
||||||
|
|
||||||
|
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_unpack_does_not_leave_the_dir_un_evictable(tmp_path, monkeypatch):
|
||||||
|
"""A dir marked in-flight is skipped by eviction. If a failed unpack leaves the
|
||||||
|
marker behind, that dir becomes permanently un-evictable — a slow leak of
|
||||||
|
exactly the thing this cap exists to prevent."""
|
||||||
|
dlc = tmp_path / "dlc"
|
||||||
|
dlc.mkdir()
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
_cap_mb(monkeypatch, 1)
|
||||||
|
_zip_pack(dlc / "boom.feedpak")
|
||||||
|
|
||||||
|
def blow_up(zip_path, dest):
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sloppak_mod, "_unpack_zip", blow_up)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
sloppak_mod.resolve_source_dir("boom.feedpak", dlc, cache)
|
||||||
|
|
||||||
|
assert not sloppak_mod._unpacking, (
|
||||||
|
"a failed unpack left its destination marked in-flight — eviction will "
|
||||||
|
"skip it forever"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user