Initial commit: multi-peer torrent download engine

Reactor/loop-pool engine with TCP/µTP/MSE transports, per-connection
pipelining, priority-driven piece selection with endgame, and the Python
FFI test harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-21 23:12:32 -04:00
commit d8208685a2
55 changed files with 9989 additions and 0 deletions

543
harness/swarm_download.py Normal file
View file

@ -0,0 +1,543 @@
"""
swarm_download.py - Download a real torrent from a real swarm using the engine,
to measure how it performs against actual peers.
Division of labour: for .torrent files, this harness parses metainfo locally and
uses the sibling torrent-tracker library for DHT get_peers and HTTP/UDP tracker
announces. Magnet metadata resolution still falls back to libtorrent. Every
discovered peer endpoint is fed to the engine, which does all the data transfer.
Pieces are reassembled and SHA-1-verified here; the engine never hashes or
persists anything.
Reality check (important for interpreting the numbers): a real public swarm
contains unreachable peers, peers behind NAT, peers that only accept a different
transport/encryption combination, and peers that do not actually have useful
pieces. Those show up as "failed". The headline metric this prints is therefore
how many discovered peers were actually usable, and the sustained rate across
them. That is the honest "how well does it work today" answer.
Usage
-----
python harness/swarm_download.py path/to/file.torrent
python harness/swarm_download.py 'magnet:?xt=urn:btih:...'
python harness/swarm_download.py file.torrent --max-peers 200 --output /tmp/out --timeout 600
python harness/swarm_download.py file.torrent --output /tmp/out --resume
Pick a well-seeded torrent (e.g. a current Linux distro ISO) for a meaningful
test; obscure or dead torrents will show few usable peers regardless.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import secrets
import sys
import tempfile
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "harness"))
import libtorrent as lt # noqa: E402 # only used for magnet metadata fallback
from harness import load_metadata # noqa: E402
from engine_ffi import (BLOCK_SIZE, Engine, EngineConfig, STATE_NAMES, # noqa: E402
ERROR_NAMES)
from torrent_meta import load_torrent # noqa: E402
from tracker_ffi import DHTClient, TrackerClient # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
def _discovery_session() -> lt.session:
s = lt.session({
"listen_interfaces": "0.0.0.0:0,[::]:0",
"enable_dht": True, "enable_lsd": True,
"enable_upnp": True, "enable_natpmp": True,
"alert_mask": 0,
})
for host, port in (("router.bittorrent.com", 6881),
("dht.transmissionbt.com", 6881),
("router.utorrent.com", 6881)):
try:
s.add_dht_node((host, port))
except Exception:
pass
return s
class LibtorrentDiscovery:
def __init__(self, handle, session):
self.handle = handle
self.session = session
def collect(self, _max_peers: int) -> set[tuple[str, int]]:
eps = set()
try:
for pi in self.handle.get_peer_info():
ip = pi.ip
if isinstance(ip, tuple) and len(ip) == 2 and ip[1]:
eps.add((ip[0], int(ip[1])))
except Exception:
pass
return eps
def close(self):
try:
self.session.remove_torrent(self.handle)
except Exception:
pass
class TrackerDiscovery:
def __init__(self, trackers: list[str], meta, *, timeout: float,
max_trackers: int, use_dht: bool, dht_timeout: float,
dht_queries: int):
self.trackers = trackers[:max_trackers] if max_trackers > 0 else trackers
self.meta = meta
self.timeout = timeout
self.client = TrackerClient()
self.dht = DHTClient() if use_dht else None
self.dht_timeout = dht_timeout
self.dht_queries = dht_queries
self.dht_done = False
self.peer_id = b"-PC0001-" + secrets.token_bytes(12)
self.key = secrets.randbits(32)
self.endpoints: set[tuple[str, int]] = set()
self.index = 0
self.next_cycle_at = 0.0
def collect(self, max_peers: int) -> set[tuple[str, int]]:
now = time.time()
if len(self.endpoints) >= max_peers:
return set(self.endpoints)
if self.dht and not self.dht_done:
self.dht_done = True
result = self.dht.lookup(
self.meta.info_hash,
timeout=self.dht_timeout,
max_queries=self.dht_queries,
max_peers=max_peers - len(self.endpoints),
)
self.endpoints.update(result.peers)
msg = (f"dht: {len(result.peers)} peers, "
f"{result.nodes_queried} queried/{result.nodes_discovered} learned "
f"({result.elapsed_ms:.0f} ms)")
if result.error:
msg += f": {result.error}"
print(msg, flush=True)
if len(self.endpoints) >= max_peers:
return set(self.endpoints)
if self.index >= len(self.trackers):
if now < self.next_cycle_at:
return set(self.endpoints)
self.index = 0
if not self.trackers:
return set()
url = self.trackers[self.index]
self.index += 1
if self.index >= len(self.trackers):
self.next_cycle_at = now + 60.0
result = self.client.announce(
url, self.meta, self.peer_id, port=6881, key=self.key,
numwant=max(1, min(200, max_peers - len(self.endpoints))),
event="started", timeout=self.timeout)
if result.ok:
self.endpoints.update(result.peers)
print(f"tracker: {url} returned {len(result.peers)} peers "
f"({result.elapsed_ms:.0f} ms)", flush=True)
else:
print(f"tracker: {url} failed: {result.error}", flush=True)
return set(self.endpoints)
def close(self):
pass
def _resolve_magnet(arg: str, scratch: str):
ses = _discovery_session()
params = lt.parse_magnet_uri(arg)
params.save_path = scratch
params.flags |= lt.torrent_flags.upload_mode
h = ses.add_torrent(params)
print("resolving magnet metadata from the swarm using libtorrent...", flush=True)
deadline = time.time() + 120
while time.time() < deadline and not h.status().has_metadata:
time.sleep(0.5)
if not h.status().has_metadata:
raise TimeoutError("could not fetch metadata for magnet within 120s")
tpath = os.path.join(scratch, "resolved.torrent")
with open(tpath, "wb") as f:
f.write(lt.bencode(lt.create_torrent(h.torrent_file()).generate()))
return tpath, LibtorrentDiscovery(h, ses)
def _completed_bytes(meta, done: bytearray) -> int:
return sum(meta.piece_len(i) for i, is_done in enumerate(done) if is_done)
def verify_existing_output(out_fh, meta) -> tuple[bytearray, int, int]:
"""Hash pieces already present in the output file.
Returns (done_bitfield, done_count, verified_bytes). Only pieces whose full
range exists and whose SHA-1 matches the torrent metadata are marked done.
This intentionally ignores partial pieces because the engine currently only
persists data after a whole piece has passed verification.
"""
done = bytearray(meta.num_pieces)
done_count = 0
file_size = os.fstat(out_fh.fileno()).st_size
for piece in range(meta.num_pieces):
piece_len = meta.piece_len(piece)
offset = piece * meta.piece_length
if offset + piece_len > file_size:
continue
out_fh.seek(offset)
data = out_fh.read(piece_len)
if len(data) != piece_len:
continue
if hashlib.sha1(data).digest() != meta.piece_hashes[piece]:
continue
done[piece] = 1
done_count += 1
return done, done_count, _completed_bytes(meta, done)
class PieceAssembler:
"""Reassemble pieces while ignoring duplicate blocks.
Endgame mode deliberately re-requests unfinished pieces from multiple peers.
That means the same block may arrive more than once. Counting raw bytes would
mark a piece complete too early, so completion is based on unique block
offsets within each piece.
"""
def __init__(self, meta, done: bytearray):
self.meta = meta
self.done = done
self.buffers: dict[int, bytearray] = {}
self.received = [0] * meta.num_pieces
self.seen: dict[int, bytearray] = {}
def _block_count(self, piece: int) -> int:
piece_len = self.meta.piece_len(piece)
return (piece_len + BLOCK_SIZE - 1) // BLOCK_SIZE
def reset_piece(self, piece: int) -> int:
previous = self.received[piece]
self.received[piece] = 0
self.buffers.pop(piece, None)
self.seen.pop(piece, None)
return previous
def add_block(self, piece: int, begin: int, data) -> tuple[bool, bool]:
if self.done[piece]:
return False, False
if begin % BLOCK_SIZE != 0:
raise ValueError(f"unaligned block for piece {piece}: begin={begin}")
piece_len = self.meta.piece_len(piece)
length = len(data)
if begin + length > piece_len:
raise ValueError(
f"block overruns piece {piece}: begin={begin} len={length} "
f"piece_len={piece_len}")
buf = self.buffers.get(piece)
if buf is None:
buf = bytearray(piece_len)
self.buffers[piece] = buf
seen = self.seen.get(piece)
if seen is None:
seen = bytearray(self._block_count(piece))
self.seen[piece] = seen
block = begin // BLOCK_SIZE
buf[begin:begin + length] = data
if seen[block]:
return False, self.received[piece] >= piece_len
seen[block] = 1
self.received[piece] += length
return True, self.received[piece] >= piece_len
def piece_bytes(self, piece: int) -> bytes:
return bytes(self.buffers[piece])
def finish_piece(self, piece: int) -> None:
self.done[piece] = 1
self.buffers.pop(piece, None)
self.seen.pop(piece, None)
class EndgameController:
"""Conservative piece-level endgame for slow tail pieces."""
def __init__(self, meta, *, min_pieces: int, peer_factor: float,
interval: float):
self.meta = meta
self.min_pieces = min_pieces
self.peer_factor = peer_factor
self.interval = interval
self.active = False
self.last_rearm = 0.0
def maybe_rearm(self, eng: Engine, tid: int, done: bytearray,
done_count: int, connected: int, now: float) -> None:
remaining = self.meta.num_pieces - done_count
if remaining <= 0:
return
threshold = max(self.min_pieces,
int(max(1, connected) * self.peer_factor))
if remaining > threshold:
return
if not self.active:
self.active = True
print(f"endgame: {remaining} pieces left; duplicating tail requests",
flush=True)
for piece, is_done in enumerate(done):
if not is_done:
eng.set_priority(tid, piece, 255)
self.last_rearm = 0.0
if now - self.last_rearm < self.interval:
return
for piece, is_done in enumerate(done):
if not is_done:
eng.request_piece(tid, piece)
self.last_rearm = now
def open_output(path: str, meta, resume: bool):
if resume:
existed = os.path.exists(path)
out_fh = open(path, "r+b" if existed else "w+b")
if existed:
print("resume: verifying existing output pieces...", flush=True)
done, done_count, verified_bytes = verify_existing_output(out_fh, meta)
pct = 100.0 * done_count / meta.num_pieces if meta.num_pieces else 100.0
print(f"resume: found {done_count}/{meta.num_pieces} verified pieces "
f"({pct:4.1f}%, {verified_bytes/1e6:.1f} MB)",
flush=True)
else:
done = bytearray(meta.num_pieces)
done_count = 0
print("resume: output file does not exist yet; starting fresh",
flush=True)
else:
out_fh = open(path, "w+b")
done = bytearray(meta.num_pieces)
done_count = 0
out_fh.truncate(meta.total_size)
return out_fh, done, done_count
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("torrent", help="path to a .torrent file or a magnet: URI")
ap.add_argument("--max-peers", type=int, default=100,
help="cap on peers fed to the engine (default 100)")
ap.add_argument("--output", "-o", help="write verified data here (else discard)")
ap.add_argument("--resume", action="store_true",
help="resume from an existing --output file by hashing "
"verified pieces and skipping them")
ap.add_argument("--timeout", type=float, default=600.0,
help="overall stall timeout in seconds")
ap.add_argument("--tracker-timeout", type=float, default=4.0,
help="per-tracker announce timeout for direct tracker "
"discovery (default 4)")
ap.add_argument("--max-trackers", type=int, default=16,
help="maximum trackers to announce to from a .torrent "
"(0 = all, default 16)")
ap.add_argument("--no-dht", action="store_true",
help="disable DHT get_peers discovery for .torrent files")
ap.add_argument("--dht-timeout", type=float, default=6.0,
help="total DHT lookup budget in seconds (default 6)")
ap.add_argument("--dht-queries", type=int, default=32,
help="maximum DHT nodes to query per torrent (default 32)")
ap.add_argument("--lib", default=LIB)
ap.add_argument("--loops", type=int, default=0, help="engine loops (0=auto)")
ap.add_argument("--encryption", type=int, default=1, choices=[0, 1, 2],
help="0=plaintext, 1=MSE offer RC4+plaintext (default), "
"2=MSE require RC4. Encryption reaches far more of a "
"real swarm.")
ap.add_argument("--utp", type=int, default=0, choices=[0, 1],
help="0=TCP (default), 1=µTP/UDP. A swarm has a mix; this "
"selects which transport the engine dials with.")
ap.add_argument("--no-endgame", action="store_true",
help="disable duplicate tail-piece requests")
ap.add_argument("--endgame-min-pieces", type=int, default=16,
help="enter endgame when remaining pieces are at or below "
"this count, also scaled by connected peers "
"(default 16)")
ap.add_argument("--endgame-peer-factor", type=float, default=2.0,
help="also enter endgame below peers*factor remaining "
"pieces (default 2.0)")
ap.add_argument("--endgame-interval", type=float, default=3.0,
help="seconds between tail-piece re-arms in endgame "
"(default 3.0)")
args = ap.parse_args()
if args.resume and not args.output:
ap.error("--resume requires --output")
scratch = tempfile.mkdtemp(prefix="swarm_dl_")
if args.torrent.startswith("magnet:"):
tpath, discovery = _resolve_magnet(args.torrent, scratch)
else:
torrent_file = load_torrent(args.torrent)
tpath = args.torrent
discovery = TrackerDiscovery(
torrent_file.trackers,
torrent_file.metadata,
timeout=args.tracker_timeout,
max_trackers=args.max_trackers,
use_dht=not args.no_dht,
dht_timeout=args.dht_timeout,
dht_queries=args.dht_queries,
)
meta = load_metadata(tpath)
print(f"torrent: {meta.name} {meta.total_size/1e6:.1f} MB "
f"{meta.num_pieces} pieces x {meta.piece_length}", flush=True)
out_fh = None
done = bytearray(meta.num_pieces)
done_count = 0
if args.output:
out_fh, done, done_count = open_output(args.output, meta, args.resume)
cfg = EngineConfig(loop_count=args.loops, slots_per_loop=4096,
max_pipeline=1024, encryption=args.encryption,
utp=args.utp)
print(f"transport: {'µTP' if args.utp else 'TCP'}, "
f"encryption={['off','offer','require'][args.encryption]}", flush=True)
eng = Engine(cfg, lib_path=args.lib)
tid = eng.add_torrent(meta.info_hash, b"-PC0001-" + secrets.token_bytes(12),
meta.piece_length, meta.total_size, meta.num_pieces)
eng.set_priorities(tid, [0 if done[i] else 1
for i in range(meta.num_pieces)])
assembler = PieceAssembler(meta, done)
endgame = None if args.no_endgame else EndgameController(
meta,
min_pieces=max(1, args.endgame_min_pieces),
peer_factor=max(1.0, args.endgame_peer_factor),
interval=max(0.5, args.endgame_interval),
)
added: set = set()
peak_connected = 0
useful_bytes = _completed_bytes(meta, done)
t0 = time.time()
deadline = t0 + args.timeout
last_print = 0.0
last_useful_bytes = useful_bytes
last_progress_t = t0
try:
while done_count < meta.num_pieces:
# Feed any newly-discovered peers to the engine, up to the cap.
if len(added) < args.max_peers:
for ip, port in discovery.collect(args.max_peers):
if (ip, port) in added:
continue
added.add((ip, port))
try:
eng.add_peer(tid, ip, port)
except Exception:
pass
if len(added) >= args.max_peers:
break
st = eng.status(tid)
peak_connected = max(peak_connected, st.peers_connected)
now = time.time()
if endgame:
endgame.maybe_rearm(
eng, tid, done, done_count, st.peers_connected, now)
descs = eng.poll_ready()
if not descs:
eng.wait(200)
now = time.time()
if useful_bytes != last_useful_bytes:
last_useful_bytes = useful_bytes
last_progress_t = now
if now - last_print >= 1.0:
last_print = now
pct = 100.0 * done_count / meta.num_pieces
print(f" {done_count}/{meta.num_pieces} pieces ({pct:4.1f}%) "
f"{st.rate_bps/1e6:6.1f} MB/s "
f"peers {st.peers_connected} up / {st.peers_failed} failed "
f"/ {len(added)} tried outstanding={st.outstanding}",
flush=True)
if now > deadline or (now - last_progress_t) > args.timeout:
print("stalled; giving up", flush=True)
break
continue
for x in descs:
if done[x.piece]:
# A block can arrive after the piece was completed and
# de-prioritized because it was already in flight. Drop it.
eng.release(x.loop, x.slot)
continue
block = bytes(eng.block_data(x.loop, x.slot, x.len))
eng.release(x.loop, x.slot)
added_unique, complete = assembler.add_block(
x.piece, x.begin, block)
if added_unique:
useful_bytes += x.len
if complete:
piece_data = assembler.piece_bytes(x.piece)
if hashlib.sha1(piece_data).digest() != meta.piece_hashes[x.piece]:
# Corrupt/garbage block from a misbehaving peer: re-arm.
useful_bytes -= assembler.reset_piece(x.piece)
eng.request_piece(tid, x.piece)
continue
done[x.piece] = 1
done_count += 1
eng.set_priority(tid, x.piece, 0)
if out_fh:
out_fh.seek(x.piece * meta.piece_length)
out_fh.write(piece_data)
out_fh.flush()
assembler.finish_piece(x.piece) # free as we go
dt = time.time() - t0
st = eng.status(tid)
mb = _completed_bytes(meta, done) / 1e6
print("-" * 70)
print(f"downloaded {done_count}/{meta.num_pieces} pieces "
f"({mb:.1f} MB) in {dt:.1f}s = {mb/dt:.1f} MB/s" if dt > 0 else "")
print(f"peers: {len(added)} discovered+tried, "
f"{peak_connected} usable at peak, "
f"{st.peers_failed} failed (likely incompatible transport/"
f"encryption or unreachable)")
if done_count < meta.num_pieces:
print(f"engine state: {STATE_NAMES[st.state]} "
f"err={ERROR_NAMES[st.error]}")
return 0 if done_count == meta.num_pieces else 2
finally:
if out_fh:
out_fh.close()
eng.close()
discovery.close()
if __name__ == "__main__":
raise SystemExit(main())