mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
test(downloader): cover truncation and retry spin guards
Assert that an empty body of unknown length, a 206 with no complete length, and a 206 starting past the resume offset each fail instead of finalizing, and that every restart path spends an attempt.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""A sequential download must not finalize a body it cannot prove is whole, and must not
|
||||
retry one for free.
|
||||
|
||||
Every shape here reaches ``os.replace(tmp_file, save_path)`` or a ``continue`` that skips
|
||||
the retry handler. The first family renames a short file into place and reports it as a
|
||||
finished track, so the merge takes a truncated segment; the second repeats one request
|
||||
forever, spending no attempt and taking no backoff.
|
||||
|
||||
Each test runs the download in a daemon thread with a hard join deadline, so a regression
|
||||
fails the suite instead of stalling it.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import importlib
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
|
||||
dl = importlib.import_module("unshackle.core.downloaders.requests")
|
||||
|
||||
TOTAL = 8192
|
||||
PAYLOAD = bytes((i * 37 + 11) % 256 for i in range(TOTAL))
|
||||
PREFIX = 1024 # what a capping or lying host serves instead of the whole thing
|
||||
SLICE_END = 4095 # inclusive end of the byte range the slice tests ask for
|
||||
DEADLINE = 60
|
||||
|
||||
|
||||
class _Server(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.requests: list = []
|
||||
self.mode = ""
|
||||
super().__init__(("127.0.0.1", 0), _Handler)
|
||||
|
||||
def handle_error(self, request, client_address): # the client hanging up is expected here
|
||||
pass
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def chunked(self, status: int, body: bytes, **headers: str) -> None:
|
||||
self.send_response(status)
|
||||
for name, value in headers.items():
|
||||
self.send_header(name.replace("_", "-"), value)
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(b"%x\r\n" % len(body) + body + b"\r\n")
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
|
||||
def sized(self, status: int, body: bytes, **headers: str) -> None:
|
||||
self.send_response(status)
|
||||
for name, value in headers.items():
|
||||
self.send_header(name.replace("_", "-"), value)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
srv = self.server
|
||||
header = self.headers.get("Range")
|
||||
with srv.lock:
|
||||
srv.requests.append(header)
|
||||
mode = srv.mode
|
||||
start = int(header.removeprefix("bytes=").split("-")[0]) if header else 0
|
||||
|
||||
if mode == "empty200":
|
||||
return self.chunked(200, b"")
|
||||
if mode == "chunked200":
|
||||
return self.chunked(200, PAYLOAD)
|
||||
if mode in ("short_slice", "good_slice"):
|
||||
end = int(header.removeprefix("bytes=").split("-")[1])
|
||||
body = PAYLOAD[start : end + 1]
|
||||
if mode == "short_slice":
|
||||
body = body[:PREFIX]
|
||||
return self.chunked(206, body, Content_Range=f"bytes {start}-{end}/{TOTAL}")
|
||||
if mode == "star206":
|
||||
if start >= TOTAL:
|
||||
return self.sized(416, b"", Content_Range=f"bytes */{TOTAL}")
|
||||
end = min(TOTAL - 1, start + PREFIX - 1)
|
||||
return self.sized(206, PAYLOAD[start : end + 1], Content_Range=f"bytes {start}-{end}/*")
|
||||
if mode == "nonzero_start206":
|
||||
half = TOTAL // 2
|
||||
return self.sized(206, PAYLOAD[half:], Content_Range=f"bytes {half}-{TOTAL - 1}/{TOTAL}")
|
||||
if mode == "prefix206":
|
||||
return self.sized(206, PAYLOAD[:PREFIX], Content_Range=f"bytes 0-{PREFIX - 1}/{TOTAL}")
|
||||
if mode == "encoded_resume":
|
||||
return self.sized(
|
||||
206,
|
||||
gzip.compress(PAYLOAD[start : start + PREFIX]),
|
||||
Content_Range=f"bytes {start}-{TOTAL - 1}/{TOTAL}",
|
||||
Content_Encoding="gzip",
|
||||
)
|
||||
# the modes below answer the first, un-Ranged request with a short but honest 206,
|
||||
# which teaches the download a complete length to resume towards
|
||||
if header is None:
|
||||
return self.sized(206, PAYLOAD[:PREFIX], Content_Range=f"bytes 0-{PREFIX - 1}/{TOTAL}")
|
||||
if mode == "ignore_range200":
|
||||
return self.sized(200, PAYLOAD[:PREFIX])
|
||||
if mode == "bare416":
|
||||
return self.sized(416, b"")
|
||||
if mode == "shorter416":
|
||||
return self.sized(416, b"", Content_Range=f"bytes */{PREFIX}")
|
||||
raise AssertionError(f"unknown mode {mode!r}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(monkeypatch):
|
||||
DOWNLOAD_CANCELLED.clear()
|
||||
monkeypatch.setattr(dl, "RETRY_WAIT", 0)
|
||||
srv = _Server()
|
||||
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 run(srv, save_path, **kwargs):
|
||||
"""Drain download() on a daemon thread, giving back (events, error)."""
|
||||
url = f"http://127.0.0.1:{srv.server_address[1]}/seg.mp4"
|
||||
events: list = []
|
||||
errors: list = []
|
||||
|
||||
def go() -> None:
|
||||
try:
|
||||
events.extend(dl.download(url=url, save_path=save_path, session=Session(), **kwargs))
|
||||
except BaseException as exc: # noqa: BLE001 - the deadline assert reports it
|
||||
errors.append(exc)
|
||||
|
||||
worker = threading.Thread(target=go, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=DEADLINE)
|
||||
assert not worker.is_alive(), f"download still running after {DEADLINE}s ({len(srv.requests)} requests)"
|
||||
return events, (errors[0] if errors else None)
|
||||
|
||||
|
||||
def assert_bounded(srv, error, save_path, per_attempt=1):
|
||||
assert isinstance(error, IOError), f"expected an IOError, got {error!r}"
|
||||
assert not save_path.exists(), f"finalized {save_path.stat().st_size} bytes"
|
||||
assert len(srv.requests) <= dl.MAX_ATTEMPTS * per_attempt + 1
|
||||
|
||||
|
||||
def test_empty_200_body_is_not_a_finished_file(server, tmp_path):
|
||||
"""A chunked 200 with no body: unknown length is not proof that the resource is empty."""
|
||||
server.mode = "empty200"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path)
|
||||
|
||||
|
||||
def test_short_byte_range_slice_is_not_a_finished_segment(server, tmp_path):
|
||||
"""A slice carries no Content-Length here, so its Content-Range is the only expected size."""
|
||||
server.mode = "short_slice"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path, headers={"Range": f"bytes=0-{SLICE_END}"}, segmented=True)
|
||||
|
||||
assert_bounded(server, error, save_path)
|
||||
|
||||
|
||||
def test_whole_byte_range_slice_still_finalizes(server, tmp_path):
|
||||
"""The guard above must not reject a chunked slice that arrives whole."""
|
||||
server.mode = "good_slice"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path, headers={"Range": f"bytes=0-{SLICE_END}"}, segmented=True)
|
||||
|
||||
assert error is None
|
||||
assert save_path.read_bytes() == PAYLOAD[: SLICE_END + 1]
|
||||
|
||||
|
||||
def test_unknown_total_206_resumes_instead_of_finalizing_short(server, tmp_path):
|
||||
"""`bytes 0-N/*` names no complete length, so only a 416 can end the download."""
|
||||
server.mode = "star206"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path, segmented=True)
|
||||
|
||||
assert error is None
|
||||
assert save_path.read_bytes() == PAYLOAD
|
||||
|
||||
|
||||
def test_206_starting_past_byte_zero_is_refused(server, tmp_path):
|
||||
"""Nothing repositions the write, so those bytes would land at offset 0."""
|
||||
server.mode = "nonzero_start206"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path)
|
||||
|
||||
|
||||
def test_resume_answered_with_the_same_prefix_spends_attempts(server, tmp_path):
|
||||
server.mode = "prefix206"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path, per_attempt=2)
|
||||
|
||||
|
||||
def test_resume_ignored_by_a_200_spends_attempts(server, tmp_path):
|
||||
"""The 200 re-sends the same prefix, so the file never grows past where it started."""
|
||||
server.mode = "ignore_range200"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path, per_attempt=2)
|
||||
|
||||
|
||||
def test_content_encoded_resume_spends_attempts(server, tmp_path):
|
||||
server.mode = "encoded_resume"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path, per_attempt=2)
|
||||
|
||||
|
||||
def test_bare_416_restart_spends_attempts(server, tmp_path):
|
||||
"""A 416 with no Content-Range proves nothing about where the resource ends."""
|
||||
server.mode = "bare416"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path, per_attempt=2)
|
||||
|
||||
|
||||
def test_416_shorter_than_the_known_total_does_not_finalize(server, tmp_path):
|
||||
"""Two responses name two complete lengths; the partial matches only the shorter one."""
|
||||
server.mode = "shorter416"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
_, error = run(server, save_path)
|
||||
|
||||
assert_bounded(server, error, save_path, per_attempt=2)
|
||||
|
||||
|
||||
def test_unknown_length_body_with_real_bytes_still_finalizes(server, tmp_path):
|
||||
"""A chunked 200 that arrives whole must still finalize."""
|
||||
server.mode = "chunked200"
|
||||
save_path = tmp_path / "seg.mp4"
|
||||
|
||||
events, error = run(server, save_path, segmented=True)
|
||||
|
||||
assert error is None
|
||||
assert save_path.read_bytes() == PAYLOAD
|
||||
assert [event["written"] for event in events if "file_downloaded" in event] == [TOTAL]
|
||||
Reference in New Issue
Block a user