fix(downloader): correct truncation, ranged sizing, and session carry

Check a sequential download against the complete length whenever one is known, and settle the unknown-total case on the 416. An empty body of unknown length, a 206 with no complete length, and a 206 starting past the resume offset all passed the size checks before and were renamed into place. Four restart paths spent no attempt, so a host that always answered the same way cycled forever.
This commit is contained in:
imSp4rky
2026-09-06 20:03:08 -06:00
parent dffb8b27e3
commit 22cc739e83
13 changed files with 1895 additions and 121 deletions
@@ -0,0 +1,40 @@
"""No test may patch ``load_cdm`` on the CDM package with ``setattr``.
``unshackle.core.cdm`` resolves ``load_cdm`` through a lazy ``__getattr__`` (PEP 562), so the
name never sits in the package dict. ``monkeypatch.setattr`` on the package reads the old value
through ``__getattr__`` and, on undo, writes the real function into the package dict. That key
shadows ``__getattr__`` for the rest of the process, and every later test that patches
``unshackle.core.cdm.loader`` loses its stub. ``tests/commands/test_cdm_cache.py`` ran the real
loader and raised ``ValueError: device_b does not exist``.
``monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", ...)`` leaves no residue: it deletes the key
it added. This guard names the file at fault instead of leaving the damage to surface in an
unrelated test.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List
import pytest
pytestmark = pytest.mark.unit
TESTS_ROOT = Path(__file__).resolve().parents[2]
# A setattr of load_cdm on anything but the loader module, which holds the name for real.
PACKAGE_SETATTR = re.compile(r"""setattr\(\s*(?!loader\b)[\w.]+\s*,\s*["']load_cdm["']""")
def test_no_test_patches_load_cdm_on_the_package() -> None:
offenders: List[str] = []
for path in sorted(TESTS_ROOT.rglob("test_*.py")):
if path == Path(__file__).resolve():
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if PACKAGE_SETATTR.search(line):
offenders.append(f"{path.relative_to(TESTS_ROOT)}:{number}: {line.strip()}")
assert not offenders, "patch the package dict with monkeypatch.setitem instead:\n" + "\n".join(offenders)
@@ -33,9 +33,6 @@ PSSH_B64 = base64.b64encode(b"pssh").decode()
SESSION_ID = b"sid-bytes"
# --- fake services, one per licence signature shape -------------------------------------
class _Recorder:
"""Base for the fake services; every licence call lands in `calls`."""
@@ -86,9 +83,6 @@ class VarKwService(_Recorder):
return "<License>ok</License>"
# --- stub DRM objects that keep the real get_content_keys call shape ---------------------
class StubWidevine(Widevine):
"""Real `Widevine.get_content_keys`, with the PSSH parsing skipped."""
@@ -153,14 +147,11 @@ class FakePlayReadyCdm:
return None
# --- fixtures ---------------------------------------------------------------------------
@pytest.fixture
def widevine_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(wv_pssh_mod, "PSSH", lambda b64: SimpleNamespace(b64=b64))
monkeypatch.setattr(drm_mod, "Widevine", StubWidevine)
monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakeWidevineCdm())
monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: FakeWidevineCdm())
monkeypatch.setattr(detect_mod, "is_widevine_cdm", lambda cdm: True)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
@@ -173,7 +164,7 @@ def widevine_env(monkeypatch: pytest.MonkeyPatch) -> None:
def playready_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(pr_pssh_mod, "PSSH", lambda data: SimpleNamespace(data=data))
monkeypatch.setattr(drm_mod, "PlayReady", StubPlayReady)
monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakePlayReadyCdm())
monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: FakePlayReadyCdm())
monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
@@ -192,9 +183,6 @@ def _server_cdm(service: Any, drm_type: str) -> dict[str, str]:
)
# --- server_cdm, Widevine ---------------------------------------------------------------
def test_server_cdm_widevine_classic_signature_gets_three_arguments(widevine_env: None) -> None:
service = ClassicService()
keys = _server_cdm(service, "widevine")
@@ -252,9 +240,6 @@ def test_server_cdm_widevine_lambda_is_never_itself_filtered(widevine_env: None)
assert "session_id" not in service.calls[0]
# --- server_cdm, PlayReady --------------------------------------------------------------
def test_server_cdm_playready_classic_signature_gets_three_arguments(playready_env: None) -> None:
service = ClassicService()
keys = _server_cdm(service, "playready")
@@ -281,9 +266,6 @@ def test_server_cdm_playready_session_service_is_not_passed_session_id(playready
assert service.calls[0]["session_id"] is None # the signature default, never an offered value
# --- proxy license ----------------------------------------------------------------------
def _proxy(service: Any, drm_type: str) -> Any:
return handlers.handle_proxy_license(
service=service,
@@ -333,9 +315,6 @@ def test_proxy_license_rejects_an_unknown_drm_type() -> None:
_proxy(ClassicService(), "monalisa")
# --- the base-class PlayReady delegate --------------------------------------------------
class _DelegatingService(Service):
"""A service with no PlayReady implementation: the base class delegates to Widevine."""
@@ -50,7 +50,7 @@ class _FakePlayReady:
def playready_env(monkeypatch):
monkeypatch.setattr(pr_pssh_mod, "PSSH", _FakePRPSSH)
monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady)
monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: object())
monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: object())
monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
@@ -0,0 +1,252 @@
"""One remote session owns one service instance, so its handlers must take turns.
``session_titles_handler`` and ``session_tracks_handler`` call the service from a worker
thread. Both calls reach the same long-lived ``SessionEntry.service_instance``, and services
keep per-title state on it: HTTP session headers, per-title tokens, cached manifest
attributes. Two requests on one remote session that run in that instance at the same time
corrupt each other's state, and the track response then hands the client the wrong headers.
The probe here reports how many service calls are inside the instance at once. Its first
entrant waits a short window for company, so the test sees a genuinely concurrent run instead
of racing past it. A slow machine can only make a concurrent run look serialised, so these
tests cannot flake red.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import threading
from types import SimpleNamespace
from typing import Any, Iterator, List
import pytest
from unshackle.core.api import handlers
from unshackle.core.api.session_store import SessionEntry, get_session_store
from unshackle.core.titles.movie import Movie
pytestmark = pytest.mark.unit
# How long the first service call waits for a second one to join it.
OVERLAP_WINDOW = 0.5
# Bounds the wait when the loop is blocked, so a regression fails the test instead of hanging.
HANDSHAKE_TIMEOUT = 5.0
class Overlap:
"""Records how many service calls are inside the service instance at once."""
def __init__(self, window: float = OVERLAP_WINDOW) -> None:
self.window = window
self.max_active = 0
self.trace: List[str] = []
self._lock = threading.Lock()
self._active = 0
self._joined = threading.Event()
self._waited = False
@contextlib.contextmanager
def call(self, label: str) -> Iterator[None]:
with self._lock:
self._active += 1
self.max_active = max(self.max_active, self._active)
self.trace.append(f"+{label}")
if self._active > 1:
self._joined.set()
first = not self._waited
self._waited = True
# Only the first call pays the window. Serialised handlers then cost one wait, not one
# per call, and a concurrent second call releases it as soon as it arrives.
if first:
self._joined.wait(timeout=self.window)
try:
yield
finally:
with self._lock:
self._active -= 1
self.trace.append(f"-{label}")
def a_movie(title_id: str) -> Movie:
return Movie(id_=title_id, service=object, name=f"Film {title_id}", year=2024, language=None)
def empty_tracks() -> Any:
return SimpleNamespace(videos=[], audio=[], subtitles=[], attachments=[])
class ServiceProbe:
"""A service whose calls report their overlap into a shared recorder."""
def __init__(self, overlap: Overlap, name: str = "probe") -> None:
self.overlap = overlap
self.name = name
self.session = SimpleNamespace(headers={}, cookies=[])
def get_titles(self) -> List[Movie]:
with self.overlap.call(f"{self.name}:titles"):
return [a_movie("movie-0001"), a_movie("movie-0002")]
def get_tracks(self, title: Any) -> Any:
with self.overlap.call(f"{self.name}:tracks:{title.id}"):
return empty_tracks()
def get_chapters(self, title: Any) -> List[Any]:
with self.overlap.call(f"{self.name}:chapters:{title.id}"):
return []
def seed_titles(session: SessionEntry) -> None:
"""Put both titles in the remote session, as a prior titles call would."""
for title_id in ("movie-0001", "movie-0002"):
session.title_map[title_id] = a_movie(title_id)
def bodies(responses: List[Any]) -> List[dict]:
parsed = [json.loads(response.body) for response in responses]
for body in parsed:
assert "error" not in body, body
return parsed
async def two_track_calls(session_id: str) -> List[Any]:
return list(
await asyncio.gather(
handlers.session_tracks_handler({"title_id": "movie-0001"}, session_id),
handlers.session_tracks_handler({"title_id": "movie-0002"}, session_id),
)
)
@pytest.mark.asyncio
async def test_two_track_requests_never_share_the_service() -> None:
overlap = Overlap()
store = get_session_store()
session = await store.create("EXAMPLE", ServiceProbe(overlap), session_id="lock-test-tracks")
seed_titles(session)
try:
responses = await two_track_calls(session.session_id)
finally:
await store.delete(session.session_id)
bodies(responses)
assert overlap.max_active == 1, overlap.trace
@pytest.mark.asyncio
async def test_the_track_lock_covers_the_chapters_call() -> None:
overlap = Overlap()
store = get_session_store()
session = await store.create("EXAMPLE", ServiceProbe(overlap), session_id="lock-test-chapters")
seed_titles(session)
try:
responses = await two_track_calls(session.session_id)
finally:
await store.delete(session.session_id)
bodies(responses)
# Every call closes before the next one opens, so one title's chapters are read before the
# other title's tracks are fetched.
assert len(overlap.trace) == 8, overlap.trace
for opened, closed in zip(overlap.trace[0::2], overlap.trace[1::2]):
assert closed == f"-{opened[1:]}", overlap.trace
@pytest.mark.asyncio
async def test_titles_and_tracks_never_overlap() -> None:
overlap = Overlap()
store = get_session_store()
session = await store.create("EXAMPLE", ServiceProbe(overlap), session_id="lock-test-mixed")
seed_titles(session)
try:
responses = list(
await asyncio.gather(
handlers.session_titles_handler(session.session_id),
handlers.session_tracks_handler({"title_id": "movie-0001"}, session.session_id),
)
)
finally:
await store.delete(session.session_id)
bodies(responses)
assert overlap.max_active == 1, overlap.trace
@pytest.mark.asyncio
async def test_a_second_session_still_runs_in_parallel() -> None:
overlap = Overlap()
store = get_session_store()
first = await store.create("EXAMPLE", ServiceProbe(overlap, "a"), session_id="lock-test-parallel-a")
second = await store.create("EXAMPLE", ServiceProbe(overlap, "b"), session_id="lock-test-parallel-b")
try:
responses = list(
await asyncio.gather(
handlers.session_titles_handler(first.session_id),
handlers.session_titles_handler(second.session_id),
)
)
finally:
await store.delete(first.session_id)
await store.delete(second.session_id)
bodies(responses)
assert first.lock is not second.lock
assert overlap.max_active == 2, overlap.trace
class BlockingTracks:
"""Holds ``get_tracks`` open until the event loop releases it."""
def __init__(self) -> None:
self.entered = threading.Event()
self.resume = threading.Event()
self.loop_was_blocked = False
self.session = SimpleNamespace(headers={}, cookies=[])
def get_tracks(self, title: Any) -> Any:
self.entered.set()
self.loop_was_blocked = not self.resume.wait(timeout=HANDSHAKE_TIMEOUT)
return empty_tracks()
def get_chapters(self, title: Any) -> List[Any]:
return []
@pytest.mark.asyncio
async def test_the_loop_stays_free_while_the_lock_is_held() -> None:
service = BlockingTracks()
store = get_session_store()
session = await store.create("EXAMPLE", service, session_id="lock-test-loop")
seed_titles(session)
try:
task = asyncio.create_task(handlers.session_tracks_handler({"title_id": "movie-0001"}, session.session_id))
try:
# Reached only while the loop is free, which is what the lock placement buys.
await asyncio.to_thread(service.entered.wait, HANDSHAKE_TIMEOUT)
held = session.lock.locked()
finally:
service.resume.set()
response = await asyncio.wait_for(task, HANDSHAKE_TIMEOUT)
finally:
await store.delete(session.session_id)
bodies([response])
assert not service.loop_was_blocked, "get_tracks blocked the event loop"
assert held, "the session lock was not held around the threaded service call"
@pytest.mark.asyncio
async def test_each_session_entry_gets_its_own_lock() -> None:
store = get_session_store()
first = await store.create("EXAMPLE", object(), session_id="lock-test-field-a")
second = await store.create("EXAMPLE", object(), session_id="lock-test-field-b")
try:
assert isinstance(first.lock, asyncio.Lock)
assert isinstance(second.lock, asyncio.Lock)
assert first.lock is not second.lock
finally:
await store.delete(first.session_id)
await store.delete(second.session_id)