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>
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""
|
|
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()
|