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
+145
View File
@@ -0,0 +1,145 @@
"""``dl.get_cdm`` builds each CDM once per run.
Regression: the callers ask for a CDM once per title, and the dedupe guard at the
call site compares object identity. No CDM class defines ``__eq__``, so the guard
was always true and every title paid a fresh build. A remote device costs an RSA
key pair and a blocking request to its host.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
import pytest
import unshackle.core.cdm as cdm_mod
from unshackle.commands.dl import dl
from unshackle.core.cdm import loader
from unshackle.core.config import config
class StubCdm:
"""A distinct object per build, so a reused CDM is visible by identity."""
def __init__(self, cdm_name: str, service_name: str) -> None:
self.cdm_name = cdm_name
self.service_name = service_name
@pytest.fixture
def builds(monkeypatch) -> list[tuple[str, str]]:
"""Record every ``load_cdm`` call and hand back a fresh stub for each one."""
calls: list[tuple[str, str]] = []
def fake_load_cdm(cdm_name: str, *, service_name: str = "", vaults: Optional[Any] = None) -> StubCdm:
calls.append((cdm_name, service_name))
return StubCdm(cdm_name, service_name)
# The package resolves ``load_cdm`` lazily, so a ``setattr`` here would write the real
# function into the package dict on undo and shadow that lookup for the whole process.
monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", fake_load_cdm)
return calls
def make_dl() -> dl:
# __new__ skips the CLI-driven __init__; get_cdm only needs these three.
instance = dl.__new__(dl)
instance.log = logging.getLogger("test_cdm_cache")
instance.cdm_override = None
instance.vaults = None
return instance
def test_repeated_calls_build_the_cdm_once(builds, monkeypatch) -> None:
monkeypatch.setattr(config, "cdm", {"EXAMPLE": "device_a"})
runner = make_dl()
first = runner.get_cdm("EXAMPLE", "default")
for _ in range(4):
assert runner.get_cdm("EXAMPLE", "default") is first
assert builds == [("device_a", "EXAMPLE")]
def test_the_title_loop_guard_sees_the_same_object(builds, monkeypatch) -> None:
"""The call site keeps its CDM when ``get_cdm`` returns what it already holds."""
monkeypatch.setattr(config, "cdm", {"EXAMPLE": "device_a"})
runner = make_dl()
held = runner.get_cdm("EXAMPLE", "default")
for _ in range(3):
quality_based_cdm = runner.get_cdm("EXAMPLE", "default", drm="widevine", quality=2160)
assert not (quality_based_cdm and quality_based_cdm != held)
assert len(builds) == 1
def test_a_different_service_gets_its_own_cdm(builds, monkeypatch) -> None:
"""A CDM is built with its ``service_name``, so two services must not share one."""
monkeypatch.setattr(config, "cdm", {"EXAMPLE": "device_a", "OTHER": "device_a"})
runner = make_dl()
example = runner.get_cdm("EXAMPLE", "default")
other = runner.get_cdm("OTHER", "default")
assert example is not other
assert builds == [("device_a", "EXAMPLE"), ("device_a", "OTHER")]
def test_a_different_profile_gets_its_own_cdm(builds, monkeypatch) -> None:
monkeypatch.setattr(config, "cdm", {"EXAMPLE": {"one": "device_one", "two": "device_two"}})
runner = make_dl()
one = runner.get_cdm("EXAMPLE", "one")
two = runner.get_cdm("EXAMPLE", "two")
assert one is not two
assert runner.get_cdm("EXAMPLE", "one") is one
assert builds == [("device_one", "EXAMPLE"), ("device_two", "EXAMPLE")]
def test_quality_selection_reuses_the_device_it_resolves_to(builds, monkeypatch) -> None:
"""Two spellings of the same device share one CDM; a different device does not."""
monkeypatch.setattr(config, "cdm", {"EXAMPLE": {">=1080": "device_hd", "<1080": "device_sd"}})
runner = make_dl()
hd = runner.get_cdm("EXAMPLE", "default", quality=2160)
assert runner.get_cdm("EXAMPLE", "default", quality=1080) is hd
sd = runner.get_cdm("EXAMPLE", "default", quality=720)
assert sd is not hd
assert builds == [("device_hd", "EXAMPLE"), ("device_sd", "EXAMPLE")]
def test_an_override_is_cached_under_the_name_it_resolves_to(builds, monkeypatch) -> None:
monkeypatch.setattr(config, "cdm", {"EXAMPLE": "device_a"})
runner = make_dl()
runner.cdm_override = "device_b"
override = runner.get_cdm("EXAMPLE", "default")
assert runner.get_cdm("EXAMPLE", "default") is override
assert builds == [("device_b", "EXAMPLE")]
@pytest.fixture
def leaked_shadow(monkeypatch) -> None:
"""Reproduce the residue that a ``setattr`` on the CDM package leaves behind.
``load_cdm`` reaches the package only through its lazy ``__getattr__``. A ``setattr`` on
the package writes the real function into the package dict when it undoes, which shadows
``__getattr__`` for the rest of the process. Two remote tests do exactly that.
"""
monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", loader.load_cdm)
def test_a_shadowed_package_attribute_still_reaches_the_stub(leaked_shadow, builds, monkeypatch) -> None:
"""The stub intercepts ``get_cdm`` whatever an earlier test left on the CDM package."""
monkeypatch.setattr(config, "cdm", {"EXAMPLE": "device_a"})
runner = make_dl()
cdm = runner.get_cdm("EXAMPLE", "default")
assert isinstance(cdm, StubCdm)
assert builds == [("device_a", "EXAMPLE")]
+335
View File
@@ -0,0 +1,335 @@
"""Removing a header or a cookie from an RnetSession must stop it reaching the wire.
``client.update(headers=...)`` merges into the rnet client default headers and has no
removal, and the client keeps its own cookie store. These tests read what a real local
server received, because the session dicts pass even with the leak in place.
"""
from __future__ import annotations
import threading
from http.cookiejar import Cookie, CookieJar
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterator
import pytest
import rnet
from unshackle.core.session import RnetCookieAdapter, RnetSession
pytestmark = pytest.mark.unit
class Recorder(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
received: list[dict[str, str]] = []
cookies_sent: list[str] = []
def do_GET(self) -> None:
type(self).received.append({k.lower(): v for k, v in self.headers.items()})
# rnet sends one Cookie header per cookie on HTTP/1.1, so a dict of the headers
# keeps only the last one and hides a cookie that leaked.
type(self).cookies_sent.append("; ".join(self.headers.get_all("Cookie") or []))
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
if self.path == "/login":
self.send_header("Set-Cookie", "sid=abc123; Path=/")
elif self.path.startswith("/set/"):
self.send_header("Set-Cookie", f"{self.path[len('/set/') :]}; Path=/")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
@pytest.fixture
def server() -> Iterator[str]:
Recorder.received = []
Recorder.cookies_sent = []
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Recorder)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
yield f"http://127.0.0.1:{httpd.server_port}"
finally:
httpd.shutdown()
httpd.server_close()
def make_cookie(name: str, value: str, domain: str) -> Cookie:
return Cookie(
version=0,
name=name,
value=value,
port=None,
port_specified=False,
domain=domain,
domain_specified=True,
domain_initial_dot=domain.startswith("."),
path="/",
path_specified=True,
secure=False,
expires=None,
discard=False,
comment=None,
comment_url=None,
rest={},
)
def test_popped_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
assert Recorder.received[-1]["x-auth"] == "secret"
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert Recorder.received[-1]["x-keep"] == "yes"
def test_deleted_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
del session.headers["X-Auth"]
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_removes_every_header(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
session.headers.clear()
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert "x-trace" not in Recorder.received[-1]
def test_header_removal_keeps_cookies(server: str) -> None:
"""A removed header must not cost the session the cookies it has already earned."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/login")
session.get(f"{server}/before")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
def test_surviving_headers_keep_the_impersonate_order(server: str) -> None:
"""A rebuilt client must overlay the preset headers, not replace them."""
session = RnetSession(impersonate=rnet.Impersonate.Chrome131)
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
before = [k for k in Recorder.received[-1] if k != "x-auth"]
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert [k for k in Recorder.received[-1]] == before
def test_header_removal_keeps_cookies_for_a_host_not_yet_visited(server: str) -> None:
"""A cookie unshackle set itself must survive the rebuild.
``rebuild_client`` can only read the rnet cookie jar per origin, and it knows only the
origins the session has requested. Cookies loaded from a cookie file, or set through
``session.cookies``, are for a host the session often reaches later.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
session.cookies.set("sid", "abc123", domain="example.test")
session.headers.pop("X-Auth", None)
raw = session._client.get_cookies("https://example.test")
assert raw is not None and b"sid=abc123" in raw
def test_rebuild_survives_an_origin_arriving_mid_rebuild(server: str, monkeypatch) -> None:
"""``request`` records origins from the download threads while the rebuild reads them.
Iterating the live set raises ``RuntimeError: Set changed size during iteration`` out of the
header removal. Adding an origin from inside the copy stands in for that race.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
original = RnetSession.__dict__["copy_cookies"].__func__
def racing_copy(source, target, origin: str) -> None:
session._origins.add(f"https://late-{len(session._origins)}.test")
original(source, target, origin)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(racing_copy))
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_drops_a_server_set_cookie(server: str) -> None:
"""``cookies.clear()`` must reach the rnet cookie store, not only the local dicts."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear()
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_leaves_the_session_able_to_take_new_cookies(server: str) -> None:
"""A cleared session must still hold the next cookie, and only that one."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.get(f"{server}/set/fresh=1")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "fresh=1"
def test_delete_drops_only_the_named_server_set_cookie(server: str) -> None:
"""``del cookies[name]`` must drop that name from the store and leave the rest."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/set/other=keep")
del session.cookies["sid"]
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "other=keep"
def test_clear_by_name_drops_a_server_set_cookie(server: str) -> None:
"""``clear(name=...)`` is the requests spelling a service reaches for."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear(name="sid")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_before_the_client_exists_empties_the_jar_and_sends_nothing(server: str) -> None:
"""``clear()`` on a session that has made no request must empty every cookie view.
``dl.save_cookies`` writes ``cookies.jar`` back to the cookie file and only ever merges,
so a cleared cookie left in the jar returns to disk.
"""
session = RnetSession()
jar = CookieJar()
jar.set_cookie(make_cookie("sid", "abc123", "127.0.0.1"))
session.cookies.update(jar)
session.cookies.set("other", "keep", domain="127.0.0.1")
session.cookies.clear()
assert dict(session.cookies) == {}
assert session.cookies.get_dict() == {}
assert list(session.cookies.jar) == []
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_later_header_rebuild_does_not_resurrect_cleared_cookies(server: str) -> None:
"""``rebuild_client`` replays the buffered cookies and copies the old jar per origin.
A ``clear()`` that left either populated hands the cookie back on the next header removal.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_request_during_the_rebuild_still_carries_headers_and_cookies(server: str, monkeypatch) -> None:
"""A request from another thread must never see a client with neither headers nor cookies.
``rebuild_client`` publishes the new client before it carries the headers and the cookies,
so a request in that window goes out unauthenticated.
"""
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
original = RnetCookieAdapter.flush_to_client
rebuilding: list[bool] = []
def flush_with_a_request(self) -> None:
if rebuilding:
session.get(f"{server}/mid")
original(self)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", flush_with_a_request)
rebuilding.append(True)
session.headers.pop("X-Trace", None)
assert Recorder.received[-1].get("x-auth") == "secret"
assert Recorder.cookies_sent[-1] == "sid=abc123"
def test_the_rebuilt_client_is_published_only_when_it_is_ready(server: str, monkeypatch) -> None:
"""The new client must go on ``_client`` last, after the headers, the flush and the copy."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
old = session._client
seen: list[object] = []
rebuilding: list[bool] = []
original_flush = RnetCookieAdapter.flush_to_client
original_copy = RnetSession.__dict__["copy_cookies"].__func__
def recording_flush(self) -> None:
if rebuilding:
seen.append(session._client)
original_flush(self)
def recording_copy(source, target, origin: str) -> None:
seen.append(session._client)
original_copy(source, target, origin)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", recording_flush)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(recording_copy))
rebuilding.append(True)
session.headers.pop("X-Auth", None)
assert seen and all(client is old for client in seen)
assert session._client is not old
+136 -7
View File
@@ -4,11 +4,17 @@ only the adapter mounts."""
from __future__ import annotations
from copy import copy
from typing import Any
from unittest.mock import patch
import pytest
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from unshackle.core.service import TimeoutHTTPAdapter, grow_session_pool
from unshackle.core.service import DEFAULT_TIMEOUT, TimeoutHTTPAdapter, grow_session_pool
from unshackle.core.utils.sslciphers import SSLCiphers
pytestmark = pytest.mark.unit
@@ -20,43 +26,166 @@ class FakeRnetSession:
raise AssertionError("grow_session_pool must not touch a non-requests session")
def test_small_pool_is_remounted_on_both_schemes() -> None:
def sent_timeout(adapter: HTTPAdapter) -> Any:
"""The timeout an adapter hands to HTTPAdapter.send when the caller passes none."""
seen: dict[str, Any] = {}
def record(self: object, request: object, **kwargs: Any) -> str:
seen.update(kwargs)
return "sent"
with patch.object(HTTPAdapter, "send", record):
assert adapter.send(object(), timeout=None) == "sent"
return seen["timeout"]
def test_subclass_adapter_keeps_its_class_and_tls_context() -> None:
"""A service-mounted SSLCiphers must survive the growth with its TLS context.
CBS, friDay, iP, MY5 and PMTP all mount SSLCiphers on the service session and none
override get_session(). Replacing it with a plain adapter drops DEFAULT:@SECLEVEL=2 and
check_hostname=False, so metadata succeeds and every segment then fails at TLS or 403.
"""
session = requests.Session()
session.mount("https://", SSLCiphers(security_level=2))
old = session.get_adapter("https://x")
context = old._ssl_context
assert old._pool_maxsize < 64
grow_session_pool(session, 64)
grown = session.get_adapter("https://x")
assert grown is old
assert isinstance(grown, SSLCiphers)
assert grown._ssl_context is context
assert grown.poolmanager.connection_pool_kw["ssl_context"] is context
assert grown._pool_maxsize == 64
assert grown._pool_block is True
def test_a_distinct_http_adapter_is_grown_not_overwritten() -> None:
"""Every mounted adapter grows, and no mount overwrites another.
The https:// adapter must not land on the http:// prefix, and a service that mounts on a
narrow prefix must be grown as well.
"""
session = requests.Session()
secure = SSLCiphers(security_level=2)
narrow = SSLCiphers(security_level=2)
plain = TimeoutHTTPAdapter(pool_connections=10, pool_maxsize=10)
session.mount("https://", secure)
session.mount("https://cbs.example/", narrow)
session.mount("http://", plain)
grow_session_pool(session, 64)
assert session.get_adapter("https://x") is secure
assert session.get_adapter("https://cbs.example/a") is narrow
assert session.get_adapter("http://x") is plain
assert session.get_adapter("http://x") is not session.get_adapter("https://x")
for adapter in (secure, narrow, plain):
assert adapter._pool_maxsize == 64
assert adapter._pool_connections == 64
def test_shared_adapter_is_grown_in_place_on_both_schemes() -> None:
session = requests.Session()
adapter = TimeoutHTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=Retry(total=5))
session.mount("https://", adapter)
session.mount("http://", adapter)
retries = adapter.max_retries
timeout = adapter.default_timeout
grow_session_pool(session, 64)
https = session.get_adapter("https://x")
http = session.get_adapter("http://x")
assert isinstance(https, TimeoutHTTPAdapter)
assert https is http
assert https is not old
assert https is adapter
assert session.get_adapter("http://x") is adapter
assert type(https) is TimeoutHTTPAdapter
assert https.max_retries is retries
assert https.default_timeout == timeout
assert https._pool_maxsize == 64
assert https._pool_connections == 64
assert https._pool_block is True
assert https.max_retries is old.max_retries
assert https.poolmanager.connection_pool_kw["maxsize"] == 64
def test_cached_proxy_manager_is_rebuilt_at_the_new_size() -> None:
"""proxy_manager_for caches one manager per proxy URL, sized when it was first built.
authenticate() and get_tracks() run before the growth, so a service behind --proxy has
already filled that cache. Leaving it there keeps every proxied segment on the small pool.
"""
session = requests.Session()
adapter = TimeoutHTTPAdapter(pool_connections=4, pool_maxsize=4)
session.mount("https://", adapter)
proxy = "http://proxy.example:8080"
stale = adapter.proxy_manager_for(proxy)
assert stale.connection_pool_kw["maxsize"] == 4
grow_session_pool(session, 64)
fresh = adapter.proxy_manager_for(proxy)
assert fresh.connection_pool_kw["maxsize"] == 64
assert fresh is not stale
def test_large_enough_pool_is_left_alone() -> None:
session = requests.Session()
session.mount("https://", HTTPAdapter(pool_connections=64, pool_maxsize=64))
old = session.get_adapter("https://x")
pool = old.poolmanager
grow_session_pool(session, 64)
assert session.get_adapter("https://x") is old
assert old.poolmanager is pool, "an adapter that needs no growth must keep its warm connections"
def test_equal_size_pool_is_left_alone() -> None:
session = requests.Session()
session.mount("https://", HTTPAdapter(pool_maxsize=32))
old = session.get_adapter("https://x")
pool = old.poolmanager
grow_session_pool(session, 32)
assert session.get_adapter("https://x") is old
assert old.poolmanager is pool
@pytest.mark.parametrize("session", [object(), FakeRnetSession()])
def test_non_requests_session_is_ignored(session: object) -> None:
grow_session_pool(session, 64)
def test_copied_adapter_keeps_the_default_timeout() -> None:
"""default_timeout must survive copy(), which no_retry_session runs per segment.
copy() runs HTTPAdapter.__getstate__/__setstate__, which carry only the names in
__attrs__. Without default_timeout there, the copy quietly falls back to DEFAULT_TIMEOUT
on the segment path and drops the service timeout.
The last two lines pin the getattr fallback in send(). A subclass that redefines
__attrs__ from HTTPAdapter.__attrs__, as SSLCiphers does, drops default_timeout again.
"""
twin = copy(TimeoutHTTPAdapter(timeout=(3, 300), pool_connections=4, pool_maxsize=4))
assert twin.default_timeout == (3, 300)
assert sent_timeout(twin) == (3, 300)
del twin.default_timeout
assert sent_timeout(twin) == DEFAULT_TIMEOUT
def test_growing_does_not_revert_a_custom_default_timeout() -> None:
session = requests.Session()
session.mount("https://", TimeoutHTTPAdapter(timeout=(1, 5), pool_connections=4, pool_maxsize=4))
old = session.get_adapter("https://x")
grow_session_pool(session, 64)
grown = session.get_adapter("https://x")
assert grown.default_timeout == (1, 5)
assert grown is old
assert copy(grown).default_timeout == (1, 5), "the per-segment copy must keep the service timeout"
+214
View File
@@ -0,0 +1,214 @@
"""``probe_ranged`` cost and fidelity.
The probe asks for one byte to learn the complete length and whether the host honours
ranges. It runs once for every single-URL download and once per tail-boost candidate,
so what it costs and what it sends both matter:
- it must leave the connection reusable. A streamed response whose body is never read
cannot go back in the pool. Every probe would then tear down a socket, and the next
request would pay a fresh handshake.
- it must ask for an unencoded body like ``download()`` does. A CDN that answers a
probe with a Content-Encoding makes the probe decline, and the ranged-parallel path
is silently lost for a file that supports it.
- it must carry the URL item's own request options. Without them a host that needs an
auth header answers the probe with a 4xx, and the download falls back to one
connection even though the ranges were there.
The drain is bounded by the declared Content-Length, not by the status code. A host
that ignores Range may answer 200 with the whole resource, and one that answers 206
with the whole resource is just as possible. Reading either would download the file
only to decide not to use it.
"""
import importlib
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from requests import Session
from unshackle.core.constants import DOWNLOAD_CANCELLED
# the package re-exports the `requests` function, shadowing the module of the same name
dl = importlib.import_module("unshackle.core.downloaders.requests")
PAYLOAD = bytes((i * 11 + 3) % 256 for i in range(32 * 1024))
BIG = bytes(8 * 1024 * 1024) # larger than any socket buffer, so a full drain is visible
PROBES = 6
AUTH = "let-me-in"
class _ProbeServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def __init__(self):
self.lock = threading.Lock()
self.requests: list = [] # (Range, Accept-Encoding, X-Auth)
self.connections = 0
self.mode = "range"
self.written = 0
self.broke = False
super().__init__(("127.0.0.1", 0), _ProbeHandler)
def handle_error(self, request, client_address): # the client hanging up is expected here
pass
class _ProbeHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def setup(self):
super().setup()
with self.server.lock:
self.server.connections += 1
def do_GET(self):
srv = self.server
rng = self.headers.get("Range")
with srv.lock:
srv.requests.append((rng, self.headers.get("Accept-Encoding"), self.headers.get("X-Auth")))
if srv.mode == "needs_auth" and self.headers.get("X-Auth") != AUTH:
self.send_response(403)
self.send_header("Content-Length", "0")
self.end_headers()
return
if srv.mode == "ignores_range":
self.send_response(200)
self.send_header("Content-Length", str(len(BIG)))
self.end_headers()
self._write_slowly(BIG)
return
if srv.mode == "fat_206":
# a 206 status on a whole-resource body: the status alone cannot bound the drain
self.send_response(206)
self.send_header("Content-Range", f"bytes 0-{len(BIG) - 1}/{len(BIG)}")
self.send_header("Content-Length", str(len(BIG)))
self.end_headers()
self._write_slowly(BIG)
return
if not rng:
self.send_response(200)
self.send_header("Content-Length", str(len(PAYLOAD)))
self.end_headers()
self.wfile.write(PAYLOAD)
return
start_s, _, end_s = rng.removeprefix("bytes=").partition("-")
start = int(start_s)
end = int(end_s) if end_s else len(PAYLOAD) - 1
body = PAYLOAD[start : end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(PAYLOAD)}")
self.send_header("Content-Length", str(len(body)))
if srv.mode == "encode_unless_identity" and self.headers.get("Accept-Encoding") != "identity":
self.send_header("Content-Encoding", "gzip")
self.end_headers()
self.wfile.write(body)
def _write_slowly(self, body):
# chunked writes so the count records how far the client actually read
srv = self.server
try:
for at in range(0, len(body), 64 * 1024):
self.wfile.write(body[at : at + 64 * 1024])
with srv.lock:
srv.written += len(body[at : at + 64 * 1024])
except OSError:
with srv.lock:
srv.broke = True
self.close_connection = True
@pytest.fixture
def server(monkeypatch):
DOWNLOAD_CANCELLED.clear()
monkeypatch.setattr(dl, "RETRY_WAIT", 0)
srv = _ProbeServer()
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
srv.server_close()
DOWNLOAD_CANCELLED.clear()
def url(srv, path="/payload.bin"):
return f"http://127.0.0.1:{srv.server_address[1]}{path}"
def test_probe_reuses_the_pooled_connection(server):
session = Session()
for _ in range(PROBES):
assert dl.probe_ranged(url(server), session) == (len(PAYLOAD), True)
assert server.connections == 1
def test_probe_does_not_drain_a_200_body(server):
server.mode = "ignores_range"
session = Session()
assert dl.probe_ranged(url(server), session) == (0, False)
for _ in range(50):
with server.lock:
if server.broke or server.written >= len(BIG):
break
time.sleep(0.1)
with server.lock:
assert server.written < len(BIG)
def test_probe_asks_for_identity_encoding(server):
server.mode = "encode_unless_identity"
session = Session()
assert dl.probe_ranged(url(server), session) == (len(PAYLOAD), True)
assert server.requests[-1][1] == "identity"
# a caller that names its own encoding still wins
assert dl.probe_ranged(url(server), session, headers={"Accept-Encoding": "gzip"}) == (0, False)
assert server.requests[-1][1] == "gzip"
def test_probe_uses_the_url_items_headers(server, tmp_path, monkeypatch):
server.mode = "needs_auth"
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
item = {"url": url(server), "headers": {"X-Auth": AUTH}}
for _ in dl.requests([item], output_dir=tmp_path, filename="seg_{i:04}.bin", max_workers=4):
pass
save_path = tmp_path / "seg_0000.bin"
assert save_path.read_bytes() == PAYLOAD
probes = [r for r in server.requests if r[0] == "bytes=0-0"]
assert probes and all(r[2] == AUTH for r in probes)
# the probe was answered, so the ranged path ran instead of one sequential connection
assert [r for r in server.requests if r[0] and r[0] != "bytes=0-0"]
def test_probe_does_not_drain_a_fat_206_body(server):
"""A 206 carrying the whole resource must not be read to decide whether to use ranges."""
server.mode = "fat_206"
session = Session()
assert dl.probe_ranged(url(server), session) == (len(BIG), True)
for _ in range(50):
with server.lock:
if server.broke or server.written >= len(BIG):
break
time.sleep(0.1)
with server.lock:
assert server.written < len(BIG)
+110 -4
View File
@@ -13,7 +13,9 @@ Pins the byte-range fan-out that splits one large file across parts:
or a content-encoded 206;
- the public single-URL path falls back to a sequential download only when the
server does not really support ranges, and re-raises a 4xx instead of paying
MAX_ATTEMPTS a second time from byte 0.
MAX_ATTEMPTS a second time from byte 0;
- the split plan is worker-driven: every worker gets a part, down to a 4 MiB floor
that stops a large worker count cutting a file into slivers.
Tests 2/3 exercise ``dispatch_parts`` directly: the public path swallows a
range-support failure and falls back, so the raise that the invariant guarantees
@@ -118,7 +120,7 @@ def run(srv, tmp_path, urls, **kwargs):
def test_ranged_parallel_merges_byte_identical(server, tmp_path, monkeypatch):
# shrink the thresholds so a 32 KiB payload splits into several parts
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_PART_SIZE", 2 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_PART_SIZE", 2 * 1024)
files = run(server, tmp_path, [{"url": url(server)}], max_workers=4)
assert len(files) == 1
assert files[0].read_bytes() == PAYLOAD
@@ -182,7 +184,7 @@ def test_permanent_part_failure_is_not_retried_sequentially(server, tmp_path, mo
server.mode = "deny_parts"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_PART_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_PART_SIZE", 8 * 1024)
with pytest.raises(HTTPError):
run(server, tmp_path, [{"url": url(server)}], max_workers=4)
# a sequential fallback would issue a plain unranged GET; only the probe and the
@@ -198,7 +200,7 @@ def test_range_unsupported_falls_back_to_sequential(server, tmp_path, monkeypatc
server.mode = "probe_only_206"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_PART_SIZE", 8 * 1024)
monkeypatch.setattr(dl, "RANGE_PARALLEL_MIN_PART_SIZE", 8 * 1024)
files = run(server, tmp_path, [{"url": url(server)}], max_workers=4)
assert len(files) == 1
assert files[0].read_bytes() == PAYLOAD
@@ -214,3 +216,107 @@ def test_permanent_status_unwraps_causes():
wrapped = RuntimeError("max retries")
wrapped.__cause__ = HTTPError(response=_Resp(404))
assert dl.permanent_status(wrapped) == 404
BIG = bytes((i * 13 + 7) % 256 for i in range(8 * 1024 * 1024))
class _BigHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
srv = self.server
rng = self.headers.get("Range")
with srv.lock:
srv.requests.append(rng)
if not rng:
self.send_response(200)
self.send_header("Content-Length", str(len(BIG)))
self.end_headers()
self.wfile.write(BIG)
return
start_s, _, end_s = rng.removeprefix("bytes=").partition("-")
start = int(start_s)
end = int(end_s) if end_s else len(BIG) - 1
body = BIG[start : end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BIG)}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def big_server():
srv = ThreadingHTTPServer(("127.0.0.1", 0), _BigHandler)
srv.lock = threading.Lock()
srv.requests = []
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
_REAL_SPLIT = dl.split_ranges
class _SplitStop(Exception):
"""Ends dispatch_parts once the split plan is known, before any request is made."""
def plan_split(monkeypatch, tmp_path, total_size, max_workers):
"""The (size, max_parts, part_target, parts) that dispatch_parts asks split_ranges for."""
record: list = []
def spy(size, max_parts, part_target):
# _REAL_SPLIT, not dl.split_ranges: a second call in one test would otherwise
# chain through the previous spy and raise before recording
record.append((size, max_parts, part_target, _REAL_SPLIT(size, max_parts, part_target)))
raise _SplitStop
monkeypatch.setattr(dl, "split_ranges", spy)
gen = dl.dispatch_parts(
url="http://127.0.0.1:1/unreachable",
save_path=tmp_path / "planned.bin",
session=Session(),
total_size=total_size,
max_workers=max_workers,
)
with pytest.raises(_SplitStop):
next(gen)
return record[0]
def test_part_count_matches_worker_count(tmp_path, monkeypatch):
_, _, _, parts = plan_split(monkeypatch, tmp_path, 64 * 1024 * 1024, 16)
assert len(parts) == 16
_, _, _, parts = plan_split(monkeypatch, tmp_path, 16 * 1024 * 1024, 4)
assert len(parts) == 4
def test_part_floor_caps_sliver_count(tmp_path, monkeypatch):
# below the 4 MiB floor a part's handshake costs more than its transfer
_, _, target, parts = plan_split(monkeypatch, tmp_path, 16 * 1024 * 1024, 64)
assert target == 4 * 1024 * 1024
assert len(parts) == 4
def test_worker_driven_parts_merge_byte_identical(big_server, tmp_path):
save_path = tmp_path / "big.bin"
for _ in dl.dispatch_parts(
url=url(big_server, "/big.bin"),
save_path=save_path,
session=Session(),
total_size=len(BIG),
max_workers=2,
):
pass
assert save_path.read_bytes() == BIG
assert len({r for r in big_server.requests if r}) == 2
assert not save_path.with_name(save_path.name + ".!dev").exists()
@@ -0,0 +1,273 @@
"""Completeness of a sequential download that the server serves in short pieces.
``download()`` renames its ``.!dev`` partial into place and reports ``file_downloaded``
only when the resource is whole. A host that caps every range answers an open-ended
resume (``Range: bytes=N-``) with an honest short 206: the body matches the response's
own ``Content-Length``, so the short-read check is satisfied while the file is still
truncated. These tests pin the completeness decision on the three paths that reach it:
- a resume with a known complete length must continue from the served end until the
complete length is on disk;
- a resume with an unknown complete length (``Content-Range: bytes N-M/*``) has no
number to compare against, so the only proof of the end is the server refusing the
next range with a 416;
- a first request that carries no Range at all, which a host may still answer with a
short 206. Its Content-Range names the complete length, so the decision has a number
to compare against even though nothing was resumed.
The guards beside them pin what the decision must not change: an unknown-length body
with no partial on disk still finalizes, a stale oversized partial is still discarded
on a 416, a resume that arrives whole costs no extra request, and an empty resume body
still raises instead of repeating the same request forever.
"""
import importlib
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from requests import Session
from unshackle.core.constants import DOWNLOAD_CANCELLED
# the package re-exports the `requests` function, shadowing the module of the same name
dl = importlib.import_module("unshackle.core.downloaders.requests")
TOTAL = 256 * 1024
PAYLOAD = bytes((i * 37 + 5) % 256 for i in range(TOTAL))
SEED = 64 * 1024 # bytes a prior run left in the .!dev partial
CAP = 32 * 1024 # most the capped server serves for one range
OPEN_CAP = 16 * 1024 # most the halving server serves for an open-ended range
CUT = 96 * 1024 # where the halving server cuts the unranged body
class _ShortServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def __init__(self):
self.lock = threading.Lock()
self.requests: list = []
self.mode = "cap"
super().__init__(("127.0.0.1", 0), _ShortHandler)
def handle_error(self, request, client_address): # the client hanging up is expected here
pass
class _ShortHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def do_GET(self):
srv = self.server
header = self.headers.get("Range")
with srv.lock:
srv.requests.append(header)
mode = srv.mode
if header is None:
if mode == "fresh206":
# a 206 to an un-Ranged request: the complete length is only in Content-Range
body = PAYLOAD[:CAP]
self.send_response(206)
self.send_header("Content-Range", f"bytes 0-{CAP - 1}/{TOTAL}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if mode == "chunked200":
# no Content-Length: an unknown-length body the downloader cannot size
self.send_response(200)
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.write(b"%x\r\n" % len(PAYLOAD) + PAYLOAD + b"\r\n0\r\n\r\n")
return
self.send_response(200)
self.send_header("Content-Length", str(TOTAL))
self.end_headers()
if mode == "halving":
# honest length, then cut: download() credits a resume and bounds the next request
self.wfile.write(PAYLOAD[:CUT])
self.close_connection = True
return
self.wfile.write(PAYLOAD)
return
start_s, _, end_s = header.removeprefix("bytes=").partition("-")
start = int(start_s)
want_end = int(end_s) if end_s else None
if start >= TOTAL:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{TOTAL}")
self.send_header("Content-Length", "0")
self.end_headers()
return
if mode == "empty206":
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{TOTAL - 1}/{TOTAL}")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.write(b"0\r\n\r\n")
return
if want_end is not None:
end = min(TOTAL - 1, want_end) # a bounded sub-range is always served whole
elif mode == "whole_remainder":
end = TOTAL - 1
elif mode == "halving":
end = min(TOTAL - 1, start + OPEN_CAP - 1)
else:
end = min(TOTAL - 1, start + CAP - 1)
body = PAYLOAD[start : end + 1]
total = "*" if mode == "cap_star" else str(TOTAL)
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{total}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@pytest.fixture
def server(monkeypatch):
DOWNLOAD_CANCELLED.clear()
monkeypatch.setattr(dl, "RETRY_WAIT", 0)
srv = _ShortServer()
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
srv.server_close()
DOWNLOAD_CANCELLED.clear()
def url(srv):
return f"http://127.0.0.1:{srv.server_address[1]}/seg.mp4"
def run(srv, save_path):
return list(dl.download(url=url(srv), save_path=save_path, session=Session(), segmented=True))
def assert_complete(save_path):
# size first: a truncation failure then names the byte counts instead of diffing blobs
assert save_path.stat().st_size == TOTAL
assert save_path.read_bytes() == PAYLOAD
def finalized(events):
return [event["written"] for event in events if "file_downloaded" in event]
def seed_partial(save_path, size):
tmp_file = save_path.with_name(f"{save_path.name}.!dev")
tmp_file.write_bytes(PAYLOAD[:size])
return tmp_file
def test_capped_open_ended_resume_finishes_the_file(server, tmp_path):
server.mode = "cap"
save_path = tmp_path / "seg.mp4"
tmp_file = seed_partial(save_path, SEED)
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
assert not tmp_file.exists()
def test_short_tail_after_chunk_halving_finishes_the_file(server, tmp_path, monkeypatch):
server.mode = "halving"
monkeypatch.setattr(dl, "REQUEST_CHUNK_SIZE", 64 * 1024)
monkeypatch.setattr(dl, "MIN_REQUEST_CHUNK", 8 * 1024)
monkeypatch.setattr(dl, "MIN_RESUME_PROGRESS", 1024)
save_path = tmp_path / "seg.mp4"
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
# the bounded sub-ranges in the middle were each served whole and continued
bounded = [r for r in server.requests if r and r.split("-")[-1]]
assert len(bounded) >= 2
def test_zero_byte_resume_body_raises_instead_of_spinning(server, tmp_path):
server.mode = "empty206"
save_path = tmp_path / "seg.mp4"
seed_partial(save_path, SEED)
with pytest.raises(IOError):
run(server, save_path)
assert not save_path.exists()
# an empty body must spend an attempt, not repeat the same request with no bound
assert len(server.requests) <= dl.MAX_ATTEMPTS
def test_unknown_total_resume_finalizes_on_416(server, tmp_path):
server.mode = "cap_star"
save_path = tmp_path / "seg.mp4"
tmp_file = seed_partial(save_path, SEED)
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
assert not tmp_file.exists()
def test_stale_partial_416_still_restarts_clean(server, tmp_path):
server.mode = "cap"
save_path = tmp_path / "seg.mp4"
tmp_file = save_path.with_name(f"{save_path.name}.!dev")
tmp_file.write_bytes(b"\x00" * (TOTAL + 4096))
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
assert None in server.requests # restarted from byte 0 with an unranged GET
assert not tmp_file.exists()
def test_unknown_length_body_still_finalizes(server, tmp_path):
server.mode = "chunked200"
save_path = tmp_path / "seg.mp4"
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
def test_complete_resume_finalizes_without_an_extra_request(server, tmp_path):
server.mode = "whole_remainder"
save_path = tmp_path / "seg.mp4"
seed_partial(save_path, SEED)
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
# a known complete length that the first response satisfies costs no confirming request
assert len(server.requests) == 1
def test_short_206_to_an_unranged_first_request_finishes_the_file(server, tmp_path):
"""No resume, so the only complete length is the one the 206's Content-Range names."""
server.mode = "fresh206"
save_path = tmp_path / "seg.mp4"
events = run(server, save_path)
assert_complete(save_path)
assert finalized(events) == [TOTAL]
+54 -7
View File
@@ -11,7 +11,9 @@ target, finalized by the last part worker via os.replace. The invariants:
- finalize is last-part-wins: on success save_path exists and the ``.tp.!dev`` target
is gone;
- a permanently failing part fails the batch and the boosted segment is never
os.replace'd into place (no corrupt finalized segment).
os.replace'd into place (no corrupt finalized segment);
- a part that fails for a reason a retry could fix puts its segment back on the normal
single-worker path, without aborting the sibling segments or the track.
Determinism: with ``max_workers`` < segment count <= 2*max_workers and adaptive=True,
the leading ``max_workers`` segments submit upfront and the trailing few stay in
@@ -50,9 +52,16 @@ class _Handler(BaseHTTPRequestHandler):
start_s, _, end_s = rng[len("bytes=") :].partition("-")
start = int(start_s)
end = int(end_s) if end_s else len(BODY) - 1
if server.deny_offset_ranges and start > 0:
# every non-leading window is refused for good (expired token, geo block)
self.send_response(403)
self.send_header("Content-Length", "0")
self.end_headers()
return
if server.break_offset_ranges and start > 0:
# misbehave for every non-leading window: send the whole body as 200 so
# part-mode's 206 check fails permanently for that part
# every non-leading window answers 200 with the whole body: part-mode's 206
# check fails, but 200 is not permanent, so the segment goes back on the
# normal single-worker path
self.send_response(200)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
@@ -80,6 +89,7 @@ def server():
srv.lock = threading.Lock()
srv.requests = []
srv.break_offset_ranges = False
srv.deny_offset_ranges = False
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
@@ -160,15 +170,52 @@ def test_tail_boost_finalize_last_part_wins(server, tmp_path):
assert not save_path.with_name(f"{save_path.name}.tp.!dev").exists()
def test_tail_boost_part_failure_fails_batch_but_keeps_siblings_files_clean(server, tmp_path, monkeypatch):
server.break_offset_ranges = True # every non-leading part window 200s -> permanent 206 failure
def test_tail_boost_part_failure_falls_back_to_normal_path(server, tmp_path, monkeypatch):
server.break_offset_ranges = True # every part window past the first fails, but not for good
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
files, advances = run(server, tmp_path, seg_urls(server))
assert boost_engaged(server), "tail boost never engaged; the fallback path is untested"
# a transient part failure returns the segment to the normal path instead of killing the track
assert len(files) == SEG_COUNT
assert all(f.read_bytes() == BODY for f in files)
# one advance per segment: a requeued segment must not be counted twice
assert advances == SEG_COUNT
assert not dl.DOWNLOAD_CANCELLED.is_set()
assert not list(tmp_path.glob("*.!dev"))
assert not list(tmp_path.glob("*.tp.!dev"))
def test_tail_boost_failure_does_not_abort_sibling_segments(server, tmp_path, monkeypatch):
server.break_offset_ranges = True
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
files, _ = run(server, tmp_path, seg_urls(server))
assert boost_engaged(server), "tail boost never engaged; the fallback path is untested"
# the parts of one segment share an abort of their own: setting the batch-wide one would
# strand every other in-flight worker and end the batch with segments missing
assert len(files) == SEG_COUNT
with server.lock:
reqs = list(server.requests)
boosted_paths = {path for path, rng in reqs if rng and rng != "bytes=0-0"}
assert boosted_paths
# each boosted segment went back on the normal path: a plain unranged GET for it
plain = {path for path, rng in reqs if rng is None}
assert boosted_paths <= plain
def test_tail_boost_permanent_part_failure_still_fails_the_batch(server, tmp_path, monkeypatch):
server.deny_offset_ranges = True # every part window past the first is a 403
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
with pytest.raises(Exception):
run(server, tmp_path, seg_urls(server))
# the boosted tail segments (6, 7) hit the failing windows: no part run reaches parts_left==0
# cleanly, so neither is finalized. sibling leaders that did finalize must be uncorrupted.
assert boost_engaged(server), "tail boost never engaged; the permanent-failure rule is untested"
# a 4xx will not get better on the normal path either, so the boosted tail segments
# (6, 7) are never finalized and the batch fails instead of paying a second full attempt
for i in (6, 7):
assert not (tmp_path / f"seg_{i:04}.bin").exists()
for f in tmp_path.glob("seg_*.bin"):
@@ -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)
+182 -65
View File
@@ -1,6 +1,7 @@
import math
import multiprocessing
import os
import pickle
import random
import re
import socket
@@ -48,6 +49,9 @@ PROGRESS_WINDOW = 2
CONNECT_TIMEOUT = 10
READ_TIMEOUT = 30
# a probe asks for one byte, so a larger body is a host that ignored the range
PROBE_DRAIN_LIMIT = 4096
# re-request a tail segment stuck past max(HEDGE_FACTOR * median segment time, HEDGE_MIN_WAIT)
HEDGE_FACTOR = 3
HEDGE_MIN_WAIT = 5.0
@@ -58,7 +62,7 @@ DEFAULT_CHUNK = 524_288
SPEED_ROLLING_WINDOW = 10 # seconds of history to keep for speed calculation
RANGE_PARALLEL_MIN_SIZE = 64 * 1024 * 1024
RANGE_PARALLEL_PART_SIZE = 16 * 1024 * 1024
RANGE_PARALLEL_MIN_PART_SIZE = 4 * 1024 * 1024
# One CPython interpreter caps sustained segment throughput (GIL in the ssl read path);
# fan a big segment batch across spawned children to reach line rate. Below this count the
@@ -193,7 +197,8 @@ class AdaptiveWorkerController:
of throughput samples exists. The target starts at ``start`` (the cap by default) and
follows AIMD from there: an error burst halves it and takes a one-tick cooldown, the
controller reverts a probe upward that plateaus, and otherwise it climbs by
``ADAPTIVE_STEP``. Target is
``ADAPTIVE_STEP``. It judges a probe on the trailing tick before and after the change, so
both sides of the ``ADAPTIVE_PLATEAU_GAIN`` comparison measure the same span. Target is
always clamped to ``[ADAPTIVE_MIN, cap]``.
"""
@@ -228,15 +233,18 @@ class AdaptiveWorkerController:
def record_error(self, now: float) -> None:
self._errors += 1
def speed(self, now: float) -> float:
"""Rolling bytes/sec over the window."""
def speed(self, now: float, span: Optional[float] = None) -> float:
"""Rolling bytes/sec over the window, or over the trailing ``span`` seconds when given."""
self.prune(now)
if not self._samples:
return 0.0
span = now - self._samples[0][0]
if span <= 0:
if span is not None and span > 0:
cutoff = now - span
return sum(n for t, n in self._samples if t > cutoff) / span
elapsed = now - self._samples[0][0]
if elapsed <= 0:
return 0.0
return sum(n for _, n in self._samples) / span
return sum(n for _, n in self._samples) / elapsed
def warmed_up(self, now: float) -> bool:
# half a tick of samples gives enough of a baseline to probe against; waiting for
@@ -267,6 +275,7 @@ class AdaptiveWorkerController:
errors = self._errors
self._errors = 0
speed_now = self.speed(now)
probe_speed = self.speed(now, min(self.tick, self.window))
old = self.target
if self._cooldown:
@@ -284,13 +293,13 @@ class AdaptiveWorkerController:
# tail guard: too little work to saturate the target, so a low measured speed
# here reflects starvation rather than a plateau, so hold and skip the probe/revert this tick
pass
elif self._last_action == "increase" and speed_now < ADAPTIVE_PLATEAU_GAIN * self._speed_before_increase:
elif self._last_action == "increase" and probe_speed < ADAPTIVE_PLATEAU_GAIN * self._speed_before_increase:
# last probe upward did not pay off: revert it and hold
self.target = max(ADAPTIVE_MIN, self._target_before_increase)
self._last_action = None
reason = "plateau"
elif self.target < self.cap:
self._speed_before_increase = speed_now
self._speed_before_increase = probe_speed
self._target_before_increase = self.target
self.target = min(self.cap, self.target + ADAPTIVE_STEP)
self._last_action = "increase"
@@ -328,6 +337,18 @@ def permanent_status(exc: BaseException) -> Optional[int]:
return None
class TailPartFailed(Exception):
"""A tail-boost range part failed. ``index`` names the segment it was split from.
The batch loop needs the index to put that segment back on the normal single-worker
path; the failure itself stays reachable as ``__cause__`` for ``permanent_status``.
"""
def __init__(self, index: int) -> None:
super().__init__(f"tail-boost part failed for segment {index}")
self.index = index
def retry_sleep(exc: Exception, attempts: int) -> float:
"""Seconds to wait before retry number ``attempts``.
@@ -376,14 +397,16 @@ def no_retry_session(session: Session) -> Session:
download() owns segment retries, so a mounted urllib3 ``Retry`` nests inside a single
attempt: one 429 carrying ``Retry-After: 60`` costs five one-minute sleeps before
download() even sees the failure. requests takes no per-call retry override, and track
threads share the caller's session, so remounting it is not an option. A shallow adapter
copy keeps the poolmanager object, so TLS and pooling behaviour does not change.
threads share the caller's session, so remounting it is not an option.
"""
view = copy(session)
view.adapters = OrderedDict()
for prefix, adapter in session.adapters.items():
twin = copy(adapter)
twin.max_retries = Retry(0, read=False)
if isinstance(adapter, HTTPAdapter) and isinstance(twin, HTTPAdapter):
twin.poolmanager = adapter.poolmanager
twin.proxy_manager = adapter.proxy_manager
view.adapters[prefix] = twin
return view
@@ -409,6 +432,8 @@ def is_content_encoded(value: Optional[str]) -> bool:
def probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]:
headers = {**(kwargs.get("headers") or {}), "Range": "bytes=0-0"}
if not any(str(k).lower() == "accept-encoding" for k in headers):
headers["Accept-Encoding"] = "identity"
rest = {k: v for k, v in kwargs.items() if k != "headers"}
if is_rnet_session(session):
rest.setdefault("read_timeout", READ_TIMEOUT)
@@ -430,6 +455,12 @@ def probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]:
total = content_range.rsplit("/", 1)[-1].strip()
return (int(total), True) if total.isdigit() else (0, False)
finally:
try:
declared = int(resp.headers.get("Content-Length") or resp.headers.get("content-length") or 0)
if resp.status_code == 206 and 0 < declared <= PROBE_DRAIN_LIMIT:
_ = resp.content
except Exception:
pass
try:
resp.close()
except Exception:
@@ -476,6 +507,17 @@ def parse_content_range(value: Any) -> Optional[tuple[int, int, Optional[int]]]:
return int(match.group(1)), int(match.group(2)), None if match.group(3) == "*" else int(match.group(3))
def parse_content_range_unsatisfied(value: Any) -> Optional[int]:
"""Parse a 416's ``Content-Range`` into the complete length. Gives None when absent or unknown.
A 416 answers with ``bytes */N`` (or ``bytes */*``), which the 206 form does not cover.
"""
if isinstance(value, bytes):
value = value.decode("latin1", "replace")
match = re.fullmatch(r"bytes\s+\*/(\d+)", str(value or "").strip(), re.IGNORECASE)
return int(match.group(1)) if match else None
def range_clamped_to_end(content_range: tuple[int, int, Optional[int]], slice_end: int) -> bool:
"""Whether a 206 stops early only because the requested range ends past the resource.
@@ -541,6 +583,18 @@ def plan_tail_parts(size: int, spare: int) -> list[tuple[int, int]]:
return split_ranges(size, spare, TAIL_BOOST_PART_SIZE)
def submission_window(target: int) -> int:
"""Futures to keep queued for ``target`` download workers.
Submitting every segment up front convoys on the pool's global lock and starves the event
drain, so the drain loop meters submission through a window it tops up. The window is
twice the worker target: a worker that finishes a segment finds its next one already staged
instead of idling until the next drain cycle stages it. A pure function, so the ratio is
unit-testable and fixed mode and adaptive mode cannot drift apart.
"""
return target * 2
def tail_boost_engages(remaining: int, pending: int, target: int) -> bool:
"""True when idle workers outnumber the remaining whole segments.
@@ -564,7 +618,8 @@ def dispatch_parts(
save_path.parent.mkdir(parents=True, exist_ok=True)
control_file = save_path.with_name(f"{save_path.name}.!dev")
parts = split_ranges(total_size, max_workers, RANGE_PARALLEL_PART_SIZE)
part_target = max(RANGE_PARALLEL_MIN_PART_SIZE, math.ceil(total_size / max_workers))
parts = split_ranges(total_size, max_workers, part_target)
control_file.write_bytes(b"")
with open(save_path, "wb") as f:
@@ -755,29 +810,24 @@ def download(
_time = time.time
use_raw = is_requests_session(session)
# item carries its own Range (DASH SegmentBase / HLS EXT-X-BYTERANGE slice): never
# overwrite it with a resume Range (that would fetch the parent's tail, not the slice);
# retries rewrite the whole slice in "wb" mode instead
item_range = has_range_header(kwargs)
attempts = 1
written = 0
resumes = 0
# None until a cut proves the host bounds one request; the sequential path also needs a
# known complete length, or a short body could not be told apart from a finished download
request_limit: Optional[int] = None
known_total: Optional[int] = None
high_water = 0
progress_seeded = False
while True:
if claimed is not None and claimed():
return
if DOWNLOAD_CANCELLED.is_set() or (abort is not None and abort.is_set()):
# a worker waking from a retry nap after cancel/batch-abort must exit here,
# before opening a new request or the .!dev handle mid-teardown
return
if not part_mode:
written = 0
secured = written if part_mode else resume_offset
resume_start = resume_offset
last_speed_refresh = _time()
try:
@@ -818,16 +868,26 @@ def download(
stream = session.get(url, stream=True, **request_kwargs)
if (not part_mode) and (not item_range) and resume_offset > 0 and stream.status_code == 416:
# our Range started past the end (a stale or oversized .!dev from a prior run).
# discard it and restart clean rather than raise_for_status → burn retries.
complete = parse_content_range_unsatisfied(
stream.headers.get("Content-Range") or stream.headers.get("content-range")
)
try:
stream.close()
except Exception:
pass
if complete == resume_offset and known_total in (None, resume_offset):
if not segmented and not progress_seeded:
# nothing is read on this path, so the bar has to be seeded here
progress_seeded = True
yield dict(total=resume_offset)
yield dict(advance=resume_offset)
os.replace(tmp_file, save_path)
yield dict(file_downloaded=save_path, written=resume_offset)
if segmented:
yield dict(advance=1)
break
tmp_file.unlink(missing_ok=True)
resume_offset = 0
written = 0
continue
raise IOError(f"resume from byte {resume_offset} got a 416, complete length {complete}")
stream.raise_for_status()
@@ -848,14 +908,8 @@ def download(
resumed = False
resume_offset = 0
else:
try:
stream.close()
except Exception:
pass
tmp_file.unlink(missing_ok=True)
resume_offset = 0
written = 0
continue
raise IOError(f"resume from byte {resume_offset} got Content-Range {content_range!r}")
elif content_range[2] is not None:
known_total = content_range[2]
if (not part_mode) and resume_offset > 0 and not resumed:
@@ -895,6 +949,15 @@ def download(
# a 200 carries the whole resource, so its length is the complete length
known_total = content_length or None
if known_total is None and not part_mode and not item_range and stream.status_code == 206:
fresh_range = parse_content_range(
stream.headers.get("Content-Range") or stream.headers.get("content-range")
)
if fresh_range is not None:
if fresh_range[0] != resume_offset:
raise IOError(f"206 body starts at byte {fresh_range[0]}, expected {resume_offset}")
known_total = fresh_range[2]
if item_range and not part_mode and stream.status_code != 206:
# server ignored the slice's Range and sent the whole parent (RFC 9110 allows
# a 200 here); writing that as the segment would silently corrupt the merge, so
@@ -926,15 +989,12 @@ def download(
f"byte-range segment got Content-Range {content_range!r}, "
f"expected {slice_start}-{slice_end if slice_end is not None else ''}"
)
if not content_encoded:
content_length = content_range[1] - content_range[0] + 1
if resumed and content_encoded:
try:
stream.close()
except Exception:
pass
tmp_file.unlink(missing_ok=True)
resume_offset = 0
continue
raise IOError(f"resume from byte {resume_offset} came back content-encoded")
limiter = speed_limiter
chunk_size = adaptive_chunk_size(content_length)
@@ -1055,9 +1115,17 @@ def download(
raise IOError(f"Failed to read {content_length} bytes from the track URI.")
if not part_mode:
if chunk_end is not None and known_total is not None and resume_offset + written < known_total:
if known_total is not None:
incomplete = resume_offset + written < known_total
else:
incomplete = written == 0 or (stream.status_code == 206 and not item_range)
if incomplete:
if resume_offset + written <= resume_start:
raise IOError(f"range from byte {resume_start} returned no bytes past it")
resume_offset += written
attempts = 1
if resume_offset > high_water:
high_water = resume_offset
attempts = 1
continue
os.replace(tmp_file, save_path)
yield dict(file_downloaded=save_path, written=resume_offset + written)
@@ -1114,22 +1182,49 @@ def download(
attempts += 1
def rebuildable_adapter(adapter: Any) -> bool:
"""Whether a child can remount ``adapter`` with the same transport behaviour.
A child mounts a plain HTTPAdapter. TimeoutHTTPAdapter counts as the same transport here:
its one override supplies a default timeout when the caller passes none, and every request
this module sends carries an explicit timeout. Any other subclass changes the transport
itself, which a spec cannot carry: SSLCiphers swaps the SSL context, so a child would
negotiate a different cipher list and the host would reject it.
"""
from unshackle.core.service import TimeoutHTTPAdapter
return type(adapter) in (HTTPAdapter, TimeoutHTTPAdapter)
def build_session_spec(session: Optional[Any]) -> Optional[dict[str, Any]]:
"""Picklable spec to rebuild ``session`` in a child process. Gives None when it is not cheaply rebuildable.
Live sessions (sockets, TLS state, threads) cannot cross a process boundary, so each child
reconstructs its own from cheap state. RnetSession needs a resolvable impersonate preset.
Without one it is not cheaply rebuildable, and the caller falls back to a single process.
reconstructs its own from cheap state. State a child cannot reproduce gives None, and the
caller falls back to a single process: a slower correct download beats a broken one.
RnetSession needs a resolvable impersonate preset. A requests session needs every mounted
adapter to be one a child can remount, and a spec spawn can pickle.
"""
if session is None:
return {"kind": "none"}
if is_requests_session(session):
return {
if not all(rebuildable_adapter(adapter) for adapter in session.adapters.values()):
return None
spec: dict[str, Any] = {
"kind": "requests",
"headers": dict(session.headers),
"cookies": session.cookies, # RequestsCookieJar pickles cleanly
"proxies": dict(session.proxies),
"params": copy(session.params),
"verify": session.verify,
"cert": session.cert,
"auth": session.auth,
}
try:
pickle.dumps(spec)
except Exception:
return None
return spec
if is_rnet_session(session):
name = session.impersonate_name
if not name:
@@ -1139,6 +1234,7 @@ def build_session_spec(session: Optional[Any]) -> Optional[dict[str, Any]]:
"impersonate": name,
"headers": dict(session.headers),
"cookies": session.cookies.get_dict_by_domain(),
"origin_cookies": session.export_origin_cookies(),
"proxy": session.proxies.get("all") or session.proxies.get("https") or session.proxies.get("http"),
}
return None
@@ -1164,6 +1260,11 @@ def rebuild_session(spec: dict[str, Any], max_workers: int) -> Optional[Any]:
rs.cookies.update(spec["cookies"])
if spec.get("proxies"):
rs.proxies.update(spec["proxies"])
if spec.get("params"):
rs.params = spec["params"]
rs.verify = spec.get("verify", True)
rs.cert = spec.get("cert")
rs.auth = spec.get("auth")
adapter = HTTPAdapter(pool_connections=max_workers, pool_maxsize=max_workers, pool_block=True)
rs.mount("https://", adapter)
rs.mount("http://", adapter)
@@ -1183,6 +1284,7 @@ def rebuild_session(spec: dict[str, Any], max_workers: int) -> Optional[Any]:
ns.cookies.update(jar)
if spec.get("proxy"):
ns.proxies.update({"all": spec["proxy"]})
ns.import_origin_cookies(spec.get("origin_cookies") or {})
return ns
return None # "none": child builds its own Session from the passed headers/cookies/proxy
@@ -1209,8 +1311,9 @@ def mp_worker(queue: Any, kwargs: dict[str, Any]) -> None:
kwargs["session"] = rebuild_session(spec, int(kwargs.get("max_workers") or 1))
for event in requests(**kwargs):
queue.put(event)
except Exception:
queue.put({"__mp_error__": traceback.format_exc()})
except Exception as exc:
summary = " ".join(f"{type(exc).__name__}: {exc}".split())
queue.put({"__mp_error__": f"{summary}\n{traceback.format_exc()}"})
finally:
queue.put({"__mp_done__": True})
@@ -1326,7 +1429,7 @@ def download_multiprocess(
if "__mp_error__" in event:
for p in procs:
p.terminate()
raise RuntimeError(f"segment download child failed:\n{event['__mp_error__']}")
raise RuntimeError(f"segment download child failed: {event['__mp_error__']}")
if "total" in event:
continue
if "downloaded" in event:
@@ -1581,7 +1684,8 @@ def requests(
and not has_range_header(url_item)
and not url_item["save_path"].exists()
):
total_size, supports_ranges = probe_ranged(url_item["url"], session)
probe_kwargs = {k: v for k, v in url_item.items() if k not in ("url", "save_path")}
total_size, supports_ranges = probe_ranged(url_item["url"], session, **probe_kwargs)
if supports_ranges and total_size >= RANGE_PARALLEL_MIN_SIZE:
try:
yield from dispatch_parts(
@@ -1730,6 +1834,7 @@ def requests(
# indices probed and left for the normal single-worker path, each decided once
tail_boosted: set[int] = set()
tail_skipped: set[int] = set()
part_aborts: list[threading.Event] = []
def submit(count: int, only: Optional[set[int]] = None) -> None:
# only=<set> submits just those indices (used in the tail window to release
@@ -1762,6 +1867,7 @@ def requests(
part_lock: threading.Lock,
req_kwargs: dict[str, Any],
) -> None:
failure: Optional[Exception] = None
try:
# part_mode download writes [start, end] into the pre-truncated target and
# emits only byte-`advance`; swallow those, since tail segments report at segment
@@ -1777,16 +1883,24 @@ def requests(
**req_kwargs,
):
pass
except BaseException:
abort.set() # stop sibling parts of this segment; a real failure fails the batch
raise
except BaseException as exc:
abort.set() # stop the sibling parts of this segment, and only those
if not isinstance(exc, Exception):
raise # KeyboardInterrupt and friends still end the batch
failure = exc
with part_lock:
parts_left[0] -= 1
finalize = parts_left[0] == 0
if not finalize:
last = parts_left[0] == 0
if DOWNLOAD_CANCELLED.is_set():
return # cancelled: keep the partial target for the stray sweep, don't finalize
if failure is not None or abort.is_set():
if last:
part_target.unlink(missing_ok=True)
if failure is not None:
raise TailPartFailed(index) from failure
return # a sibling part failed; that one reports the segment
if not last:
return
if DOWNLOAD_CANCELLED.is_set() or abort.is_set():
return # cancelled/aborted: keep the partial target for the stray sweep, don't finalize
with seg_lock:
if index in seg_done:
part_target.unlink(missing_ok=True)
@@ -1849,8 +1963,8 @@ def requests(
f.truncate(size)
seg_start[index] = time.time()
tail_boosted.add(index)
# a failed part fails the whole batch anyway, so parts share batch_abort
abort = batch_abort
abort = threading.Event()
part_aborts.append(abort)
parts_left = [len(parts)]
part_lock = threading.Lock()
for start, end in parts:
@@ -1872,12 +1986,10 @@ def requests(
)
boosted += 1
# submitting every segment up front convoys on the pool's global lock and starves the
# event drain, so fixed mode meters submission through a window the loop tops up
queue_depth = max_workers * 2
queue_depth = submission_window(max_workers)
if controller:
submit(controller.update(time.time()))
submit(controller.update(time.time(), len(remaining)))
else:
submit(queue_depth)
@@ -1914,15 +2026,12 @@ def requests(
if controller:
target = controller.update(now, len(pending) + len(remaining))
maybe_tail_boost(target)
if len(pending) < target:
# In the final stride (unstarted segments <= one worker target), hold back
# boost candidates so idle workers accumulate for a range-split; only
# release segments the boost already declined (too small / no range) so
# nothing stalls. Otherwise top up in FIFO order as usual.
queue_target = submission_window(target)
if len(pending) < queue_target:
if remaining and len(remaining) <= target:
submit(target - len(pending), only=tail_skipped)
else:
submit(target - len(pending))
submit(queue_target - len(pending))
elif len(pending) < queue_depth:
submit(queue_depth - len(pending))
@@ -1975,6 +2084,12 @@ def requests(
exc = future.exception()
if isinstance(exc, KeyboardInterrupt):
raise KeyboardInterrupt()
elif isinstance(exc, TailPartFailed) and permanent_status(exc) is None:
if exc.index not in tail_skipped:
tail_boosted.discard(exc.index)
tail_skipped.add(exc.index)
remaining.appendleft((exc.index, urls[exc.index]))
continue
elif exc:
DOWNLOAD_CANCELLED.set()
yield dict(downloaded="[red]FAILING")
@@ -2006,6 +2121,8 @@ def requests(
# batch_abort outlives the shutdown so a leaked worker exits at its next
# arrival/timeout instead of retrying after the global flag is cleared
batch_abort.set()
for part_abort in part_aborts:
part_abort.set()
# batch_abort is set, so a loser whose read we unblock here sees it and exits without
# retrying; unblock only on the success path (the wait below still guarantees handles
# are released before merge sweeps). rnet streams have no reachable socket -> no-op.
+151 -14
View File
@@ -230,13 +230,14 @@ class RnetResponse:
class RnetSessionHeaders(CaseInsensitiveDict):
"""Dict-like headers that write to the rnet client through update()."""
def __init__(self, client: Any) -> None:
self._client = client
def __init__(self, session: Optional[RnetSession] = None) -> None:
self._session = session
self._client: Any = None
super().__init__()
def sync(self) -> None:
"""Push current headers to the rnet client."""
if self._client is not None and hasattr(self, "_store") and self._store:
if self._client is not None and hasattr(self, "_store"):
self._client.update(headers={k: v for k, v in self.items()})
def __setitem__(self, key: str, value: str) -> None:
@@ -255,21 +256,31 @@ class RnetSessionHeaders(CaseInsensitiveDict):
super().__setitem__(k, v)
self.sync()
def pop(self, key: str, *args: Any) -> Any:
result = super().pop(key, *args)
# rnet doesn't support removing individual headers, but we track locally
# and always send the full set on next update
return result
def rebuild(self) -> None:
"""Remake the rnet client so removed headers stop going out.
rnet's update() merges into the client default headers and offers no removal,
so a header dropped from this dict alone keeps reaching every later host.
"""
if self._session is not None and self._client is not None:
self._session.rebuild_client()
def __delitem__(self, key: str) -> None:
super().__delitem__(key)
self.rebuild()
def clear(self) -> None:
for key in list(self.keys()):
super().__delitem__(key)
self.rebuild()
class RnetCookieAdapter(MutableMapping):
"""Cookie adapter that bridges requests-style cookie access to rnet."""
def __init__(self, client: Any) -> None:
def __init__(self, client: Any, session: Optional[RnetSession] = None) -> None:
self._client = client
self._session = session
self._cookies: dict[str, dict[str, str]] = {}
self._flat: dict[str, str] = {}
self._original_cookies: list[Any] = []
@@ -294,6 +305,36 @@ class RnetCookieAdapter(MutableMapping):
except Exception:
pass
def client_urls(self, domain: Optional[str] = None) -> list[str]:
"""URLs the rnet cookie jar could hold a cookie under.
rnet keys its jar by URL and cannot list the jar whole, so a removal has to name every
URL this class has set a cookie on and every origin the HTTP session has requested.
"""
urls = {f"https://{d.lstrip('.')}" for d in self._cookies if d}
urls.add("https://localhost")
if self._session is not None:
urls |= set(self._session._origins)
if domain is not None:
host = domain.lstrip(".")
urls = {url for url in urls if (urlparse(url).hostname or "") == host}
urls.add(f"https://{host}")
return sorted(urls)
def remove_cookie_on_client(self, name: str, domain: Optional[str] = None) -> None:
"""Drop a cookie from the rnet cookie jar.
The client is built with cookie_store=True, so a cookie the server set lives in that
jar alone. Dropping it from the local dicts leaves it going out on the wire.
"""
if self._client is None:
return
for url in self.client_urls(domain):
try:
self._client.remove_cookie(url, name)
except Exception:
pass
@property
def jar(self) -> CookieJar:
"""Return a CookieJar with original Cookie objects (requests compat).
@@ -355,6 +396,8 @@ class RnetCookieAdapter(MutableMapping):
self._flat.pop(name, None)
for domain_cookies in self._cookies.values():
domain_cookies.pop(name, None)
self._original_cookies = [cookie for cookie in self._original_cookies if cookie.name != name]
self.remove_cookie_on_client(name)
def __contains__(self, name: object) -> bool:
return name in self._flat
@@ -406,6 +449,12 @@ class RnetCookieAdapter(MutableMapping):
else:
for domain_cookies in self._cookies.values():
domain_cookies.pop(name, None)
self._original_cookies = [
cookie
for cookie in self._original_cookies
if cookie.name != name or (domain is not None and cookie.domain != domain)
]
self.remove_cookie_on_client(name, domain)
elif domain is not None:
removed = self._cookies.pop(domain, {})
for k in removed:
@@ -413,9 +462,15 @@ class RnetCookieAdapter(MutableMapping):
still_exists = any(k in dc for dc in self._cookies.values())
if not still_exists:
self._flat.pop(k, None)
self._original_cookies = [cookie for cookie in self._original_cookies if cookie.domain != domain]
for k in removed:
self.remove_cookie_on_client(k, domain)
else:
self._flat.clear()
self._cookies.clear()
self._original_cookies.clear()
if self._client is not None:
self._client.clear_cookies()
def items(self) -> list[tuple[str, str]]:
return list(self._flat.items())
@@ -530,9 +585,10 @@ class RnetSession:
self._client_kwargs = dict(client_kwargs)
self._client: Optional[rnet.BlockingClient] = None
self._origins: set[str] = set()
self.headers = RnetSessionHeaders(None)
self.cookies = RnetCookieAdapter(None)
self.headers = RnetSessionHeaders(self)
self.cookies = RnetCookieAdapter(None, self)
self.proxies = RnetProxyDict(self)
if "headers" in session_kwargs:
@@ -559,13 +615,90 @@ class RnetSession:
def ensure_client(self) -> rnet.BlockingClient:
"""Lazily make the rnet client on first use, flushing any buffered state."""
if self._client is None:
self._client = rnet.BlockingClient(**self._client_kwargs)
self.headers._client = self._client
client = rnet.BlockingClient(**self._client_kwargs)
self.headers._client = client
self.headers.sync()
self.cookies._client = self._client
self.cookies._client = client
self.cookies.flush_to_client()
self._client = client
return self._client
def rebuild_client(self) -> None:
"""Replace the rnet client with one that carries only the current header set.
rnet merges header updates and has no header removal, so a new client is the only
way to drop a header the caller deleted. Do not pass the headers as default_headers:
that replaces the impersonate header set instead of overlaying it, which breaks the
fingerprint.
A new client also starts with an empty cookie jar. This method replays the cookies
unshackle set itself, then the cookies of every origin this HTTP session has used.
That order lets a value the server rotated win over the buffered one. The new client
goes on the HTTP session last, so a request from another thread keeps the old client
until the new one holds the headers and the cookies.
"""
old_client = self._client
if old_client is None:
return
client = rnet.BlockingClient(**self._client_kwargs)
self.headers._client = client
self.headers.sync()
self.cookies._client = client
self.cookies.flush_to_client()
for origin in list(self._origins):
self.copy_cookies(old_client, client, origin)
self._client = client
@staticmethod
def set_cookie_header(client: rnet.BlockingClient, origin: str, header: str) -> None:
"""Set every cookie in a ``name=value; ...`` header string on one origin of *client*."""
for pair in header.split(";"):
name, separator, value = pair.partition("=")
if not separator:
continue
try:
client.set_cookie(origin, rnet.Cookie(name.strip(), value.strip()))
except Exception:
pass
@staticmethod
def copy_cookies(source: rnet.BlockingClient, target: rnet.BlockingClient, origin: str) -> None:
"""Copy one origin's cookies between clients. rnet cannot list the whole cookie jar."""
try:
raw = source.get_cookies(origin)
except Exception:
return
if not raw:
return
RnetSession.set_cookie_header(target, origin, raw.decode("utf-8", errors="replace"))
def export_origin_cookies(self) -> dict[str, str]:
"""Cookie headers the live client holds, one per origin this HTTP session has requested.
The client owns its own cookie jar, so a cookie a server set lives there and in no
part of :class:`RnetCookieAdapter`, which records only what was set through it. rnet
cannot list the jar, so this covers the origins already requested and no others.
"""
if self._client is None:
return {}
exported: dict[str, str] = {}
for origin in list(self._origins):
try:
raw = self._client.get_cookies(origin)
except Exception:
continue
if raw:
exported[origin] = raw.decode("utf-8", errors="replace")
return exported
def import_origin_cookies(self, exported: dict[str, str]) -> None:
"""Load cookie headers from :meth:`export_origin_cookies` into this HTTP session's client."""
if not exported:
return
client = self.ensure_client()
for origin, header in exported.items():
self.set_cookie_header(client, origin, header)
def build_url(self, url: str, params: Optional[Any] = None) -> str:
"""Encode params into the URL (rnet ignores the params kwarg).
@@ -632,6 +765,10 @@ class RnetSession:
url = self.build_url(url, kwargs.pop("params", None))
parsed_origin = urlparse(url)
if parsed_origin.scheme and parsed_origin.netloc:
self._origins.add(f"{parsed_origin.scheme}://{parsed_origin.netloc}")
kwargs.setdefault("allow_redirects", True)
if not self.verify: