fix(downloader): honor Retry-After with cancel-safe backoff, add fault-injection bench and tests

This commit is contained in:
imSp4rky
2026-07-18 20:21:30 -06:00
parent 2c8b6774af
commit b54aadea7a
12 changed files with 1589 additions and 19 deletions
+188 -1
View File
@@ -43,6 +43,7 @@ from shutil import rmtree
from typing import Any, Optional
import click
import fault_server # local sibling (scripts/): fault-injection server + payload verifier
from rich.console import Console
from rich.table import Table
@@ -393,6 +394,174 @@ def print_report(rows: list[Row], runs: int, total_segments: int) -> None:
)
def fault_integrity(out_dir: Path, segments: int, seg_size: int) -> tuple[list[int], list[int]]:
"""Verify every output file byte-for-byte against its seeded expected payload.
Returns (missing, corrupt). ``corrupt`` (present but not byte-identical) is the loud
CORRUPTION signal: a fault path that finalizes wrong bytes must fail the run, not pass.
"""
missing: list[int] = []
corrupt: list[int] = []
for i in range(segments):
p = out_dir / f"seg_{i}.bin"
if not p.exists():
missing.append(i)
continue
if p.read_bytes() != fault_server.segment_payload(f"/seg/{i}", seg_size):
corrupt.append(i)
return missing, corrupt
# per worker-count under a fault profile: workers, median_s, MB/s, missing, corrupt, raised,
# requests seen, amplification (requests/segments), retries (requests-segments), status_counts,
# then worst-across-runs sweep safety: strays, locked handles, live pool threads
FaultRow = tuple[int, float, float, list[int], list[int], bool, int, float, int, dict[int, int], int, int, int]
def print_fault_report(rows: list[FaultRow], runs: int, segments: int, profile: str) -> None:
plural = "s" if runs != 1 else ""
table = Table(title=f"fault profile '{profile}': {segments} segments (median of {runs} run{plural})")
table.add_column("workers", justify="right")
table.add_column("seconds", justify="right")
table.add_column("MB/s", justify="right")
table.add_column("status", justify="left")
table.add_column("requests", justify="right")
table.add_column("amp", justify="right")
table.add_column("sweep", justify="left")
for w, secs, mbps, missing, corrupt, raised, requests_seen, amp, _retries, _codes, strays, locked, threads in rows:
if corrupt:
status = f"[red]CORRUPTION {len(corrupt)} segs[/]"
elif missing:
status = f"[red]FAIL {len(missing)} incomplete[/]"
elif raised:
status = "[yellow]raised (all present)[/]"
else:
status = "[green]ok[/]"
if locked:
sweep_status = f"[red]{locked} LOCKED (WinError 32)[/]"
elif threads:
sweep_status = f"[yellow]{threads} pool threads live[/]"
elif strays:
sweep_status = f"{strays} strays"
else:
sweep_status = "[green]clean[/]"
table.add_row(str(w), f"{secs:.3f}", f"{mbps:.1f}", status, str(requests_seen), f"{amp:.2f}x", sweep_status)
console.print(table)
for w, _secs, _mbps, _missing, _corrupt, _raised, requests_seen, amp, retries, codes, *_rest in rows:
code_str = ", ".join(f"{c}:{n}" for c, n in codes.items()) or "none"
console.print(
f"amplification workers={w:<3} {requests_seen} requests for {segments} segments "
f"({amp:.2f}x, {retries} excess requests (retries/hedges/probes/parts)), statuses [{code_str}]"
)
def _procs_notice(procs: int, n_urls: int, session: Optional[Any]) -> None:
"""Warn loudly when --procs cannot engage, so a run is never mistaken for a multiprocess test."""
if procs <= 1:
return
if n_urls < dl_mod.MP_MIN_SEGMENTS:
console.print(
f"[yellow]--procs {procs} will NOT engage: {n_urls} segments < MP_MIN_SEGMENTS "
f"({dl_mod.MP_MIN_SEGMENTS}); the single-process path runs instead[/]"
)
elif dl_mod._build_session_spec(session) is None:
console.print(
f"[yellow]--procs {procs} will NOT engage: session not rebuildable in child processes; "
f"the single-process path runs instead[/]"
)
def run_fault_mode(
profile_name: str,
segments: int,
seg_size: int,
worker_counts: list[int],
runs: int,
session: Optional[Any],
adaptive: bool,
procs: int,
seed: int,
) -> None:
"""Start a FaultServer in-process, sweep worker counts, and report reliability + overhead.
Reuses run_batch/tail_droop/sweep_strays (via run_batch) and the report conventions, adding
request amplification and byte-exact integrity, the metrics that catch retry/hedge storms
and silent corruption against a misbehaving host.
"""
profile = fault_server.resolve_profile(profile_name)
server = fault_server.FaultServer(profile, seg_size, segments, seed)
threading.Thread(target=server.serve_forever, daemon=True).start()
host, port = str(server.server_address[0]), int(server.server_address[1])
urls = [f"http://{host}:{port}/seg/{i}" for i in range(segments)]
console.print(
f"[cyan]Fault mode:[/] profile={profile_name} seed={seed} "
f"{segments} x {seg_size / MIB:.2f} MiB, impersonate={getattr(session, 'impersonate_name', None) or 'none'}"
)
rows: list[FaultRow] = []
try:
with tempfile.TemporaryDirectory(prefix="faultbench_") as root_str:
root = Path(root_str)
for w in worker_counts:
samples: list[tuple[float, float, list[int], list[int], bool, int, dict[int, int], int, int, int]] = []
for _ in range(runs):
server.reset_counters()
out_dir = Path(tempfile.mkdtemp(prefix="run_", dir=root))
secs, _comps, err, strays, locked, pool_threads = run_batch(
urls, out_dir, w, session, adaptive, procs
)
missing, corrupt = fault_integrity(out_dir, segments, seg_size)
stats = server.stats()
samples.append(
(
secs,
downloaded_mib(out_dir),
missing,
corrupt,
err is not None,
stats["requests"],
stats["status_counts"],
strays,
locked,
pool_threads,
)
)
rmtree(out_dir, ignore_errors=True)
median_s = statistics.median(s[0] for s in samples)
best = min(samples, key=lambda s: abs(s[0] - median_s)) # representative run near the median
mbps = best[1] / best[0] if best[0] else 0.0
requests_seen = best[5]
amp = requests_seen / segments if segments else 0.0
retries = max(0, requests_seen - segments)
rows.append(
(
w,
median_s,
mbps,
best[2],
best[3],
best[4],
requests_seen,
amp,
retries,
best[6],
max(s[7] for s in samples),
max(s[8] for s in samples),
max(s[9] for s in samples),
)
)
finally:
server.stall_event.set() # release any stalled handler threads before shutdown
server.shutdown()
print_fault_report(rows, runs, segments, profile_name)
broken = {row[0]: (row[3], row[4]) for row in rows if row[3] or row[4]}
if broken:
detail = "; ".join(f"workers={w}: missing {miss} corrupt {corr}" for w, (miss, corr) in broken.items())
raise click.ClickException(f"incomplete/corrupt downloads under fault profile: {detail}")
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.option("--segments", default=64, show_default=True, help="Synthetic segment count.")
@click.option("--seg-size", default=2 * MIB, show_default=True, help="Bytes per synthetic segment.")
@@ -434,6 +603,12 @@ def print_report(rows: list[Row], runs: int, total_segments: int) -> None:
@click.option(
"--no-read1", is_flag=True, help="A/B: force racers back to blocking full-chunk reads (pre-fix behavior)."
)
@click.option(
"--fault-profile",
type=click.Choice(sorted(fault_server.PROFILES)),
help="Reliability mode: serve via the fault-injection server (fault_server.py) and verify integrity + amplification.",
)
@click.option("--seed", default=fault_server.DEFAULT_SEED, show_default=True, help="Fault RNG seed (--fault-profile).")
def main(
segments: int,
seg_size: int,
@@ -452,6 +627,8 @@ def main(
adaptive: bool,
procs: int,
no_read1: bool,
fault_profile: Optional[str],
seed: int,
) -> None:
"""Benchmark downloader throughput vs worker count, with optional fault injection."""
worker_counts = [int(w) for w in workers.split(",") if w.strip()]
@@ -481,7 +658,7 @@ def main(
saved["RACER_READ1"] = dl_mod.RACER_READ1
dl_mod.RACER_READ1 = False
else:
console.print("[yellow]--no-read1 ignored: this checkout has no RACER_READ1 (pre-fix behavior anyway)[/]")
console.print("[yellow]--no-read1 ignored: only applies to older checkouts that still have RACER_READ1[/]")
if impersonate:
from unshackle.core.session import session as make_session
@@ -489,6 +666,14 @@ def main(
session = make_session(impersonate)
try:
if fault_profile:
if urls_file:
console.print("[yellow]--urls-file ignored in --fault-profile mode[/]")
if any(faults.values()):
console.print("[yellow]--fault-* flags ignored in --fault-profile mode (use the profile knobs)[/]")
_procs_notice(procs, segments, session)
run_fault_mode(fault_profile, segments, seg_size, worker_counts, runs, session, adaptive, procs, seed)
return
if urls_file:
if any(faults.values()):
console.print("[yellow]fault flags ignored in --urls-file mode[/]")
@@ -496,6 +681,7 @@ def main(
if not urls:
raise click.ClickException("--urls-file contained no URLs")
total = len(urls)
_procs_notice(procs, len(urls), session)
console.print(f"[cyan]URL-file mode:[/] {len(urls)} URLs, impersonate={impersonate or 'none'}")
else:
# deterministic shared buffer; generated once, served for every segment, and
@@ -507,6 +693,7 @@ def main(
host, port = str(server.server_address[0]), int(server.server_address[1])
urls = [f"http://{host}:{port}/seg/{i}" for i in range(segments)]
active = {k: v for k, v in faults.items() if v}
_procs_notice(procs, segments, session)
console.print(
f"[cyan]Synthetic mode:[/] {segments} x {seg_size / MIB:.2f} MiB "
f"(latency={latency_ms}ms, rate={rate_kib or 'unbounded'} KiB/s, "
+22
View File
@@ -73,6 +73,16 @@ run scripts/bench_downloader.py --fault-503 2 --fast-timeouts --segments 8
run scripts/bench_downloader.py --fault-reset 2 --fast-timeouts --segments 8
run scripts/bench_downloader.py --fault-stall 1 --fast-timeouts --segments 8 || true
# 2b. Fault-injection reliability + request amplification (fault_server.py; every run is
# byte-integrity verified, so a CORRUPTION line here is a real finalize-wrong-bytes bug).
# loopback is the zero-fault python-overhead ceiling; the rest exercise retry/hedge/tail paths.
run scripts/bench_downloader.py --fault-profile loopback --segments 64 --workers 8,16 --runs 3
run scripts/bench_downloader.py --fault-profile rate-limit --segments 32 --workers 8
run scripts/bench_downloader.py --fault-profile flaky-first --segments 16 --workers 8 --fast-timeouts
run scripts/bench_downloader.py --fault-profile reset --segments 16 --workers 8 --fast-timeouts
run scripts/bench_downloader.py --fault-profile stall --segments 16 --workers 8 --fast-timeouts
run scripts/bench_downloader.py --fault-profile tail-slow --segments 32 --workers 8 --adaptive --fast-timeouts
# 3. Multiprocess path (>=24 segments engages spawned children; strided chunk split).
# big batch: at small sizes the fixed spawn cost drowns the throughput signal
run scripts/bench_downloader.py --segments 128 --workers 8 --procs 2
@@ -91,6 +101,18 @@ run scripts/bench_downloader.py --segments 32 --workers 8 --impersonate Chrome13
echo; echo "==================== pytest tests/test_downloader_leaks.py ===================="
uv run pytest tests/test_downloader_leaks.py -q || echo "(pytest failed or missing - run 'uv sync' to install dev deps)"
# 6b. Low-core variant: pin to two cores to emulate a small box, so the python-overhead
# ceiling and one fault profile can be read against the full-core numbers above. taskset is
# util-linux (Linux only); skipped cleanly elsewhere.
if command -v taskset >/dev/null 2>&1 && taskset -c 0-1 true 2>/dev/null; then
echo; echo "==================== low-core variant (taskset -c 0-1, 2 cores) ===================="
run_lowcore() { echo; echo "-------------------- taskset -c 0-1 $* --------------------"; taskset -c 0-1 uv run python "$@"; }
run_lowcore scripts/bench_downloader.py --fault-profile loopback --segments 64 --workers 8,16 --runs 3
run_lowcore scripts/bench_downloader.py --fault-profile rate-limit --segments 32 --workers 8
else
echo; echo "==================== low-core variant skipped (taskset missing or CPUs 0-1 not schedulable) ===================="
fi
# 7. Live network tests (opt-in)
if [[ "${1:-}" == "--live" ]]; then
run scripts/live_manifest_test.py --workers 16 --track largest
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
Localhost fault-injection HTTP server for downloader reliability benchmarking.
Serves deterministic pseudo-random segment payloads (seeded per URL path, so the
bytes are reproducible and any output file can be hashed against them to prove
integrity) and applies a configurable *fault profile* to those responses. Stall
and reset faults are decided deterministically from a printed seed keyed on
(path, attempt), so a retry can recover instead of failing forever; rate-limit
windows are wall-clock relative to run start, so their timing is not seed-bound.
Fault profiles:
stall sleep N seconds mid-body on X% of (path, attempt)s
reset RST the connection mid-body on X% of (path, attempt)s
rate-limit reply 429 + Retry-After during recurring throttle windows
(window_open seconds serving / window_closed seconds throttled)
tail-slow heavily throttle the last K segments' bodies
flaky-first fail attempt 1 per path (503), succeed on retry
loopback no faults; payloads served from memory (overhead ceiling)
Range requests are honored (206 + Content-Range) so byte-range / hedge / tail-boost
download paths exercise correctly, and an unsatisfiable resume gets a 416.
Standalone:
uv run python scripts/fault_server.py --profile rate-limit --segments 32
uv run python scripts/fault_server.py --profile stall --stall-secs 4 --port 8080
Importable: build a ``FaultServer``, serve it on a thread, and hash output against
``segment_payload(path, size)``.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
import socket
import struct
import threading
import time
from dataclasses import asdict, dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
MIB = 1024 * 1024
DEFAULT_SEED = 1729
def segment_payload(path: str, size: int) -> bytes:
"""Deterministic pseudo-random body for ``path``.
Pure and seed-stable: the bench regenerates this to byte-compare every output
file, so it must never depend on the fault seed, request order, or attempt.
"""
seed = int.from_bytes(hashlib.sha256(path.encode("utf-8")).digest()[:8], "big")
return random.Random(seed).randbytes(size)
@dataclass
class FaultProfile:
"""A named set of deterministic response faults. Presets live in ``PROFILES``."""
name: str
stall_pct: float = 0.0 # % of (path, attempt)s that stall mid-body
stall_secs: float = 3.0 # seconds to hold when a stall fires
reset_pct: float = 0.0 # % of (path, attempt)s that RST mid-body
rate_limit: bool = False # 429 + Retry-After during closed windows
retry_after: int = 1 # Retry-After seconds advertised on a 429
window_open: float = 5.0 # seconds serving normally before a throttle window
window_closed: float = 5.0 # seconds replying 429 during a throttle window
body_rate_kib: int = 0 # throttle every segment body to this KiB/s (0 = unthrottled)
tail_slow: int = 0 # throttle the last K segments' bodies
tail_rate_kib: int = 256 # throttle rate for tail-slow segments (KiB/s)
flaky_first: bool = False # fail attempt 1 per path, succeed after
flaky_status: int = 503 # status returned by a flaky-first failure
PROFILES: dict[str, FaultProfile] = {
"loopback": FaultProfile("loopback"),
"stall": FaultProfile("stall", stall_pct=25.0, stall_secs=3.0),
"reset": FaultProfile("reset", reset_pct=25.0),
# short windows + a body throttle so a fast loopback batch actually spans several throttle
# windows and trips the 429 + Retry-After retry path (a real 900 MB/s batch would otherwise
# finish inside the first open window). Standalone use can widen the windows via overrides.
"rate-limit": FaultProfile(
"rate-limit", rate_limit=True, retry_after=1, window_open=1.0, window_closed=1.0, body_rate_kib=2048
),
"tail-slow": FaultProfile("tail-slow", tail_slow=4, tail_rate_kib=256),
"flaky-first": FaultProfile("flaky-first", flaky_first=True, flaky_status=503),
}
class FaultServer(ThreadingHTTPServer):
"""Threaded loopback server serving seeded payloads under a ``FaultProfile``.
Counters (total requests, per-status, per-path attempts) are the request-amplification
signal the bench reads: a retry/hedge storm against a throttled host shows up as
requests >> segment count. ``reset_counters`` re-baselines between benchmark runs.
"""
allow_reuse_address = True
daemon_threads = True
request_queue_size = 128 # default 5 drops SYNs past 5 concurrent connects -> phantom 1s retransmit tail
def __init__(
self,
profile: FaultProfile,
seg_size: int,
total_segments: int,
seed: int = DEFAULT_SEED,
host: str = "127.0.0.1",
port: int = 0,
) -> None:
self.profile = profile
self.seg_size = seg_size
self.total_segments = total_segments
self.seed = seed
self.stall_event = threading.Event() # set at teardown to release stalled handlers
self._lock = threading.Lock()
self._request_count = 0
self._status_counts: dict[int, int] = {}
self._attempts: dict[str, int] = {}
self._window_origin = time.monotonic()
# precompute the known segment bodies so serving is a dict lookup (no per-request
# generation), making the loopback profile a true python-overhead ceiling
self._payloads: dict[str, bytes] = {
f"/seg/{i}": segment_payload(f"/seg/{i}", seg_size) for i in range(total_segments)
}
super().__init__((host, port), FaultHandler)
def handle_error(self, request: Any, client_address: Any) -> None: # silence expected fault noise
pass
def payload(self, path: str) -> bytes:
cached = self._payloads.get(path)
if cached is not None:
return cached
with self._lock: # unknown path (standalone use): generate + cache once
body = self._payloads.get(path)
if body is None:
body = segment_payload(path, self.seg_size)
self._payloads[path] = body
return body
def note_request(self) -> None:
with self._lock:
self._request_count += 1
def note_status(self, code: int) -> None:
with self._lock:
self._status_counts[code] = self._status_counts.get(code, 0) + 1
def note_attempt(self, path: str) -> int:
with self._lock:
n = self._attempts.get(path, 0) + 1
self._attempts[path] = n
return n
def in_throttle_window(self) -> bool:
p = self.profile
period = p.window_open + p.window_closed
if period <= 0:
return False
phase = (time.monotonic() - self._window_origin) % period
return phase >= p.window_open
def decides(self, kind: str, path: str, attempt: int, pct: float) -> bool:
"""Deterministic per-(path, attempt) fault gate: fires for ``pct`` percent of them."""
if pct <= 0:
return False
digest = hashlib.sha256(f"{self.seed}:{kind}:{path}:{attempt}".encode("utf-8")).digest()
return (int.from_bytes(digest[:4], "big") / 0xFFFFFFFF) * 100.0 < pct
def reset_counters(self) -> None:
with self._lock:
self._request_count = 0
self._status_counts.clear()
self._attempts.clear()
self._window_origin = time.monotonic() # realign throttle windows to this run's start
def stats(self) -> dict[str, Any]:
with self._lock:
max_attempts = max(self._attempts.values(), default=0)
return {
"requests": self._request_count,
"status_counts": dict(sorted(self._status_counts.items())),
"max_attempts": max_attempts,
}
class FaultHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_: Any) -> None: # silence per-request logging
pass
def _parse_range(self, total: int) -> Optional[tuple[int, int]]:
header = self.headers.get("Range")
if not header or not header.startswith("bytes="):
return None
start_s, _, end_s = header[len("bytes=") :].partition("-")
if not start_s: # RFC 7233 suffix range: bytes=-N means the last N bytes
return max(0, total - int(end_s)), total - 1
start = int(start_s)
end = int(end_s) if end_s else total - 1
if start > end: # inverted range: report unsatisfiable so _serve answers 416
return total, total - 1
return start, min(end, total - 1)
def do_GET(self) -> None:
server: FaultServer = self.server # type: ignore[assignment]
server.note_request()
if not self.path.startswith("/seg/"):
self.send_error(404)
return
try:
idx = int(self.path.rsplit("/", 1)[-1])
except ValueError:
self.send_error(404)
return
attempt = server.note_attempt(self.path)
prof = server.profile
if prof.flaky_first and attempt == 1: # first attempt per path always fails, retry recovers
self._send_status(prof.flaky_status)
return
if prof.rate_limit and server.in_throttle_window(): # recurring throttle window
self._send_429(prof.retry_after)
return
rate = prof.body_rate_kib
if prof.tail_slow and idx >= server.total_segments - prof.tail_slow:
rate = prof.tail_rate_kib
stall = server.decides("stall", self.path, attempt, prof.stall_pct)
reset = server.decides("reset", self.path, attempt, prof.reset_pct)
self._serve(server.payload(self.path), stall=stall, reset=reset, rate_kib=rate)
def _send_status(self, code: int) -> None:
server: FaultServer = self.server # type: ignore[assignment]
server.note_status(code)
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
def _send_429(self, retry_after: int) -> None:
server: FaultServer = self.server # type: ignore[assignment]
server.note_status(429)
self.send_response(429)
self.send_header("Retry-After", str(retry_after))
self.send_header("Content-Length", "0")
self.end_headers()
def _serve(self, full: bytes, stall: bool = False, reset: bool = False, rate_kib: int = 0) -> None:
server: FaultServer = self.server # type: ignore[assignment]
rng = self._parse_range(len(full))
if rng is not None and rng[0] >= len(full): # unsatisfiable resume: a real CDN answers 416
server.note_status(416)
self.send_response(416)
self.send_header("Content-Range", f"bytes */{len(full)}")
self.send_header("Content-Length", "0")
self.end_headers()
return
if rng is not None:
start, end = rng
body = full[start : end + 1]
server.note_status(206)
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(full)}")
else:
body = full
server.note_status(200)
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Type", "application/octet-stream")
self.end_headers()
try:
if reset: # send half, then abort the TCP connection (RST via SO_LINGER=0)
half = max(1, len(body) // 2)
self.wfile.write(body[:half])
self.wfile.flush()
sock = self.connection
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
sock.close()
self.close_connection = True
elif stall: # send half, hold (a client READ_TIMEOUT shorter than stall_secs will retry), then finish
half = max(1, len(body) // 2)
self.wfile.write(body[:half])
self.wfile.flush()
server.stall_event.wait(server.profile.stall_secs)
self.wfile.write(body[half:])
self.wfile.flush()
else:
self._write_body(body, rate_kib)
except (BrokenPipeError, ConnectionError, OSError):
self.close_connection = True
def _write_body(self, body: bytes, rate_kib: int) -> None:
if rate_kib <= 0:
self.wfile.write(body)
return
chunk = 64 * 1024 # throttle: fixed-size chunks paced to rate_kib KiB/s
per_chunk = chunk / (rate_kib * 1024)
for off in range(0, len(body), chunk):
self.wfile.write(body[off : off + chunk])
self.wfile.flush()
time.sleep(per_chunk)
def resolve_profile(name: str, overrides: Optional[dict[str, Any]] = None) -> FaultProfile:
"""Return a copy of the named preset with any non-None ``overrides`` applied."""
if name not in PROFILES:
raise KeyError(f"unknown profile {name!r}; choices: {', '.join(sorted(PROFILES))}")
base = asdict(PROFILES[name])
for key, value in (overrides or {}).items():
if value is not None and key in base:
base[key] = value
return FaultProfile(**base)
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--port", type=int, default=0, help="Listen port (0 = auto).")
p.add_argument("--profile", choices=sorted(PROFILES), default="loopback", help="Fault profile to apply.")
p.add_argument("--segments", type=int, default=32, help="Number of /seg/i payloads to precompute.")
p.add_argument("--seg-size", type=int, default=2 * MIB, help="Bytes per segment payload.")
p.add_argument("--seed", type=int, default=DEFAULT_SEED, help="Fault RNG seed (printed for reproducibility).")
# profile knobs (override the chosen preset when given)
p.add_argument("--stall-pct", type=float, help="Override: %% of (path, attempt)s that stall.")
p.add_argument("--stall-secs", type=float, help="Override: seconds a stall holds.")
p.add_argument("--reset-pct", type=float, help="Override: %% of (path, attempt)s that RST.")
p.add_argument("--retry-after", type=int, help="Override: Retry-After seconds on a 429.")
p.add_argument("--window-open", type=float, help="Override: seconds serving before a throttle window.")
p.add_argument("--window-closed", type=float, help="Override: seconds replying 429 during a window.")
p.add_argument("--body-rate-kib", type=int, help="Override: throttle every body to this KiB/s (0 = off).")
p.add_argument("--tail-slow", type=int, help="Override: throttle the last K segments.")
p.add_argument("--tail-rate-kib", type=int, help="Override: tail-slow throttle rate (KiB/s).")
return p
def main() -> None:
args = _build_arg_parser().parse_args()
overrides = {
"stall_pct": args.stall_pct,
"stall_secs": args.stall_secs,
"reset_pct": args.reset_pct,
"retry_after": args.retry_after,
"window_open": args.window_open,
"window_closed": args.window_closed,
"body_rate_kib": args.body_rate_kib,
"tail_slow": args.tail_slow,
"tail_rate_kib": args.tail_rate_kib,
}
profile = resolve_profile(args.profile, overrides)
server = FaultServer(profile, args.seg_size, args.segments, args.seed, port=args.port)
host, port = server.server_address[0], server.server_address[1]
config = {
"listen": f"http://{host}:{port}",
"seed": args.seed,
"segments": args.segments,
"seg_size": args.seg_size,
"base_url": f"http://{host}:{port}/seg/{{i}}",
"profile": asdict(profile),
}
print(json.dumps(config, indent=2), flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.stall_event.set()
server.shutdown()
if __name__ == "__main__":
main()
+26 -3
View File
@@ -11,9 +11,16 @@ from pathlib import Path
import pytest
from unshackle.core.downloaders.requests import (TAIL_BOOST_MIN_SEGMENT_SIZE, TAIL_BOOST_PART_SIZE,
AdaptiveWorkerController, _has_range_header, _plan_tail_parts,
_tail_boost_engages, requests)
from unshackle.core.downloaders.requests import (
TAIL_BOOST_MIN_SEGMENT_SIZE,
TAIL_BOOST_PART_SIZE,
AdaptiveWorkerController,
_has_range_header,
_plan_tail_parts,
_split_ranges,
_tail_boost_engages,
requests,
)
from unshackle.core.tracks.track import DownloadContext
pytestmark = pytest.mark.unit
@@ -202,6 +209,22 @@ def test_plan_tail_parts_skips_small_or_no_spare() -> None:
assert _plan_tail_parts(20 * 1024 * 1024, spare=0) == []
def test_split_ranges_covers_exactly_no_gaps_no_overlaps() -> None:
for size in (1, 100, 4 * 1024 * 1024, 16 * 1024 * 1024 + 7, 64 * 1024 * 1024):
parts = _split_ranges(size, max_parts=8, part_target=4 * 1024 * 1024)
assert parts[0][0] == 0 and parts[-1][1] == size - 1
for (_, prev_end), (start, _) in zip(parts, parts[1:]):
assert start == prev_end + 1
assert all(start <= end for start, end in parts)
assert sum(end - start + 1 for start, end in parts) == size
def test_split_ranges_respects_max_parts_and_target() -> None:
assert len(_split_ranges(100 * 1024 * 1024, max_parts=4, part_target=16 * 1024 * 1024)) == 4 # capped
assert len(_split_ranges(10 * 1024 * 1024, max_parts=8, part_target=16 * 1024 * 1024)) == 1 # below one target
assert _split_ranges(1, max_parts=8, part_target=16 * 1024 * 1024) == [(0, 0)]
def test_tail_boost_engages_on_idle_capacity() -> None:
# engages at the tail: idle workers outnumber the remaining whole segments
assert _tail_boost_engages(remaining=4, pending=2, target=8) # spare 6 >= 4
@@ -0,0 +1,180 @@
"""Integration coverage for the CONSUMING side of the DASH single-URL byte-range collapse.
The pure predicate ``DASH._collapsible_single_url`` is unit-tested in
``test_dash_collapse.py``; this file drives the real ``DASH.download_track`` code path that
acts on it. A minimal MPD is parsed with ``DASH.from_text().to_tracks()`` (so the track's
``data["dash"]`` is built by real parser code, not hand-faked) and downloaded against a
localhost server, asserting that:
- a SegmentList whose ranges collapse fetches the parent resource whole in one direct
(Range-less) request and writes a byte-correct file at ``save_path``, leaving no
segment dir or ``.!dev`` artifacts, and
- a track with mixed segment URLs still takes the per-segment download-and-merge path.
"""
from __future__ import annotations
import threading
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from unshackle.core.constants import DOWNLOAD_CANCELLED
from unshackle.core.manifests import DASH
from unshackle.core.tracks.track import DownloadContext
# distinctive parent resource for the collapse case; init is its [0, INIT_LEN) prefix
PARENT = bytes(i % 256 for i in range(10240))
INIT_LEN = 512
# separate resources for the non-collapse (mixed-URL) case
INIT2 = bytes(range(100))
PART0 = bytes([0xA0]) * 300
PART1 = bytes([0xB1]) * 200
class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
server = self.server
rng = self.headers.get("Range")
with server.lock:
server.requests.append((self.path, rng))
body = server.routes.get(self.path)
if body is None:
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
return
if rng:
start, end = (int(x) for x in rng.removeprefix("bytes=").split("-"))
chunk = body[start : end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(body)}")
body = chunk
else:
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def server():
srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
srv.lock = threading.Lock()
srv.requests = []
srv.routes = {}
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
@pytest.fixture(autouse=True)
def _clear_cancel():
was_set = DOWNLOAD_CANCELLED.is_set()
DOWNLOAD_CANCELLED.clear()
yield
if was_set:
DOWNLOAD_CANCELLED.set()
else:
DOWNLOAD_CANCELLED.clear()
def _base(srv) -> str:
return f"http://127.0.0.1:{srv.server_address[1]}/"
def _collapse_mpd(srv) -> str:
# SegmentList on one media.mp4: init is bytes [0, INIT_LEN), media ranges cover the rest
# contiguously -> _collapsible_single_url returns the URL and download_track collapses.
return f"""<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
<BaseURL>{_base(srv)}</BaseURL>
<Period id="0">
<AdaptationSet contentType="video" mimeType="video/mp4" lang="en">
<Representation id="v0" codecs="avc1.640028" bandwidth="1000000" width="1920" height="1080">
<BaseURL>media.mp4</BaseURL>
<SegmentList timescale="1">
<Initialization range="0-{INIT_LEN - 1}"/>
<SegmentURL mediaRange="{INIT_LEN}-5119"/>
<SegmentURL mediaRange="5120-{len(PARENT) - 1}"/>
</SegmentList>
</Representation>
</AdaptationSet>
</Period>
</MPD>"""
def _mixed_mpd(srv) -> str:
# Two SegmentURLs on distinct paths -> mixed URLs -> predicate False -> segmented path.
return f"""<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
<BaseURL>{_base(srv)}</BaseURL>
<Period id="0">
<AdaptationSet contentType="video" mimeType="video/mp4" lang="en">
<Representation id="v0" codecs="avc1.640028" bandwidth="1000000" width="1920" height="1080">
<SegmentList timescale="1">
<Initialization sourceURL="init.bin"/>
<SegmentURL media="part0.bin"/>
<SegmentURL media="part1.bin"/>
</SegmentList>
</Representation>
</AdaptationSet>
</Period>
</MPD>"""
def _track(mpd: str, srv):
return DASH.from_text(mpd, url=f"{_base(srv)}manifest.mpd").to_tracks(language="en").videos[0]
def _ctx(tmp_path, name="out.mp4"):
return DownloadContext(
save_path=tmp_path / name,
save_dir=tmp_path / "v0_segments",
progress=partial(lambda **kw: None),
)
def test_collapse_downloads_parent_whole_in_one_direct_request(server, tmp_path):
server.routes["/media.mp4"] = PARENT
track = _track(_collapse_mpd(server), server)
ctx = _ctx(tmp_path, "collapsed.mp4")
DASH.download_track(track, ctx)
# the whole parent lands at save_path, byte-for-byte
assert ctx.save_path.read_bytes() == PARENT
assert track.path == ctx.save_path
# exactly one Range-less GET (the direct whole-resource download); the only other hit is
# the ranged init probe done while parsing SegmentList, never a per-segment slice fetch.
no_range = [path for path, rng in server.requests if rng is None]
assert no_range == ["/media.mp4"]
# collapse skips the segment dir + merge entirely: no dir, no leftover control files
assert not ctx.save_dir.exists()
assert not list(tmp_path.glob("*.!dev"))
assert not list(tmp_path.glob("**/*.!dev"))
def test_non_collapse_takes_segmented_merge_path(server, tmp_path):
server.routes["/init.bin"] = INIT2
server.routes["/part0.bin"] = PART0
server.routes["/part1.bin"] = PART1
track = _track(_mixed_mpd(server), server)
ctx = _ctx(tmp_path, "merged.mp4")
DASH.download_track(track, ctx)
# merged output is init followed by both segments, in order
assert ctx.save_path.read_bytes() == INIT2 + PART0 + PART1
# both distinct segments were fetched -> genuinely the per-segment path, not a collapse
fetched = {path for path, _ in server.requests}
assert {"/part0.bin", "/part1.bin"} <= fetched
# segment files are consumed and the segment dir is cleaned up after merge
assert not list(ctx.save_dir.glob("*")) if ctx.save_dir.exists() else True
+145
View File
@@ -0,0 +1,145 @@
"""Cancel/abort hardening for the downloader.
Pins two teardown-safety invariants:
- ``_dispatch_parts`` must NOT finalize when the process-global ``DOWNLOAD_CANCELLED``
fires mid-download: part workers return silently keeping partials, so every future
completes without error, and finalizing would strip the ``.!dev`` control file and
pass off a hole-filled pre-truncated file as complete on the next run.
- A worker parked in the retry backoff wait (which can reach MAX_BACKOFF) must wake and
exit as soon as the batch abort is set, instead of sleeping out the full delay and
stalling shutdown or the merge.
"""
import importlib
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from requests import Session
dl = importlib.import_module("unshackle.core.downloaders.requests")
pytestmark = pytest.mark.unit
BODY = b"x" * 4096
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
server = self.server
if server.always_status:
self.send_response(server.always_status)
self.send_header("Content-Length", "0")
self.end_headers()
return
rng = self.headers.get("Range")
if rng:
start, end = (int(x) for x in rng.removeprefix("bytes=").split("-"))
body = BODY[start : end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BODY)}")
else:
body = BODY
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if server.gate is not None:
self.wfile.write(body[:64])
self.wfile.flush()
server.started.set()
server.gate.wait(timeout=10)
self.wfile.write(body[64:])
else:
self.wfile.write(body)
def log_message(self, *args): # keep the test output quiet
pass
class _Server(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address): # cancelled readers close mid-write
pass
@pytest.fixture
def server():
srv = _Server(("127.0.0.1", 0), _Handler)
srv.always_status = 0
srv.gate = None
srv.started = threading.Event()
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
if srv.gate is not None:
srv.gate.set()
srv.shutdown()
thread.join(timeout=5)
@pytest.fixture(autouse=True)
def _clear_cancel():
dl.DOWNLOAD_CANCELLED.clear()
yield
dl.DOWNLOAD_CANCELLED.clear()
def _url(server: _Server) -> str:
host, port = server.server_address
return f"http://{host}:{port}/seg.bin"
def test_dispatch_parts_does_not_finalize_on_global_cancel(server, tmp_path):
server.gate = threading.Event()
save_path = tmp_path / "big.bin"
def _cancel_when_started() -> None:
assert server.started.wait(timeout=10)
dl.DOWNLOAD_CANCELLED.set()
server.gate.set()
canceller = threading.Thread(target=_cancel_when_started)
canceller.start()
events = list(
dl._dispatch_parts(
url=_url(server),
save_path=save_path,
session=Session(),
total_size=len(BODY),
max_workers=2,
)
)
canceller.join(timeout=10)
assert not any("file_downloaded" in ev for ev in events)
# the control file must survive so the next run restarts instead of trusting the file
assert save_path.with_name(f"{save_path.name}.!dev").exists()
def test_backoff_wait_exits_promptly_on_batch_abort(server, tmp_path, monkeypatch):
server.always_status = 503
monkeypatch.setattr(dl, "_retry_sleep", lambda exc, attempts: 30.0)
abort = threading.Event()
def _consume() -> None:
for _ in dl.download(
url=_url(server),
save_path=tmp_path / "seg.bin",
session=Session(),
segmented=True,
abort=abort,
):
pass
worker = threading.Thread(target=_consume)
start = time.monotonic()
worker.start()
time.sleep(0.5) # let the first attempt fail and enter the backoff wait
abort.set()
worker.join(timeout=10)
assert not worker.is_alive()
# generous bound: far below the 30s nap, so the wait was interrupted, not slept out
assert time.monotonic() - start < 10
@@ -9,7 +9,10 @@ media requests must ask for identity encoding.
import importlib
import threading
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
@@ -26,6 +29,12 @@ class _Handler(BaseHTTPRequestHandler):
with server.lock:
server.requests.append(dict(self.headers))
nth = len(server.requests)
if nth <= server.fail_429_first:
self.send_response(429)
self.send_header("Retry-After", "0")
self.send_header("Content-Length", "0")
self.end_headers()
return
rng = self.headers.get("Range")
if server.honor_range and rng:
start, end = (int(x) for x in rng.removeprefix("bytes=").split("-"))
@@ -53,6 +62,7 @@ def server():
srv.requests = []
srv.honor_range = True
srv.truncate_first = False
srv.fail_429_first = 0
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
@@ -120,3 +130,64 @@ def test_range_item_retry_rewrites_slice(server, tmp_path, monkeypatch):
def test_segment_requests_ask_identity_encoding(server, tmp_path):
_run(server, tmp_path, [{"url": _url(server)}], max_workers=1)
assert all(r.get("Accept-Encoding") == "identity" for r in server.requests)
def _exc_with_headers(headers):
exc = Exception("boom")
exc.response = SimpleNamespace(headers=headers)
return exc
def test_retry_sleep_honors_numeric_retry_after():
assert dl._retry_sleep(_exc_with_headers({"Retry-After": "3"}), 1) == 3.0
assert dl._retry_sleep(_exc_with_headers({"Retry-After": "9999"}), 1) == 60.0 # session.MAX_BACKOFF cap
def test_retry_sleep_honors_http_date_retry_after():
when = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=10), usegmt=True)
assert 5 <= dl._retry_sleep(_exc_with_headers({"Retry-After": when}), 1) <= 10
def test_retry_sleep_exponential_backoff_with_jitter():
# no response on the failure: exponential from RETRY_WAIT with up to 10% jitter
for attempts, base in ((1, dl.RETRY_WAIT), (2, dl.RETRY_WAIT * 2), (3, dl.RETRY_WAIT * 4)):
assert base <= dl._retry_sleep(Exception("reset"), attempts) <= base * 1.1
assert dl._retry_sleep(Exception("reset"), 30) == 60.0 # capped
def test_retry_sleep_invalid_retry_after_falls_back():
v = dl._retry_sleep(_exc_with_headers({"Retry-After": "soon"}), 1)
assert dl.RETRY_WAIT <= v <= dl.RETRY_WAIT * 1.1
def test_retry_sleep_naive_http_date_treated_as_utc():
# "-0000" makes parsedate_to_datetime return a naive datetime; must clamp, not raise
when = format_datetime(datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=10))
assert when.endswith("-0000")
v = dl._retry_sleep(_exc_with_headers({"Retry-After": when}), 1)
assert 5 <= v <= 10
def test_retry_sleep_nan_retry_after_falls_back():
# float("nan") parses; a non-finite wait must fall back to the exponential path
v = dl._retry_sleep(_exc_with_headers({"Retry-After": "nan"}), 1)
assert dl.RETRY_WAIT <= v <= dl.RETRY_WAIT * 1.1
def test_retry_sleep_honors_retry_after_on_wrapped_cause():
# RnetSession raises MaxRetriesError whose __cause__ HTTPError carries the response
exc = Exception("max retries exceeded")
exc.__cause__ = _exc_with_headers({"Retry-After": "3"})
assert dl._retry_sleep(exc, 1) == 3.0
def test_transient_429_with_retry_after_does_not_abort_track(server, tmp_path, monkeypatch):
# a short rate-limit window must retry the segment, not kill the whole batch
dl.DOWNLOAD_CANCELLED.clear()
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
server.fail_429_first = 2
files = _run(server, tmp_path, [{"url": _url(server, f"/seg{i}.bin")} for i in range(3)], max_workers=1)
assert len(files) == 3
assert all(f.read_bytes() == PARENT for f in files)
assert not dl.DOWNLOAD_CANCELLED.is_set()
assert len(server.requests) == 5 # 3 successes + 2 rate-limited attempts
+156
View File
@@ -0,0 +1,156 @@
"""Multiprocess segment-download invariants.
Pins the spawned-child fan-out path in ``requests()``/``_download_multiprocess``:
- Strided assignment plus the ``index_offset``/``index_stride`` filename mapping puts every
segment's bytes in the correctly named file (a broken stride mapping misfiles content).
- The parent normalizes child progress to segment granularity: it drops child ``advance``
and ``total``/speed events and emits exactly one ``advance=1`` per ``file_downloaded``, so
the advances summed by a caller equal the segment count.
- A child that dies (its worker sends ``__mp_error__``) surfaces as a ``RuntimeError`` from
the generator without hanging.
- A process-global ``DOWNLOAD_CANCELLED`` set mid-batch terminates the spawned children (which
carry their own fresh flag and can't see it) and the teardown join reaps them promptly.
Children are spawned and re-import the module, so monkeypatched module attributes do NOT reach
them; these tests drive real child processes against a localhost server on 127.0.0.1 (reachable
from the children) and avoid any child-side patching. Segments are a few hundred bytes so the
real spawns stay fast.
"""
import importlib
import multiprocessing
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
dl = importlib.import_module("unshackle.core.downloaders.requests")
SEG_REPEAT = 32 # 8-digit index * 32 = 256-byte body, distinct per index
def _seg_body(index: int) -> bytes:
"""Segment body that encodes its own index, so misfiled striding is detectable."""
return (f"{index:08d}" * SEG_REPEAT).encode()
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
server = self.server
delay = getattr(server, "delay", 0.0)
if delay:
time.sleep(delay)
try:
index = int(self.path.rsplit("/", 1)[1].split(".")[0])
except ValueError:
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
return
body = _seg_body(index)
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # keep the test output quiet
pass
class _Server(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address): # a cancelled child closes its socket mid-write
pass
@pytest.fixture
def server():
srv = _Server(("127.0.0.1", 0), _Handler)
srv.delay = 0.0
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
@pytest.fixture(autouse=True)
def _clear_cancel():
# spawned children carry their own flag, but the parent loop reads this process's flag;
# keep it clean so cancel state never leaks between tests
dl.DOWNLOAD_CANCELLED.clear()
yield
dl.DOWNLOAD_CANCELLED.clear()
def _url(srv, path):
return f"http://127.0.0.1:{srv.server_address[1]}{path}"
def test_multiprocess_stride_downloads_all_segments_correctly(server, tmp_path):
# >= MP_MIN_SEGMENTS with processes=2 engages the fan-out via the public entry point
n = dl.MP_MIN_SEGMENTS
urls = [{"url": _url(server, f"/seg/{i}.bin")} for i in range(n)]
total_advance = 0
for event in dl.requests(urls, output_dir=tmp_path, filename="seg_{i:04}.bin", max_workers=4, processes=2):
total_advance += event.get("advance", 0)
files = sorted(tmp_path.glob("seg_*.bin"))
assert len(files) == n
for f in files:
index = int(f.stem.split("_")[1])
assert f.read_bytes() == _seg_body(index), f"segment {index} holds another index's bytes (stride mapping bug)"
# parent drops child advances and emits one advance=1 per file_downloaded
assert total_advance == n
def test_multiprocess_child_crash_raises(server, tmp_path):
# _download_multiprocess has no MP_MIN_SEGMENTS gate, so drive it directly with 2 urls.
# a dict url without "url" raises KeyError while requests() builds the save path (before
# any retry loop), so the child sends __mp_error__ fast; the sibling downloads normally.
good = {"url": _url(server, "/seg/0.bin")}
bad = {"nourl": True}
with pytest.raises(RuntimeError):
for _ in dl._download_multiprocess(
urls=[good, bad],
output_dir=tmp_path,
filename="seg_{i:04}.bin",
headers=None,
cookies=None,
proxy=None,
max_workers=2,
adaptive=False,
spec={"kind": "none"},
processes=2,
debug_logger=None,
):
pass
def test_multiprocess_cancel_terminates_children(server, tmp_path):
server.delay = 0.5 # slow segments so cancel lands well before the batch could finish
n = dl.MP_MIN_SEGMENTS
urls = [{"url": _url(server, f"/seg/{i}.bin")} for i in range(n)]
baseline = len(multiprocessing.active_children())
start = time.time()
progressed = 0
for event in dl.requests(urls, output_dir=tmp_path, filename="seg_{i:04}.bin", max_workers=4, processes=2):
if event.get("advance") or event.get("written"):
progressed += 1
if progressed >= 2:
dl.DOWNLOAD_CANCELLED.set() # sibling-track cancel: parent must tear the children down
elapsed = time.time() - start
# cancel stopped the work: nowhere near all segments finished, and no hang
assert len(list(tmp_path.glob("seg_*.bin"))) < n
assert elapsed < 15
# the finally-block join reaps the spawned children promptly
deadline = time.time() + 5
while time.time() < deadline and len(multiprocessing.active_children()) > baseline:
time.sleep(0.05)
assert len(multiprocessing.active_children()) <= baseline
+164
View File
@@ -0,0 +1,164 @@
"""Ranged-parallel single-URL download invariants.
Pins the byte-range fan-out that splits one large file across parts:
- a range-capable server above the size threshold is split into several byte
windows, the parts merge byte-identical, and the ``.!dev`` control marker is
removed on success;
- a ranged part that receives a 200 (server ignored the Range) fails after
retries rather than writing a wrong-length body into the pre-truncated file;
- that failure sets only the ranged download's LOCAL abort event, never the
process-global DOWNLOAD_CANCELLED (which would poison sibling tracks);
- ``_probe_ranged`` parses the Content-Range total on a 206 and declines a 200
or a content-encoded 206.
Tests 2/3 exercise ``_dispatch_parts`` directly: the public single-URL path in
``requests()`` deliberately swallows a ranged failure and falls back to a plain
sequential download, so the raise the invariant guarantees is only observable at
the dispatch layer.
"""
import importlib
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from requests import Session
dl = importlib.import_module("unshackle.core.downloaders.requests")
PAYLOAD = bytes(range(256)) * 128 # 32 KiB, distinctive repeating byte pattern
class _RangeHandler(BaseHTTPRequestHandler):
def do_GET(self):
srv = self.server
rng = self.headers.get("Range")
with srv.lock:
srv.requests.append(rng)
mode = srv.mode
if mode == "gzip_probe":
# a 206 that (wrongly) carries a content-encoding: the probe must decline it
self.send_response(206)
self.send_header("Content-Range", f"bytes 0-0/{len(PAYLOAD)}")
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Length", "1")
self.end_headers()
self.wfile.write(b"\x00")
return
# always_200 ignores Range entirely; probe_only_206 answers the 0-0 probe
# with a real 206 but hands every wider part window a 200 full body
serve_full = mode == "always_200" or not rng or (mode == "probe_only_206" and rng != "bytes=0-0")
if serve_full:
self.send_response(200)
self.send_header("Content-Length", str(len(PAYLOAD)))
self.end_headers()
self.wfile.write(PAYLOAD)
return
start, end = (int(x) for x in rng.removeprefix("bytes=").split("-"))
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)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture(autouse=True)
def _clear_cancelled():
# ranged failures must never touch the process-global flag; isolate every test from it
dl.DOWNLOAD_CANCELLED.clear()
yield
dl.DOWNLOAD_CANCELLED.clear()
@pytest.fixture
def server():
srv = ThreadingHTTPServer(("127.0.0.1", 0), _RangeHandler)
srv.lock = threading.Lock()
srv.requests = []
srv.mode = "range" # honor every Range with a proper 206
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
def _url(srv, path="/payload.bin"):
return f"http://127.0.0.1:{srv.server_address[1]}{path}"
def _run(srv, tmp_path, urls, **kwargs):
for _ in dl.requests(urls, output_dir=tmp_path, filename="seg_{i:04}.bin", **kwargs):
pass
return sorted(tmp_path.glob("seg_*.bin"))
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)
files = _run(server, tmp_path, [{"url": _url(server)}], max_workers=4)
assert len(files) == 1
assert files[0].read_bytes() == PAYLOAD
# the split must actually have happened: >1 distinct window beyond the 0-0 probe
windows = {r for r in server.requests if r and r != "bytes=0-0"}
assert len(windows) >= 2
# control marker removed once the parts finalize
assert not files[0].with_name(files[0].name + ".!dev").exists()
def test_ranged_part_requires_206(server, tmp_path, monkeypatch):
server.mode = "probe_only_206"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
save_path = tmp_path / "seg_0000.bin"
gen = dl._dispatch_parts(
url=_url(server),
save_path=save_path,
session=Session(),
total_size=len(PAYLOAD),
max_workers=4,
)
with pytest.raises(IOError, match="expected 206 for ranged part"):
for _ in gen:
pass
# not finalized: the control marker survives (completed=False) and the target
# is still the pre-truncated zero-fill, never the served bytes
assert save_path.with_name(save_path.name + ".!dev").exists()
assert save_path.read_bytes() != PAYLOAD
def test_ranged_part_failure_stays_local(server, tmp_path, monkeypatch):
server.mode = "probe_only_206"
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
dl.DOWNLOAD_CANCELLED.clear()
save_path = tmp_path / "seg_0000.bin"
gen = dl._dispatch_parts(
url=_url(server),
save_path=save_path,
session=Session(),
total_size=len(PAYLOAD),
max_workers=4,
)
with pytest.raises(IOError, match="expected 206 for ranged part"):
for _ in gen:
pass
# a failed part rides the local abort event; the global cancel must stay clear
assert not dl.DOWNLOAD_CANCELLED.is_set()
def test_probe_ranged_parses_content_range(server):
session = Session()
server.mode = "range"
assert dl._probe_ranged(_url(server), session) == (len(PAYLOAD), True)
server.mode = "always_200"
assert dl._probe_ranged(_url(server), session) == (0, False)
server.mode = "gzip_probe"
assert dl._probe_ranged(_url(server), session) == (0, False)
+175
View File
@@ -0,0 +1,175 @@
"""Tail-boost success-path invariants.
Pins the tail-boost engage path in ``requests()``: when only a few segments remain
and workers would otherwise idle, each remaining segment is probed for range support
and split into intra-segment byte-range parts written into a pre-truncated ``.tp.!dev``
target, finalized by the last part worker via os.replace. The invariants:
- a boosted segment's final file is byte-identical to the served content, every
segment reports exactly one advance=1 (part-mode byte advances are swallowed), and
a clean run leaves no ``.!dev`` / ``.tp.!dev`` strays;
- 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).
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
``remaining`` (the tail top-up only releases boost-declined indices). The controller
starts at the cap and never grows, so as soon as two leaders finish the spare workers
outnumber the tail and ``_tail_boost_engages`` fires. Module timing/size constants are
lowered via monkeypatch; the whole batch runs in-process (threads), so the patched
values are seen by the workers.
"""
import importlib
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
dl = importlib.import_module("unshackle.core.downloaders.requests")
BODY = bytes((i * 7 + 11) % 256 for i in range(16384)) # 16 KiB, distinctive, range-sliceable
# lowered so a 16 KiB segment clears the min and splits into several 4 KiB parts
BOOST_MIN = 4096
BOOST_PART = 4096
MAX_WORKERS = 6
SEG_COUNT = 8 # > MAX_WORKERS and <= 2*MAX_WORKERS: leaders 0-5 submit, tail 6,7 boost
class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
server = self.server
rng = self.headers.get("Range")
with server.lock:
server.requests.append((self.path, rng))
if rng and rng.startswith("bytes="):
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.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
self.send_response(200)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
return
body = BODY[start : end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(BODY)}")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_response(200)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, *args):
pass
@pytest.fixture
def server():
srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
srv.lock = threading.Lock()
srv.requests = []
srv.break_offset_ranges = False
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
yield srv
srv.shutdown()
thread.join(timeout=5)
@pytest.fixture(autouse=True)
def _clean_cancel():
# a failing batch sets the process-global cancel; keep tests independent
dl.DOWNLOAD_CANCELLED.clear()
yield
dl.DOWNLOAD_CANCELLED.clear()
@pytest.fixture(autouse=True)
def _boost_constants(monkeypatch):
monkeypatch.setattr(dl, "TAIL_BOOST_MIN_SEGMENT_SIZE", BOOST_MIN)
monkeypatch.setattr(dl, "TAIL_BOOST_PART_SIZE", BOOST_PART)
def _url(srv, path):
return f"http://127.0.0.1:{srv.server_address[1]}{path}"
def _urls(srv):
return [{"url": _url(srv, f"/seg{i}.bin")} for i in range(SEG_COUNT)]
def _run(srv, tmp_path, urls):
advances = 0
for ev in dl.requests(urls, output_dir=tmp_path, filename="seg_{i:04}.bin", max_workers=MAX_WORKERS, adaptive=True):
a = ev.get("advance")
if a:
advances += a
return sorted(tmp_path.glob("seg_*.bin")), advances
def _ranged_counts(srv):
with srv.lock:
reqs = list(srv.requests)
counts: dict[str, int] = {}
for path, rng in reqs:
if rng:
counts[path] = counts.get(path, 0) + 1
return counts
def _boost_engaged(srv):
# a boosted path sees a 0-0 probe plus >=2 part windows; a normal segment sees no Range
return any(n > 1 for n in _ranged_counts(srv).values())
def test_tail_boost_split_produces_byte_identical_segment(server, tmp_path):
files, advances = _run(server, tmp_path, _urls(server))
assert _boost_engaged(server), "tail boost never engaged; test would silently cover the normal path"
assert len(files) == SEG_COUNT
assert all(f.read_bytes() == BODY for f in files)
# part-mode byte advances are swallowed; each segment reports exactly one advance=1
assert advances == SEG_COUNT
# a clean run renames every target into place, so no partial/part strays survive
assert not list(tmp_path.glob("*.!dev"))
assert not list(tmp_path.glob("*.tp.!dev"))
def test_tail_boost_finalize_last_part_wins(server, tmp_path):
files, _ = _run(server, tmp_path, _urls(server))
assert _boost_engaged(server), "tail boost never engaged; finalize path untested"
assert len(files) == SEG_COUNT
# last part wins: the pre-truncated .tp.!dev is os.replace'd into save_path, leaving no target
assert not list(tmp_path.glob("*.tp.!dev"))
for path in _ranged_counts(server):
i = int(path.removeprefix("/seg").removesuffix(".bin"))
save_path = tmp_path / f"seg_{i:04}.bin"
assert save_path.exists()
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
monkeypatch.setattr(dl, "RETRY_WAIT", 0.01)
with pytest.raises(Exception):
_run(server, tmp_path, _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.
for i in (6, 7):
assert not (tmp_path / f"seg_{i:04}.bin").exists()
for f in tmp_path.glob("seg_*.bin"):
assert f.read_bytes() == BODY
+82 -14
View File
@@ -1,6 +1,7 @@
import math
import multiprocessing
import os
import random
import socket
import statistics
import sys
@@ -10,6 +11,8 @@ import traceback
from collections import deque
from concurrent.futures import FIRST_COMPLETED, wait
from concurrent.futures.thread import ThreadPoolExecutor
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from http.cookiejar import CookieJar
from pathlib import Path
from queue import Empty, Queue
@@ -34,9 +37,6 @@ READ_TIMEOUT = 30
HEDGE_FACTOR = 3
HEDGE_MIN_WAIT = 5.0
# racers read per network arrival (read1) so superseded hedge losers exit promptly
RACER_READ1 = True
# Adaptive chunk sizing — benchmarked optimal range
MIN_CHUNK = 524_288 # 512KB
MAX_CHUNK = 4_194_304 # 4MB
@@ -207,6 +207,43 @@ class AdaptiveWorkerController:
return self.target
def _retry_sleep(exc: Exception, attempts: int) -> float:
"""Seconds to wait before retry number ``attempts``.
download() owns segment retries (session-level retries are suppressed), so backoff
lives here: honor the server's Retry-After when the failure carries a response,
else exponential growth from RETRY_WAIT with jitter so concurrently rate-limited
segments don't retry in lockstep. Capped at session.MAX_BACKOFF either way.
"""
from unshackle.core.session import MAX_BACKOFF
response = getattr(exc, "response", None)
if response is None:
# RnetSession wraps forcelist failures in MaxRetriesError with the HTTPError as __cause__
response = getattr(getattr(exc, "__cause__", None), "response", None)
if response is not None:
try:
retry_after = response.headers.get("Retry-After")
except Exception:
retry_after = None
if retry_after:
wait: Optional[float] = None
try:
wait = float(retry_after)
except ValueError:
try:
retry_date = parsedate_to_datetime(retry_after)
if retry_date.tzinfo is None:
retry_date = retry_date.replace(tzinfo=timezone.utc)
wait = (retry_date - datetime.now(timezone.utc)).total_seconds()
except Exception:
wait = None
if wait is not None and math.isfinite(wait):
return min(max(wait, 0.0), MAX_BACKOFF)
backoff = RETRY_WAIT * (2 ** (attempts - 1))
return min(backoff + random.uniform(0, backoff * 0.1), MAX_BACKOFF)
def _is_requests_session(session: Any) -> bool:
"""Check if the session is a standard requests.Session (supports resp.raw)."""
return isinstance(session, Session)
@@ -396,6 +433,11 @@ def _dispatch_parts(
total_bytes += advance
yield {"advance": advance}
if DOWNLOAD_CANCELLED.is_set() or abort.is_set():
# a cross-track cancel makes part workers return silently with partials, so
# every future completes without error; finalizing here would strip the .!dev
# control file and pass off a hole-filled pre-truncated file as complete
return
yield {"file_downloaded": save_path, "written": total_size}
completed = True
except KeyboardInterrupt:
@@ -450,12 +492,11 @@ def download(
part_end: Inclusive end byte of the part. Required when `part_offset` is set.
claimed: Optional predicate checked before every attempt; when it returns True
the download stops silently (another worker already delivered this file).
racing: Optional predicate selecting read granularity per iteration. When None,
read1 (per-network-arrival reads) is used whenever `claimed` is set and
RACER_READ1 is on. When provided, read1 is used only
on iterations where it returns True (this segment is being hedge-raced, so a
superseded loser must notice `claimed()` mid-stream); otherwise a blocking
full-chunk read is used, avoiding the per-record read tax on non-racing segments.
racing: Optional predicate selecting read granularity per iteration. Requires
`claimed`. read1 (per-network-arrival reads) is used only on iterations where
it returns True (this segment is being hedge-raced, so a superseded loser must
notice `claimed()` mid-stream); otherwise a blocking full-chunk read is used,
avoiding the per-record read tax on non-racing segments.
abort: Optional local cancel signal. When set, the download stops like a cancel
(keeps its partial, returns silently, does not raise). Used by ranged-parallel
downloads to abort sibling parts without touching the process-global cancel.
@@ -504,6 +545,10 @@ def download(
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
last_speed_refresh = _time()
@@ -598,14 +643,12 @@ def download(
if use_rnet:
chunks = stream.stream()
elif use_raw:
_read1 = getattr(stream.raw, "read1", None) if claimed is not None and RACER_READ1 else None
_read1 = getattr(stream.raw, "read1", None) if claimed is not None else None
if _read1 is not None and racing is not None:
# only pay the per-arrival read1 tax while this segment is actually
# racing; a blocking full-chunk read otherwise (the common case)
_raw_read = stream.raw.read
chunks = iter(lambda: _read1(chunk_size) if racing() else _raw_read(chunk_size), b"")
elif _read1 is not None:
chunks = iter(lambda: _read1(chunk_size), b"")
else:
chunks = iter(lambda: stream.raw.read(chunk_size), b"")
else:
@@ -701,7 +744,15 @@ def download(
pass
if not part_mode:
resume_offset = tmp_file.stat().st_size if tmp_file.exists() else 0
time.sleep(RETRY_WAIT)
delay = _retry_sleep(exc, attempts)
if abort is not None:
# interruptible nap: backoff can reach MAX_BACKOFF, and teardown always
# sets the batch abort, so a parked worker must wake and exit at once
# instead of stalling shutdown (or the merge) for the full delay
if abort.wait(delay):
return
else:
time.sleep(delay)
attempts += 1
@@ -774,7 +825,10 @@ def _mp_worker(queue: Any, kwargs: dict[str, Any]) -> None:
if overrides:
for pair in overrides.split(","):
name, _, value = pair.partition("=")
setattr(sys.modules[__name__], name, float(value))
# only the timing constants the bench relays; a stale/foreign env var
# must not be able to rewrite arbitrary module globals in every child
if name in ("READ_TIMEOUT", "RETRY_WAIT", "HEDGE_MIN_WAIT"):
setattr(sys.modules[__name__], name, float(value))
spec = kwargs.pop("_session_spec")
kwargs["session"] = _rebuild_session(spec)
for event in requests(**kwargs):
@@ -866,6 +920,13 @@ def _download_multiprocess(
dead_ticks = 0
try:
while done_count < len(procs):
if DOWNLOAD_CANCELLED.is_set():
# cross-track cancel (e.g. a sibling track failed): spawned children carry
# their own fresh flag and can't see it, so terminate them like KeyboardInterrupt
for p in procs:
p.terminate()
yield dict(downloaded="[yellow]CANCELLED")
return
try:
event = queue.get(timeout=0.1)
except Empty:
@@ -893,9 +954,16 @@ def _download_multiprocess(
continue # parent owns the aggregate total
if "downloaded" in event:
continue # child speed/status string; parent computes its own aggregate speed
if "advance" in event:
# children report mixed granularity (segment counts, or raw bytes when a
# length-1 strided chunk routes through the single-file path); the parent
# bar counts segments, so advance=1 is emitted per file_downloaded instead
continue
written = event.get("written")
if written:
total_bytes += written
if "file_downloaded" in event:
yield dict(advance=1)
yield event
now = time.time()
+1 -1
View File
@@ -701,7 +701,7 @@ class RnetSession:
if response.status_code not in self.status_forcelist:
return response
last_exception = HTTPError(f"Received status code: {response.status_code}")
last_exception = HTTPError(f"Received status code: {response.status_code}", response=response)
self.log.warning(
f"{response.status_code} {response.reason}({urlparse(url).path}). Retrying... "
f"({attempt + 1}/{max_retries})"