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

125
tests/test_encryption.py Normal file
View file

@ -0,0 +1,125 @@
"""
MSE / PE encryption tests against a libtorrent seed forced into encrypted-only
mode (in/out_enc_policy = forced, allowed_enc_level = rc4). A plaintext engine
cannot complete the handshake with such a seed, so a successful byte-for-byte
download proves the MSE transport works end to end.
* test_encrypted_download - engine with encryption=1 (offer RC4+plaintext)
* test_require_rc4 - engine with encryption=2 (RC4 only)
Run with: python tests/test_encryption.py
"""
from __future__ import annotations
import hashlib
import os
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
from harness import load_metadata # noqa: E402
from engine_ffi import Engine, EngineConfig, STATE_ERROR, ERROR_NAMES # noqa: E402
from test_localseed import ensure_built, make_torrent, pick_listen_port # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
def start_encrypted_seed(root: str, torrent_path: str):
"""A seed that REQUIRES MSE/RC4 encryption (refuses plaintext)."""
port = pick_listen_port()
ses = lt.session({
"listen_interfaces": f"127.0.0.1:{port}",
"enable_dht": False, "enable_lsd": False,
"enable_upnp": False, "enable_natpmp": False,
"in_enc_policy": int(lt.enc_policy.forced),
"out_enc_policy": int(lt.enc_policy.forced),
"allowed_enc_level": int(lt.enc_level.rc4),
"prefer_rc4": True,
})
h = ses.add_torrent({"ti": lt.torrent_info(torrent_path), "save_path": root,
"flags": lt.torrent_flags.seed_mode})
deadline = time.time() + 15
while time.time() < deadline and not h.status().is_seeding:
time.sleep(0.1)
assert h.status().is_seeding, "seed did not become ready"
return ses, h, ses.listen_port() or port
def _download(meta, port, encryption, timeout=60.0):
with Engine(EngineConfig(loop_count=1, slots_per_loop=1024,
max_pipeline=512, encryption=encryption),
lib_path=LIB) as eng:
tid = eng.add_torrent(meta.info_hash, b"-PC0001-" + os.urandom(12),
meta.piece_length, meta.total_size, meta.num_pieces)
eng.set_priorities(tid, [1] * meta.num_pieces)
eng.add_peer(tid, "127.0.0.1", port)
buffers = [bytearray(meta.piece_len(i)) for i in range(meta.num_pieces)]
received = [0] * meta.num_pieces
done = bytearray(meta.num_pieces)
done_count = 0
deadline = time.time() + timeout
while done_count < meta.num_pieces:
st = eng.status(tid)
if st.state == STATE_ERROR:
raise RuntimeError(f"engine error: {ERROR_NAMES[st.error]}")
descs = eng.poll_ready()
if not descs:
eng.wait(200)
if time.time() > deadline:
raise TimeoutError(f"stalled at {done_count}/{meta.num_pieces} "
f"(connected={st.peers_connected} "
f"failed={st.peers_failed})")
continue
for x in descs:
buf = buffers[x.piece]
buf[x.begin:x.begin + x.len] = eng.block_data(x.loop, x.slot, x.len)
eng.release(x.loop, x.slot)
received[x.piece] += x.len
if not done[x.piece] and received[x.piece] >= meta.piece_len(x.piece):
if hashlib.sha1(bytes(buf)).digest() != meta.piece_hashes[x.piece]:
raise ValueError(f"piece {x.piece} hash mismatch")
done[x.piece] = 1
done_count += 1
eng.set_priority(tid, x.piece, 0)
return b"".join(bytes(b) for b in buffers)
def test_encrypted_download():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_encrypted_seed(root, torrent)
try:
got = _download(meta, port, encryption=1)
finally:
ses.remove_torrent(h)
assert got == original, "decrypted bytes differ from original"
print(f"encrypted (offer RC4+plain) OK: {size/1e6:.1f} MB over MSE")
def test_require_rc4():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_encrypted_seed(root, torrent)
try:
got = _download(meta, port, encryption=2)
finally:
ses.remove_torrent(h)
assert got == original, "decrypted bytes differ from original"
print(f"encrypted (require RC4) OK: {size/1e6:.1f} MB over MSE")
if __name__ == "__main__":
test_encrypted_download()
test_require_rc4()

220
tests/test_endgame.py Normal file
View file

@ -0,0 +1,220 @@
"""
Regression test for engine-side endgame (scheduler.c).
The bug: a piece is claimed loop-wide (tor->requested[i]=1) by one connection,
so no other peer will request it. If that peer stays alive but stops delivering
(slow/snubbing seed), the piece never completes and healthy seeds sit idle even
though they have the data -- the classic "a few pieces never finish" tail stall.
This test reproduces it deterministically with a raw "stalling" seed that serves
every block EXCEPT the last block of each piece (so it keeps its claims but never
finishes them, and the connection stays alive so the claim is never released). A
second, healthy seed is added afterwards. Only engine endgame -- letting an idle
peer race blocks of an already-claimed piece -- can finish the download, so a
regression here turns into a TimeoutError from drive_engine().
Run with: python tests/test_endgame.py
"""
from __future__ import annotations
import os
import socket
import sys
import tempfile
import threading
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "harness"))
from harness import load_metadata # noqa: E402
from engine_ffi import Engine, EngineConfig # noqa: E402
from test_localseed import ensure_built, make_torrent # noqa: E402
from test_engine import TorrentDrive, drive_engine, make_peer_id # noqa: E402
from test_mockpeer import _recv_exact, _msg, _piece_msg # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
BLOCK = 16384
class StallingSeed(threading.Thread):
"""Seed that has every piece but never serves the LAST block of any piece.
It keeps reading (and ignoring) the withheld requests, so the connection
stays healthy and its piece claims are never released -- exactly the state
that wedges the tail without endgame.
"""
def __init__(self, data, meta):
super().__init__(daemon=True)
self.data = data
self.meta = meta
self.served = 0
self.srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.srv.bind(("127.0.0.1", 0))
self.srv.listen(1)
self.port = self.srv.getsockname()[1]
self._stop = False
def stop(self):
self._stop = True
try:
self.srv.close()
except OSError:
pass
def _last_begin(self, index):
plen = self.meta.piece_len(index)
nblocks = (plen + BLOCK - 1) // BLOCK
return (nblocks - 1) * BLOCK
def run(self):
try:
conn, _ = self.srv.accept()
except OSError:
return
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
hs = _recv_exact(conn, 68)
if not hs:
return
info_hash = hs[28:48]
conn.sendall(bytes([19]) + b"BitTorrent protocol" + bytes(8)
+ info_hash + os.urandom(20))
nbytes = (self.meta.num_pieces + 7) // 8
bf = bytearray(nbytes)
for i in range(self.meta.num_pieces):
bf[i >> 3] |= 0x80 >> (i & 7)
conn.sendall(_msg(5, bytes(bf))) # bitfield: has everything
conn.sendall(_msg(1)) # unchoke
plen = self.meta.piece_length
import struct
while not self._stop:
hdr = _recv_exact(conn, 4)
if hdr is None:
break
ln = struct.unpack(">I", hdr)[0]
if ln == 0:
continue
mid = _recv_exact(conn, 1)
if mid is None:
break
payload = _recv_exact(conn, ln - 1) if ln > 1 else b""
if payload is None:
break
if mid[0] != 6: # only act on REQUEST
continue
index, begin, length = struct.unpack(">III", payload)
if begin == self._last_begin(index):
continue # withhold the last block forever -> piece never finishes
off = index * plen + begin
conn.sendall(_piece_msg(index, begin, self.data[off:off + length]))
self.served += 1
class HealthySeed(threading.Thread):
"""Plain seed that serves every requested block."""
def __init__(self, data, meta):
super().__init__(daemon=True)
self.data = data
self.meta = meta
self.srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.srv.bind(("127.0.0.1", 0))
self.srv.listen(1)
self.port = self.srv.getsockname()[1]
self._stop = False
def stop(self):
self._stop = True
try:
self.srv.close()
except OSError:
pass
def run(self):
try:
conn, _ = self.srv.accept()
except OSError:
return
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
hs = _recv_exact(conn, 68)
if not hs:
return
info_hash = hs[28:48]
conn.sendall(bytes([19]) + b"BitTorrent protocol" + bytes(8)
+ info_hash + os.urandom(20))
nbytes = (self.meta.num_pieces + 7) // 8
bf = bytearray(nbytes)
for i in range(self.meta.num_pieces):
bf[i >> 3] |= 0x80 >> (i & 7)
conn.sendall(_msg(5, bytes(bf)))
conn.sendall(_msg(1))
plen = self.meta.piece_length
import struct
while not self._stop:
hdr = _recv_exact(conn, 4)
if hdr is None:
break
ln = struct.unpack(">I", hdr)[0]
if ln == 0:
continue
mid = _recv_exact(conn, 1)
if mid is None:
break
payload = _recv_exact(conn, ln - 1) if ln > 1 else b""
if payload is None:
break
if mid[0] != 6:
continue
index, begin, length = struct.unpack(">III", payload)
off = index * plen + begin
conn.sendall(_piece_msg(index, begin, self.data[off:off + length]))
def test_endgame_rescues_stalled_claims():
ensure_built()
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, 2 * 1024 * 1024) # 8 pieces
meta = load_metadata(torrent)
stalling = StallingSeed(original, meta)
healthy = HealthySeed(original, meta)
stalling.start()
healthy.start()
try:
with Engine(EngineConfig(loop_count=1, slots_per_loop=1024,
max_pipeline=64,
request_timeout_ms=400), lib_path=LIB) as eng:
tid = eng.add_torrent(meta.info_hash, make_peer_id(),
meta.piece_length, meta.total_size,
meta.num_pieces)
eng.set_priorities(tid, [1] * meta.num_pieces)
# Let the stalling seed connect and claim pieces first.
eng.add_peer(tid, "127.0.0.1", stalling.port)
deadline = time.time() + 10.0
while time.time() < deadline:
if eng.status(tid).outstanding > 0:
break
eng.wait(20)
else:
raise TimeoutError("stalling seed never issued requests")
# Now the only way to finish the pieces it claimed is endgame.
eng.add_peer(tid, "127.0.0.1", healthy.port)
drives = {tid: TorrentDrive(meta)}
drive_engine(eng, drives, timeout=30.0)
got = b"".join(bytes(b) for b in drives[tid].buffers)
assert got == original, "endgame download bytes differ"
finally:
stalling.stop()
healthy.stop()
print("endgame OK: healthy seed finished pieces a stalled peer had claimed")
if __name__ == "__main__":
test_endgame_rescues_stalled_claims()

206
tests/test_engine.py Normal file
View file

@ -0,0 +1,206 @@
"""
Engine-level tests for the multi-peer / multi-torrent download engine
(include/engine.h, harness/engine_ffi.py):
* test_two_torrents - one engine, two torrents downloaded concurrently;
each is pinned to a loop and verified byte-for-byte.
* test_two_peers - one torrent fed by two independent seeds; both
connections share the torrent's "requested" state, so
work is split with no duplicate requests, and the file
still verifies.
These exercise the keystone restructure (reactor/loop-pool + transport vtable)
directly, alongside the legacy single-peer path covered by test_localseed.py.
Run with: python tests/test_engine.py
"""
from __future__ import annotations
import hashlib
import os
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
from harness import load_metadata # noqa: E402
from engine_ffi import Engine, EngineConfig, STATE_ERROR, ERROR_NAMES # noqa: E402
from test_localseed import ensure_built, make_torrent, start_full_seed # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
def make_peer_id() -> bytes:
return b"-PC0001-" + os.urandom(12)
class TorrentDrive:
"""Per-torrent reassembly + verification state."""
def __init__(self, meta):
self.meta = meta
self.buffers = [bytearray(meta.piece_len(i)) for i in range(meta.num_pieces)]
self.seen = [set() for _ in range(meta.num_pieces)]
self.received = [0] * meta.num_pieces
self.done = bytearray(meta.num_pieces)
self.done_count = 0
self.order = []
def drive_engine(eng: Engine, drives: dict[int, TorrentDrive], timeout=60.0):
"""Pump the engine until every torrent's pieces are verified."""
want = {tid: d.meta.num_pieces for tid, d in drives.items()}
deadline = time.time() + timeout
while any(drives[tid].done_count < want[tid] for tid in drives):
for tid, d in drives.items():
st = eng.status(tid)
if st.state == STATE_ERROR:
raise RuntimeError(f"torrent {tid} error: {ERROR_NAMES[st.error]}")
descs = eng.poll_ready()
if not descs:
eng.wait(100)
if time.time() > deadline:
raise TimeoutError(
{tid: drives[tid].done_count for tid in drives})
continue
for x in descs:
d = drives[x.torrent]
meta = d.meta
buf = d.buffers[x.piece]
first_copy = x.begin not in d.seen[x.piece]
if first_copy:
buf[x.begin:x.begin + x.len] = eng.block_data(x.loop, x.slot, x.len)
d.seen[x.piece].add(x.begin)
d.received[x.piece] += x.len
eng.release(x.loop, x.slot)
if (not d.done[x.piece]
and d.received[x.piece] >= meta.piece_len(x.piece)):
if hashlib.sha1(bytes(buf)).digest() != meta.piece_hashes[x.piece]:
raise ValueError(f"torrent {x.torrent} piece {x.piece} mismatch")
d.done[x.piece] = 1
d.done_count += 1
d.order.append(x.piece)
eng.set_priority(x.torrent, x.piece, 0)
def test_two_torrents():
ensure_built()
size = 6 * 1024 * 1024
with tempfile.TemporaryDirectory() as root_a, \
tempfile.TemporaryDirectory() as root_b:
ta, data_a, _ = make_torrent(root_a, size)
tb, data_b, _ = make_torrent(root_b, size)
meta_a, meta_b = load_metadata(ta), load_metadata(tb)
ses_a, h_a, port_a = start_full_seed(root_a, ta)
ses_b, h_b, port_b = start_full_seed(root_b, tb)
try:
with Engine(EngineConfig(loop_count=2, slots_per_loop=1024,
max_pipeline=512), lib_path=LIB) as eng:
pid = make_peer_id()
tid_a = eng.add_torrent(meta_a.info_hash, pid, meta_a.piece_length,
meta_a.total_size, meta_a.num_pieces)
tid_b = eng.add_torrent(meta_b.info_hash, pid, meta_b.piece_length,
meta_b.total_size, meta_b.num_pieces)
eng.set_priorities(tid_a, [1] * meta_a.num_pieces)
eng.set_priorities(tid_b, [1] * meta_b.num_pieces)
eng.add_peer(tid_a, "127.0.0.1", port_a)
eng.add_peer(tid_b, "127.0.0.1", port_b)
drives = {tid_a: TorrentDrive(meta_a),
tid_b: TorrentDrive(meta_b)}
t0 = time.time()
drive_engine(eng, drives, timeout=60.0)
dt = time.time() - t0
got_a = b"".join(bytes(b) for b in drives[tid_a].buffers)
got_b = b"".join(bytes(b) for b in drives[tid_b].buffers)
assert got_a == data_a, "torrent A bytes differ"
assert got_b == data_b, "torrent B bytes differ"
finally:
ses_a.remove_torrent(h_a)
ses_b.remove_torrent(h_b)
print(f"two torrents OK: 2 x {size/1e6:.1f} MB in {dt:.2f}s")
def test_two_peers():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
# Two independent seeds of the same content -> two peers for one torrent.
ses1, h1, port1 = start_full_seed(root, torrent)
ses2, h2, port2 = start_full_seed(root, torrent)
try:
with Engine(EngineConfig(loop_count=1, slots_per_loop=1024,
max_pipeline=256), lib_path=LIB) as eng:
tid = eng.add_torrent(meta.info_hash, make_peer_id(),
meta.piece_length, meta.total_size,
meta.num_pieces)
eng.set_priorities(tid, [1] * meta.num_pieces)
eng.add_peer(tid, "127.0.0.1", port1)
eng.add_peer(tid, "127.0.0.1", port2)
drives = {tid: TorrentDrive(meta)}
drive_engine(eng, drives, timeout=60.0)
got = b"".join(bytes(b) for b in drives[tid].buffers)
assert got == original, "two-peer download bytes differ"
st = eng.status(tid)
assert st.peers == 2, f"expected 2 peers, got {st.peers}"
finally:
ses1.remove_torrent(h1)
ses2.remove_torrent(h2)
print(f"two peers OK: {size/1e6:.1f} MB via 2 connections, no duplicate blocks")
def test_peer_failure_releases_claims():
ensure_built()
size = 4 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses1, h1, port1 = start_full_seed(root, torrent)
ses2, h2, port2 = start_full_seed(root, torrent)
removed1 = False
try:
with Engine(EngineConfig(loop_count=1, slots_per_loop=64,
max_pipeline=4), lib_path=LIB) as eng:
tid = eng.add_torrent(meta.info_hash, make_peer_id(),
meta.piece_length, meta.total_size,
meta.num_pieces)
eng.set_priorities(tid, [1] * meta.num_pieces)
eng.add_peer(tid, "127.0.0.1", port1)
deadline = time.time() + 10.0
while time.time() < deadline:
st = eng.status(tid)
if st.outstanding > 0:
break
eng.wait(20)
else:
raise TimeoutError("first peer never issued requests")
ses1.remove_torrent(h1)
removed1 = True
eng.add_peer(tid, "127.0.0.1", port2)
drives = {tid: TorrentDrive(meta)}
drive_engine(eng, drives, timeout=60.0)
got = b"".join(bytes(b) for b in drives[tid].buffers)
assert got == original, "download after peer failure differs"
finally:
if not removed1:
ses1.remove_torrent(h1)
ses2.remove_torrent(h2)
print("peer failure recovery OK: dead-peer claims were released")
if __name__ == "__main__":
test_two_torrents()
test_two_peers()
test_peer_failure_releases_claims()

211
tests/test_localseed.py Normal file
View file

@ -0,0 +1,211 @@
"""
End-to-end tests against a local libtorrent seed:
* test_localseed_roundtrip - full seed, download everything, verify bytes
* test_partial_availability - seed has only some pieces; the peer must
download exactly those and skip the rest
(the bug a fixed in-order schedule hit)
* test_priority_order - pieces are selected highest-priority-first
Run with: python tests/test_localseed.py (or) python -m pytest tests/
"""
from __future__ import annotations
import hashlib
import os
import socket
import subprocess
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
from harness import Downloader, load_metadata # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
PIECE_SIZE = 256 * 1024
def pick_listen_port() -> int:
"""Reserve a free loopback TCP port briefly, then hand it to libtorrent."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def ensure_built():
if os.path.exists(LIB):
return
build = os.path.join(ROOT, "build")
subprocess.run(["cmake", "-S", ROOT, "-B", build,
"-DCMAKE_BUILD_TYPE=Release"], check=True)
subprocess.run(["cmake", "--build", build], check=True)
assert os.path.exists(LIB), "build did not produce libtorrentpeer.so"
def make_torrent(root: str, size: int) -> tuple[str, bytes, str]:
data = os.urandom(size)
path = os.path.join(root, "data.bin")
with open(path, "wb") as f:
f.write(data)
fs = lt.file_storage()
lt.add_files(fs, path)
t = lt.create_torrent(fs, piece_size=PIECE_SIZE)
t.set_priv(False)
lt.set_piece_hashes(t, root)
torrent_path = os.path.join(root, "test.torrent")
with open(torrent_path, "wb") as f:
f.write(lt.bencode(t.generate()))
return torrent_path, data, path
def _session() -> tuple[lt.session, int]:
port = pick_listen_port()
return lt.session({
"listen_interfaces": f"127.0.0.1:{port}",
"enable_dht": False, "enable_lsd": False,
"enable_upnp": False, "enable_natpmp": False,
# Force plaintext so our minimal peer's handshake is accepted.
"in_enc_policy": int(lt.enc_policy.disabled),
"out_enc_policy": int(lt.enc_policy.disabled),
}), port
def start_full_seed(root: str, torrent_path: str):
ses, port = _session()
h = ses.add_torrent({"ti": lt.torrent_info(torrent_path), "save_path": root,
"flags": lt.torrent_flags.seed_mode})
deadline = time.time() + 15
while time.time() < deadline and not h.status().is_seeding:
time.sleep(0.1)
assert h.status().is_seeding, "seed did not become ready"
return ses, h, ses.listen_port() or port
def start_partial_seed(root: str, torrent_path: str, data_path: str, size: int):
"""Corrupt the second half on disk so the seeder only HAS the first half."""
with open(data_path, "r+b") as f:
f.seek(size // 2)
f.write(b"\x00" * (size - size // 2))
ses, port = _session()
h = ses.add_torrent({"ti": lt.torrent_info(torrent_path), "save_path": root})
deadline = time.time() + 15
while time.time() < deadline:
state = str(h.status().state)
if "checking" not in state and "queued" not in state:
break
time.sleep(0.1)
time.sleep(0.3)
avail = {i for i, b in enumerate(h.status().pieces) if b}
return ses, h, ses.listen_port() or port, avail
def drive(dl: Downloader, meta, port: int, prio: bytearray, want: set,
timeout: float = 30.0):
"""Drive the peer until `want` pieces are verified. Returns completion order."""
for i in range(meta.num_pieces):
dl.buffers[i] = bytearray(meta.piece_len(i))
dl.peer.start("127.0.0.1", port)
dl.peer.set_priorities(prio)
order, got = [], set()
deadline = time.time() + timeout
while got != want and time.time() < deadline:
st = dl.peer.status()
if st.state == 6: # STATE_ERROR
raise RuntimeError(f"peer error {st.error}")
descs = dl.peer.poll_ready()
if not descs:
dl.peer.wait(100)
continue
for x in descs:
buf = dl.buffers[x.piece]
buf[x.begin:x.begin + x.len] = dl.peer.block_data(x.slot, x.len)
dl.peer.release(x.slot)
dl.received[x.piece] += x.len
if dl.received[x.piece] >= meta.piece_len(x.piece) and x.piece not in got:
assert hashlib.sha1(bytes(buf)).digest() == meta.piece_hashes[x.piece]
got.add(x.piece)
order.append(x.piece)
dl.peer.set_priority(x.piece, 0)
return order, got
def test_localseed_roundtrip():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_full_seed(root, torrent)
try:
dl = Downloader(meta, num_slots=1024, max_pipeline=512, lib_path=LIB)
try:
t0 = time.time()
got = dl.download("127.0.0.1", port, timeout=60.0)
dt = time.time() - t0
finally:
dl.close()
finally:
ses.remove_torrent(h)
assert got == original, "downloaded bytes differ from original"
print(f"roundtrip OK: {size/1e6:.1f} MB in {dt:.2f}s "
f"({size/1e6/dt:.0f} MB/s)")
def test_partial_availability():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, _, data_path = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port, avail = start_partial_seed(root, torrent, data_path, size)
try:
dl = Downloader(meta, num_slots=512, max_pipeline=256, lib_path=LIB)
try:
prio = bytearray([1] * meta.num_pieces) # want everything
_, got = drive(dl, meta, port, prio, want=avail, timeout=30.0)
# peer must idle, not stall requesting missing pieces
time.sleep(0.3)
assert dl.peer.status().outstanding == 0
finally:
dl.close()
finally:
ses.remove_torrent(h)
assert 0 < len(avail) < meta.num_pieces, "test needs a partial seed"
assert got == avail, (sorted(got), sorted(avail))
print(f"partial OK: downloaded {len(got)}/{meta.num_pieces} available "
f"pieces, skipped {meta.num_pieces - len(avail)} missing")
def test_priority_order():
ensure_built()
size = 2 * 1024 * 1024 # 8 pieces
with tempfile.TemporaryDirectory() as root:
torrent, _, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_full_seed(root, torrent)
try:
# pipeline=1 makes selection order observable; piece i gets priority
# i+1, so we expect strictly descending completion order.
dl = Downloader(meta, num_slots=4, max_pipeline=1, lib_path=LIB)
try:
prio = bytearray([i + 1 for i in range(meta.num_pieces)])
want = set(range(meta.num_pieces))
order, _ = drive(dl, meta, port, prio, want=want, timeout=30.0)
finally:
dl.close()
finally:
ses.remove_torrent(h)
expected = list(range(meta.num_pieces - 1, -1, -1))
assert order == expected, order
print(f"priority order OK: {order}")
if __name__ == "__main__":
test_localseed_roundtrip()
test_partial_availability()
test_priority_order()

362
tests/test_mockpeer.py Normal file
View file

@ -0,0 +1,362 @@
"""
Deterministic tests for the P0 reliability fixes, using a tiny raw-socket mock
BitTorrent peer (no libtorrent quirks in the loop):
* test_request_timeout_recovery - the mock silently drops the first request
for block (0,0); the peer must time out, re-request it, and still complete
(validates C1).
* test_unsolicited_block_ignored - the mock injects a duplicate/unsolicited
block; the peer must drop it without corrupting the download or its credit
accounting (validates C2).
Run with: python tests/test_mockpeer.py (or) python -m pytest tests/
"""
from __future__ import annotations
import os
import socket
import struct
import subprocess
import sys
import threading
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
from harness import Downloader, load_metadata # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
BLOCK = 16384
def ensure_built():
if os.path.exists(LIB):
return
build = os.path.join(ROOT, "build")
subprocess.run(["cmake", "-S", ROOT, "-B", build,
"-DCMAKE_BUILD_TYPE=Release"], check=True)
subprocess.run(["cmake", "--build", build], check=True)
def make_torrent(root: str, size: int, piece: int):
data = os.urandom(size)
path = os.path.join(root, "data.bin")
with open(path, "wb") as f:
f.write(data)
fs = lt.file_storage()
lt.add_files(fs, path)
t = lt.create_torrent(fs, piece_size=piece)
t.set_priv(False)
lt.set_piece_hashes(t, root)
tp = os.path.join(root, "m.torrent")
with open(tp, "wb") as f:
f.write(lt.bencode(t.generate()))
return tp, data
def _recv_exact(conn, n):
buf = b""
while len(buf) < n:
try:
chunk = conn.recv(n - len(buf))
except OSError:
return None
if not chunk:
return None
buf += chunk
return buf
def _msg(mid, payload=b""):
return struct.pack(">I", 1 + len(payload)) + bytes([mid]) + payload
def _ext_msg(ext_id, payload=b""):
return struct.pack(">I", 2 + len(payload)) + bytes([20, ext_id]) + payload
def _piece_msg(index, begin, data):
return (struct.pack(">I", 9 + len(data)) + bytes([7])
+ struct.pack(">II", index, begin) + data)
class MockPeer(threading.Thread):
"""A seed that has every piece, with optional misbehavior for the test."""
def __init__(self, data, meta, *, drop_first=False, inject_unsolicited=False):
super().__init__(daemon=True)
self.data = data
self.meta = meta
self.drop_first = drop_first
self.inject_unsolicited = inject_unsolicited
self.srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.srv.bind(("127.0.0.1", 0))
self.srv.listen(1)
self.port = self.srv.getsockname()[1]
self._stop = False
def stop(self):
self._stop = True
try:
self.srv.close()
except OSError:
pass
def run(self):
try:
conn, _ = self.srv.accept()
except OSError:
return
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
hs = _recv_exact(conn, 68)
if not hs:
return
info_hash = hs[28:48]
conn.sendall(bytes([19]) + b"BitTorrent protocol" + bytes(8)
+ info_hash + os.urandom(20))
nbytes = (self.meta.num_pieces + 7) // 8
bf = bytearray(nbytes)
for i in range(self.meta.num_pieces):
bf[i >> 3] |= 0x80 >> (i & 7)
conn.sendall(_msg(5, bytes(bf))) # bitfield: has everything
conn.sendall(_msg(1)) # unchoke
plen = self.meta.piece_length
dropped = injected = False
served = 0
while not self._stop:
hdr = _recv_exact(conn, 4)
if hdr is None:
break
ln = struct.unpack(">I", hdr)[0]
if ln == 0:
continue
mid = _recv_exact(conn, 1)
if mid is None:
break
payload = _recv_exact(conn, ln - 1) if ln > 1 else b""
if payload is None:
break
if mid[0] != 6: # only act on requests
continue
index, begin, length = struct.unpack(">III", payload)
if self.drop_first and not dropped and (index, begin) == (0, 0):
dropped = True # silently drop -> force a client timeout+retry
continue
off = index * plen + begin
conn.sendall(_piece_msg(index, begin, self.data[off:off + length]))
served += 1
if self.inject_unsolicited and not injected and served >= 4:
injected = True
# Duplicate of (0,0), already delivered -> now unsolicited.
conn.sendall(_piece_msg(0, 0, self.data[0:BLOCK]))
class FastMockPeer(MockPeer):
"""Seed that uses BEP-6 Fast messages instead of a v1 bitfield."""
def __init__(self, data, meta, *, unchoke=True, allowed_fast=False):
super().__init__(data, meta)
self.unchoke = unchoke
self.allowed_fast = allowed_fast
def run(self):
try:
conn, _ = self.srv.accept()
except OSError:
return
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
hs = _recv_exact(conn, 68)
if not hs:
return
info_hash = hs[28:48]
reserved = bytearray(8)
reserved[7] |= 0x04
conn.sendall(bytes([19]) + b"BitTorrent protocol" + bytes(reserved)
+ info_hash + os.urandom(20))
conn.sendall(_msg(14)) # HAVE_ALL
if self.allowed_fast:
for i in range(self.meta.num_pieces):
conn.sendall(_msg(17, struct.pack(">I", i)))
if self.unchoke:
conn.sendall(_msg(1))
plen = self.meta.piece_length
while not self._stop:
hdr = _recv_exact(conn, 4)
if hdr is None:
break
ln = struct.unpack(">I", hdr)[0]
if ln == 0:
continue
mid = _recv_exact(conn, 1)
if mid is None:
break
payload = _recv_exact(conn, ln - 1) if ln > 1 else b""
if payload is None:
break
if mid[0] != 6:
continue
index, begin, length = struct.unpack(">III", payload)
off = index * plen + begin
conn.sendall(_piece_msg(index, begin, self.data[off:off + length]))
class DontHaveMockPeer(MockPeer):
"""Peer that advertises all pieces, then revokes one through BEP-54."""
def __init__(self, data, meta, revoked_piece=0):
super().__init__(data, meta)
self.revoked_piece = revoked_piece
self.requests = []
self.saw_ext_handshake = False
def run(self):
try:
conn, _ = self.srv.accept()
except OSError:
return
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
hs = _recv_exact(conn, 68)
if not hs:
return
info_hash = hs[28:48]
reserved = bytearray(8)
reserved[5] |= 0x10
reserved[7] |= 0x04
conn.sendall(bytes([19]) + b"BitTorrent protocol" + bytes(reserved)
+ info_hash + os.urandom(20))
conn.sendall(_msg(14)) # HAVE_ALL
conn.sendall(_ext_msg(0, b"d1:md11:lt_donthavei1eee"))
conn.sendall(_ext_msg(1, struct.pack(">I", self.revoked_piece)))
conn.sendall(_msg(1))
plen = self.meta.piece_length
while not self._stop:
hdr = _recv_exact(conn, 4)
if hdr is None:
break
ln = struct.unpack(">I", hdr)[0]
if ln == 0:
continue
mid = _recv_exact(conn, 1)
if mid is None:
break
payload = _recv_exact(conn, ln - 1) if ln > 1 else b""
if payload is None:
break
if mid[0] == 20 and payload[:1] == b"\x00" and b"lt_donthave" in payload:
self.saw_ext_handshake = True
if mid[0] != 6:
continue
index, begin, length = struct.unpack(">III", payload)
self.requests.append((index, begin, length))
if index == self.revoked_piece:
continue
off = index * plen + begin
conn.sendall(_piece_msg(index, begin, self.data[off:off + length]))
def _run(drop_first=False, inject_unsolicited=False):
ensure_built()
import tempfile
with tempfile.TemporaryDirectory() as root:
tp, data = make_torrent(root, 2 * 1024 * 1024, 256 * 1024) # 8 pieces
meta = load_metadata(tp)
mock = MockPeer(data, meta, drop_first=drop_first,
inject_unsolicited=inject_unsolicited)
mock.start()
dl = Downloader(meta, num_slots=256, max_pipeline=64,
request_timeout_ms=600, lib_path=LIB)
try:
got = dl.download("127.0.0.1", mock.port, timeout=15.0)
st = dl.peer.status()
finally:
dl.close()
mock.stop()
assert got == data, "download did not reconstruct the original bytes"
assert st.outstanding == 0, f"credit leak: outstanding={st.outstanding}"
return st
def _run_fast(*, unchoke=True, allowed_fast=False):
ensure_built()
import tempfile
with tempfile.TemporaryDirectory() as root:
tp, data = make_torrent(root, 512 * 1024, 256 * 1024) # 2 pieces
meta = load_metadata(tp)
mock = FastMockPeer(data, meta, unchoke=unchoke,
allowed_fast=allowed_fast)
mock.start()
dl = Downloader(meta, num_slots=64, max_pipeline=16,
request_timeout_ms=600, lib_path=LIB)
try:
got = dl.download("127.0.0.1", mock.port, timeout=15.0)
finally:
dl.close()
mock.stop()
assert got == data, "fast-extension download did not reconstruct bytes"
def _run_donthave():
ensure_built()
import tempfile
with tempfile.TemporaryDirectory() as root:
tp, data = make_torrent(root, 512 * 1024, 256 * 1024) # 2 pieces
meta = load_metadata(tp)
mock = DontHaveMockPeer(data, meta, revoked_piece=0)
mock.start()
dl = Downloader(meta, num_slots=64, max_pipeline=16,
request_timeout_ms=600, lib_path=LIB)
try:
try:
dl.download("127.0.0.1", mock.port, pieces=[0], timeout=2.0,
progress_every=10.0)
raise AssertionError("revoked piece unexpectedly downloaded")
except TimeoutError:
pass
finally:
dl.close()
mock.stop()
assert mock.saw_ext_handshake, "client did not advertise lt_donthave"
assert not mock.requests, f"requested revoked piece: {mock.requests[:4]}"
def test_request_timeout_recovery():
_run(drop_first=True)
print("C1 OK: recovered from a silently dropped request via timeout")
def test_unsolicited_block_ignored():
_run(inject_unsolicited=True)
print("C2 OK: ignored an unsolicited block, download intact")
def test_fast_have_all():
_run_fast(unchoke=True)
print("BEP-6 OK: HAVE_ALL populated peer availability")
def test_allowed_fast_while_choked():
_run_fast(unchoke=False, allowed_fast=True)
print("BEP-6 OK: ALLOWED_FAST pieces downloaded while choked")
def test_ltep_donthave_receiver():
_run_donthave()
print("BEP-10/54 OK: LT extension handshake + lt_donthave receiver")
if __name__ == "__main__":
test_request_timeout_recovery()
test_unsolicited_block_ignored()
test_fast_have_all()
test_allowed_fast_while_choked()
test_ltep_donthave_receiver()

View file

@ -0,0 +1,89 @@
"""
Regression tests for swarm_download's endgame helpers.
Run with: python tests/test_swarm_endgame.py
"""
from __future__ import annotations
import hashlib
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "harness"))
from engine_ffi import BLOCK_SIZE # noqa: E402
from swarm_download import EndgameController, PieceAssembler # noqa: E402
class FakeMeta:
def __init__(self, payloads: list[bytes]):
self.payloads = payloads
self.num_pieces = len(payloads)
self.piece_length = max(len(p) for p in payloads)
self.total_size = sum(len(p) for p in payloads)
self.piece_hashes = [hashlib.sha1(p).digest() for p in payloads]
def piece_len(self, piece: int) -> int:
return len(self.payloads[piece])
class FakeEngine:
def __init__(self):
self.priorities = []
self.rearms = []
def set_priority(self, tid: int, piece: int, priority: int) -> None:
self.priorities.append((tid, piece, priority))
def request_piece(self, tid: int, piece: int) -> None:
self.rearms.append((tid, piece))
def test_piece_assembler_ignores_duplicate_blocks():
payload = (b"a" * BLOCK_SIZE) + b"tail"
meta = FakeMeta([payload])
done = bytearray(meta.num_pieces)
asm = PieceAssembler(meta, done)
first = payload[:BLOCK_SIZE]
tail = payload[BLOCK_SIZE:]
added, complete = asm.add_block(0, 0, first)
assert added
assert not complete
assert asm.received[0] == len(first)
added, complete = asm.add_block(0, 0, first)
assert not added
assert not complete
assert asm.received[0] == len(first)
added, complete = asm.add_block(0, BLOCK_SIZE, tail)
assert added
assert complete
assert hashlib.sha1(asm.piece_bytes(0)).digest() == meta.piece_hashes[0]
def test_endgame_rearms_only_unfinished_pieces_on_interval():
meta = FakeMeta([b"a", b"b", b"c", b"d"])
done = bytearray([0, 1, 0, 0])
eng = FakeEngine()
ctl = EndgameController(meta, min_pieces=3, peer_factor=2.0, interval=3.0)
ctl.maybe_rearm(eng, 7, done, done_count=1, connected=2, now=10.0)
assert eng.priorities == [(7, 0, 255), (7, 2, 255), (7, 3, 255)]
assert eng.rearms == [(7, 0), (7, 2), (7, 3)]
ctl.maybe_rearm(eng, 7, done, done_count=1, connected=2, now=11.0)
assert eng.rearms == [(7, 0), (7, 2), (7, 3)]
done[2] = 1
ctl.maybe_rearm(eng, 7, done, done_count=2, connected=2, now=13.0)
assert eng.rearms[-2:] == [(7, 0), (7, 3)]
if __name__ == "__main__":
test_piece_assembler_ignores_duplicate_blocks()
test_endgame_rearms_only_unfinished_pieces_on_interval()
print("swarm endgame OK")

View file

@ -0,0 +1,93 @@
"""
Smoke tests for the torrent-tracker DHT ctypes bindings.
Run with: python tests/test_tracker_ffi_dht.py
"""
from __future__ import annotations
import ctypes as C
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "harness"))
from tracker_ffi import ( # noqa: E402
DHTClient,
DHTMessage,
DHT_MSG_QUERY,
DHT_MSG_RESPONSE,
DHT_QUERY_GET_PEERS,
TRACKER_ADDR_IPV4,
TRACKER_ADDR_IPV6,
TRACKER_OK,
TrackerPeer,
)
def _parse(client: DHTClient, raw: bytes) -> DHTMessage:
msg = DHTMessage()
raw_buf = C.create_string_buffer(raw, len(raw))
rc = client.lib.dht_parse_message(raw_buf, len(raw), C.byref(msg))
assert rc == TRACKER_OK
return msg
def test_get_peers_query_roundtrips_through_tracker_library():
client = DHTClient(bootstrap=())
client.node_id = b"abcdefghij0123456789"
tx = b"aa"
info_hash = bytes(range(20))
msg = _parse(client, client._get_peers_packet(info_hash, tx))
assert msg.type == DHT_MSG_QUERY
assert msg.query == DHT_QUERY_GET_PEERS
assert bytes(msg.transaction[:msg.transaction_len]) == tx
assert bytes(msg.id) == client.node_id
assert bytes(msg.info_hash) == info_hash
assert msg.want_ipv4 == 1
assert msg.want_ipv6 == 1
def test_peers_response_parses_to_endpoint_tuples():
client = DHTClient(bootstrap=())
tx = b"bb"
node_id = b"mnopqrstuvwxyz123456"
token = b"tok"
peers = (TrackerPeer * 2)()
peers[0].family = TRACKER_ADDR_IPV4
for i, b in enumerate((8, 8, 8, 8)):
peers[0].addr[i] = b
peers[0].port = 51413
peers[1].family = TRACKER_ADDR_IPV6
peers[1].addr[15] = 2
peers[1].port = 51414
buf = C.create_string_buffer(1024)
written = C.c_size_t()
tx_buf = C.create_string_buffer(tx, len(tx))
id_buf = C.create_string_buffer(node_id, len(node_id))
token_buf = C.create_string_buffer(token, len(token))
rc = client.lib.dht_write_peers_response(
tx_buf, len(tx), id_buf, token_buf, len(token), peers, 2,
buf, C.sizeof(buf), C.byref(written))
assert rc == TRACKER_OK
msg = _parse(client, buf.raw[:written.value])
assert msg.type == DHT_MSG_RESPONSE
assert bytes(msg.transaction[:msg.transaction_len]) == tx
assert bytes(msg.id) == node_id
assert bytes(msg.token[:msg.token_len]) == token
assert msg.peer_count == 2
assert client._peer_endpoint(msg.peers[0]) == ("8.8.8.8", 51413)
assert client._peer_endpoint(msg.peers[1]) == ("::2", 51414)
if __name__ == "__main__":
test_get_peers_query_roundtrips_through_tracker_library()
test_peers_response_parses_to_endpoint_tuples()
print("tracker DHT ffi OK")

159
tests/test_utp.py Normal file
View file

@ -0,0 +1,159 @@
"""
µTP (BEP-29) tests against a libtorrent seed with TCP disabled, so the only way
to reach it is over µTP/UDP. A successful byte-for-byte download proves the µTP
transport works end to end.
* test_utp_download - plaintext µTP (engine utp=1)
* test_utp_tcp_fallback - engine prefers TCP, then falls back to µTP
* test_utp_encrypted - MSE over µTP (engine utp=1, encryption=1) against a
seed that is both µTP-only and encryption-forced
Run with: python tests/test_utp.py
"""
from __future__ import annotations
import hashlib
import os
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
from harness import load_metadata # noqa: E402
from engine_ffi import Engine, EngineConfig, STATE_ERROR, ERROR_NAMES # noqa: E402
from test_localseed import ensure_built, make_torrent, pick_listen_port # noqa: E402
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
def start_utp_seed(root: str, torrent_path: str, encrypted: bool):
port = pick_listen_port()
settings = {
"listen_interfaces": f"127.0.0.1:{port}",
"enable_dht": False, "enable_lsd": False,
"enable_upnp": False, "enable_natpmp": False,
# µTP only: refuse TCP entirely.
"enable_outgoing_tcp": False,
"enable_incoming_tcp": False,
"enable_outgoing_utp": True,
"enable_incoming_utp": True,
}
if encrypted:
settings.update({
"in_enc_policy": int(lt.enc_policy.forced),
"out_enc_policy": int(lt.enc_policy.forced),
"allowed_enc_level": int(lt.enc_level.rc4),
"prefer_rc4": True,
})
else:
settings.update({
"in_enc_policy": int(lt.enc_policy.disabled),
"out_enc_policy": int(lt.enc_policy.disabled),
})
ses = lt.session(settings)
h = ses.add_torrent({"ti": lt.torrent_info(torrent_path), "save_path": root,
"flags": lt.torrent_flags.seed_mode})
deadline = time.time() + 15
while time.time() < deadline and not h.status().is_seeding:
time.sleep(0.1)
assert h.status().is_seeding, "seed did not become ready"
return ses, h, ses.listen_port() or port
def _download(meta, port, utp, encryption, fallback=0,
connect_timeout_ms=0, timeout=90.0):
with Engine(EngineConfig(loop_count=1, slots_per_loop=1024, max_pipeline=256,
utp=utp, encryption=encryption,
fallback=fallback,
connect_timeout_ms=connect_timeout_ms),
lib_path=LIB) as eng:
tid = eng.add_torrent(meta.info_hash, b"-PC0001-" + os.urandom(12),
meta.piece_length, meta.total_size, meta.num_pieces)
eng.set_priorities(tid, [1] * meta.num_pieces)
eng.add_peer(tid, "127.0.0.1", port)
buffers = [bytearray(meta.piece_len(i)) for i in range(meta.num_pieces)]
received = [0] * meta.num_pieces
done = bytearray(meta.num_pieces)
done_count = 0
deadline = time.time() + timeout
while done_count < meta.num_pieces:
st = eng.status(tid)
if st.state == STATE_ERROR:
raise RuntimeError(f"engine error: {ERROR_NAMES[st.error]}")
descs = eng.poll_ready()
if not descs:
eng.wait(200)
if time.time() > deadline:
raise TimeoutError(f"stalled at {done_count}/{meta.num_pieces} "
f"(connected={st.peers_connected} "
f"failed={st.peers_failed})")
continue
for x in descs:
buf = buffers[x.piece]
buf[x.begin:x.begin + x.len] = eng.block_data(x.loop, x.slot, x.len)
eng.release(x.loop, x.slot)
received[x.piece] += x.len
if not done[x.piece] and received[x.piece] >= meta.piece_len(x.piece):
if hashlib.sha1(bytes(buf)).digest() != meta.piece_hashes[x.piece]:
raise ValueError(f"piece {x.piece} hash mismatch")
done[x.piece] = 1
done_count += 1
eng.set_priority(tid, x.piece, 0)
return b"".join(bytes(b) for b in buffers)
def test_utp_download():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_utp_seed(root, torrent, encrypted=False)
try:
got = _download(meta, port, utp=1, encryption=0)
finally:
ses.remove_torrent(h)
assert got == original, "µTP download bytes differ from original"
print(f"µTP (plaintext) OK: {size/1e6:.1f} MB over UDP")
def test_utp_tcp_fallback():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_utp_seed(root, torrent, encrypted=False)
try:
got = _download(meta, port, utp=0, encryption=0, fallback=1,
connect_timeout_ms=1000)
finally:
ses.remove_torrent(h)
assert got == original, "TCP->µTP fallback bytes differ from original"
print(f"µTP fallback OK: TCP failed over to UDP for {size/1e6:.1f} MB")
def test_utp_encrypted():
ensure_built()
size = 8 * 1024 * 1024
with tempfile.TemporaryDirectory() as root:
torrent, original, _ = make_torrent(root, size)
meta = load_metadata(torrent)
ses, h, port = start_utp_seed(root, torrent, encrypted=True)
try:
got = _download(meta, port, utp=1, encryption=1)
finally:
ses.remove_torrent(h)
assert got == original, "MSE-over-µTP bytes differ from original"
print(f"µTP + MSE OK: {size/1e6:.1f} MB over encrypted UDP")
if __name__ == "__main__":
test_utp_download()
test_utp_tcp_fallback()
test_utp_encrypted()