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:
commit
d8208685a2
55 changed files with 9989 additions and 0 deletions
125
tests/test_encryption.py
Normal file
125
tests/test_encryption.py
Normal 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
220
tests/test_endgame.py
Normal 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
206
tests/test_engine.py
Normal 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
211
tests/test_localseed.py
Normal 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
362
tests/test_mockpeer.py
Normal 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()
|
||||
89
tests/test_swarm_endgame.py
Normal file
89
tests/test_swarm_endgame.py
Normal 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")
|
||||
93
tests/test_tracker_ffi_dht.py
Normal file
93
tests/test_tracker_ffi_dht.py
Normal 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
159
tests/test_utp.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue