mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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.
111 lines
4.0 KiB
Python
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()
|