Files
unshackle/tests/downloaders/test_empty_subrange.py
T
imSp4rky dffb8b27e3 fix(serve): lock shared session state and throttle progress writes
Handlers share one service instance, so a per-session lock keeps two requests out of it at once. Track threads merge and write job state under one lock, and the progress sink coalesces ticks that say the same thing.
2026-09-06 20:01:38 -06:00

111 lines
4.0 KiB
Python

"""A bounded sub-range that returns no bytes must fail, not spin.
After a cut, the sequential path asks for bounded sub-ranges and treats a whole
sub-range as progress rather than a retry. A response with an empty body of
unknown length (a chunked 206, a 204, a 304) makes no progress, so it must
raise and spend an attempt, or the same request repeats forever.
"""
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
import requests as rq
from unshackle.core.constants import DOWNLOAD_CANCELLED
from unshackle.core.downloaders.requests import download
# the package re-exports the `requests` function, shadowing the module of the same name
downloader = sys.modules[download.__module__]
BODY = bytes(range(256)) * 256 # 64 KiB
CUT_AFTER = 10 * 1024
CHUNK = 16 * 1024
DEADLINE = 30
class _EmptyRangeServer(ThreadingHTTPServer):
"""Cuts the first body, then answers every bounded range with an empty chunked 206."""
allow_reuse_address = True
daemon_threads = True
def __init__(self):
self.ranges: list[str] = []
super().__init__(("127.0.0.1", 0), _EmptyRangeHandler)
self.url = f"http://127.0.0.1:{self.server_address[1]}/seg.mp4"
def handle_error(self, request, client_address): # the client hanging up is expected here
pass
class _EmptyRangeHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def do_GET(self):
header = self.headers.get("Range")
self.server.ranges.append(header)
start, end, bounded = 0, len(BODY) - 1, False
if header:
start_s, _, end_s = header.removeprefix("bytes=").partition("-")
start, end = int(start_s), (int(end_s) if end_s else len(BODY) - 1)
bounded = bool(end_s)
if bounded:
# a valid response: the chunked terminator closes a zero-length body, so requests raises nothing
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BODY)}")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.write(b"0\r\n\r\n")
return
body = BODY[start : end + 1]
self.send_response(206 if header else 200)
if header:
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BODY)}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
# the cut teaches the downloader to bound the requests that follow
self.wfile.write(body[:CUT_AFTER])
self.close_connection = True
@pytest.fixture
def server(monkeypatch):
DOWNLOAD_CANCELLED.clear()
monkeypatch.setattr(downloader, "RETRY_WAIT", 0)
monkeypatch.setattr(downloader, "REQUEST_CHUNK_SIZE", CHUNK)
monkeypatch.setattr(downloader, "MIN_REQUEST_CHUNK", 4 * 1024)
monkeypatch.setattr(downloader, "MIN_RESUME_PROGRESS", 1024)
server = _EmptyRangeServer()
threading.Thread(target=server.serve_forever, daemon=True).start()
yield server
server.shutdown()
server.server_close()
DOWNLOAD_CANCELLED.clear()
def test_empty_sub_range_fails_instead_of_looping(tmp_path, server):
save_path = tmp_path / "0.mp4"
result: list[BaseException] = []
def run() -> None:
try:
list(download(url=server.url, save_path=save_path, session=rq.Session()))
except BaseException as exc:
result.append(exc)
# a hard deadline: a regression hangs this thread, and the suite must fail, not stall
worker = threading.Thread(target=run, daemon=True)
worker.start()
worker.join(timeout=DEADLINE)
assert not worker.is_alive(), f"download still running after {DEADLINE}s ({len(server.ranges)} requests)"
assert result and isinstance(result[0], IOError), f"expected an IOError, got {result!r}"
assert "no bytes" in str(result[0])
assert len(server.ranges) <= downloader.MAX_ATTEMPTS + 1
assert not save_path.exists()