mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-15 05:25:12 +00:00
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:
@@ -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)
|
||||
Reference in New Issue
Block a user