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>
364 lines
15 KiB
Python
364 lines
15 KiB
Python
"""
|
|
seed_server.py - A lightweight emulated BitTorrent client (seed side) for
|
|
load-testing the peer/engine against "as many peers as possible".
|
|
|
|
Rather than spinning up N heavyweight libtorrent sessions, this serves one
|
|
torrent's data from a single asyncio event loop across many listener sockets.
|
|
Each listener is a distinct endpoint, so from the engine's point of view each is
|
|
a separate peer: one `add_peer(tid, ip, port)` per listener.
|
|
|
|
It speaks the plaintext BitTorrent v1 wire protocol a real client would on the
|
|
seed side: validates the handshake + info-hash, advertises a full bitfield,
|
|
unchokes, and answers `request` messages with `piece` data read (mmap'd) from
|
|
disk. That is exactly the subset the engine drives, and it is plaintext because
|
|
the engine does not negotiate MSE encryption yet.
|
|
|
|
Usage
|
|
-----
|
|
Serve an existing torrent (data already on disk under --data):
|
|
python harness/seed_server.py some.torrent --data /path/to/datadir --peers 64
|
|
|
|
Generate a random test torrent, serve it, and print the .torrent path:
|
|
python harness/seed_server.py --generate 256M --peers 64 --out /tmp/seedtest
|
|
|
|
Generate + serve + drive the engine against every peer and report throughput:
|
|
python harness/seed_server.py --generate 256M --peers 64 --out /tmp/seedtest --self-test
|
|
|
|
In a test, use the SeedSwarm class directly to start listeners in-process and
|
|
read `swarm.endpoints`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import mmap
|
|
import os
|
|
import secrets
|
|
import struct
|
|
import threading
|
|
import time
|
|
|
|
PSTR = b"BitTorrent protocol"
|
|
HANDSHAKE_LEN = 68
|
|
|
|
# Wire message ids.
|
|
MSG_CHOKE, MSG_UNCHOKE, MSG_INTERESTED, MSG_NOT_INTERESTED = 0, 1, 2, 3
|
|
MSG_HAVE, MSG_BITFIELD, MSG_REQUEST, MSG_PIECE, MSG_CANCEL = 4, 5, 6, 7, 8
|
|
|
|
DRAIN_HIGH_WATER = 1 << 20 # let blocks queue up, apply backpressure past 1 MiB
|
|
MAX_BLOCK = 1 << 17 # reject absurd request lengths (128 KiB)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Data source: read (offset, length) from the torrent's concatenated files.
|
|
# --------------------------------------------------------------------------- #
|
|
class DataSource:
|
|
"""Maps the linear piece space onto one or more on-disk files (BT v1 lays
|
|
files out back-to-back). Files are mmap'd for cheap repeated reads."""
|
|
|
|
def __init__(self, files: list[tuple[str, int]]):
|
|
self._maps = [] # (global_offset, size, mmap_or_none)
|
|
self._handles = []
|
|
off = 0
|
|
for path, size in files:
|
|
mm = None
|
|
if size > 0:
|
|
fh = open(path, "rb")
|
|
self._handles.append(fh)
|
|
mm = mmap.mmap(fh.fileno(), size, prot=mmap.PROT_READ)
|
|
self._maps.append((off, size, mm))
|
|
off += size
|
|
self.total = off
|
|
self._single = self._maps[0][2] if len(self._maps) == 1 else None
|
|
|
|
def read(self, offset: int, length: int) -> bytes:
|
|
if self._single is not None: # fast path: one file
|
|
return self._single[offset:offset + length]
|
|
out = bytearray()
|
|
remaining = length
|
|
for foff, size, mm in self._maps:
|
|
if remaining <= 0:
|
|
break
|
|
if offset >= foff + size or offset < foff:
|
|
continue
|
|
local = offset - foff
|
|
take = min(size - local, remaining)
|
|
out += mm[local:local + take]
|
|
offset += take
|
|
remaining -= take
|
|
return bytes(out)
|
|
|
|
def close(self):
|
|
for _, _, mm in self._maps:
|
|
if mm is not None:
|
|
mm.close()
|
|
for fh in self._handles:
|
|
fh.close()
|
|
|
|
|
|
def _full_bitfield(num_pieces: int) -> bytes:
|
|
nbytes = (num_pieces + 7) // 8
|
|
bf = bytearray(b"\xff" * nbytes)
|
|
rem = num_pieces & 7
|
|
if rem: # clear pad bits past the end
|
|
bf[-1] = (0xFF << (8 - rem)) & 0xFF
|
|
return bytes(bf)
|
|
|
|
|
|
def _msg(mid: int, payload: bytes = b"") -> bytes:
|
|
return struct.pack(">IB", 1 + len(payload), mid) + payload
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The swarm: many listeners sharing one event loop, on a background thread.
|
|
# --------------------------------------------------------------------------- #
|
|
class SeedSwarm:
|
|
def __init__(self, info_hash: bytes, piece_length: int, num_pieces: int,
|
|
source: DataSource, host: str = "127.0.0.1"):
|
|
self.info_hash = info_hash
|
|
self.piece_length = piece_length
|
|
self.num_pieces = num_pieces
|
|
self.source = source
|
|
self.host = host
|
|
self.bitfield = _full_bitfield(num_pieces)
|
|
self.endpoints: list[tuple[str, int]] = []
|
|
self.served_bytes = 0
|
|
self._loop = asyncio.new_event_loop()
|
|
self._servers: list[asyncio.AbstractServer] = []
|
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
|
|
# -- connection handler (one per accepted peer) ------------------------- #
|
|
async def _handle(self, reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter):
|
|
try:
|
|
hs = await reader.readexactly(HANDSHAKE_LEN)
|
|
if hs[1:20] != PSTR or hs[28:48] != self.info_hash:
|
|
writer.close()
|
|
return
|
|
peer_id = b"-SD0001-" + secrets.token_bytes(12)
|
|
writer.write(bytes([len(PSTR)]) + PSTR + b"\x00" * 8 +
|
|
self.info_hash + peer_id)
|
|
writer.write(_msg(MSG_BITFIELD, self.bitfield))
|
|
writer.write(_msg(MSG_UNCHOKE))
|
|
await writer.drain()
|
|
|
|
while True:
|
|
(length,) = struct.unpack(">I", await reader.readexactly(4))
|
|
if length == 0:
|
|
continue # keep-alive
|
|
body = await reader.readexactly(length)
|
|
mid = body[0]
|
|
if mid == MSG_REQUEST and length >= 13:
|
|
index, begin, blen = struct.unpack(">III", body[1:13])
|
|
if blen > MAX_BLOCK:
|
|
continue
|
|
data = self.source.read(index * self.piece_length + begin, blen)
|
|
writer.write(struct.pack(">IB", 9 + len(data), MSG_PIECE) +
|
|
struct.pack(">II", index, begin) + data)
|
|
self.served_bytes += len(data)
|
|
if writer.transport.get_write_buffer_size() > DRAIN_HIGH_WATER:
|
|
await writer.drain()
|
|
# interested / not-interested / cancel / choke: nothing to do —
|
|
# we are already unchoked and answer requests as they arrive.
|
|
except (asyncio.IncompleteReadError, ConnectionResetError,
|
|
BrokenPipeError, ConnectionError):
|
|
pass
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _make_servers(self, count: int):
|
|
servers, endpoints = [], []
|
|
for _ in range(count):
|
|
srv = await asyncio.start_server(self._handle, self.host, 0)
|
|
servers.append(srv)
|
|
endpoints.append((self.host, srv.sockets[0].getsockname()[1]))
|
|
return servers, endpoints
|
|
|
|
def _run(self):
|
|
asyncio.set_event_loop(self._loop)
|
|
self._loop.run_forever()
|
|
|
|
def start(self, count: int):
|
|
self._thread.start()
|
|
fut = asyncio.run_coroutine_threadsafe(self._make_servers(count), self._loop)
|
|
self._servers, self.endpoints = fut.result(timeout=15)
|
|
return self.endpoints
|
|
|
|
def stop(self):
|
|
def _shutdown():
|
|
for srv in self._servers:
|
|
srv.close()
|
|
self._loop.stop()
|
|
if self._loop.is_running():
|
|
self._loop.call_soon_threadsafe(_shutdown)
|
|
self._thread.join(timeout=5)
|
|
try:
|
|
self._loop.close()
|
|
except Exception:
|
|
pass
|
|
self.source.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Torrent loading / generation.
|
|
# --------------------------------------------------------------------------- #
|
|
def load_torrent(torrent_path: str, data_dir: str):
|
|
"""Return (info_hash, piece_length, num_pieces, total_size, DataSource)."""
|
|
from torrent_meta import load_torrent as parse_torrent
|
|
tf = parse_torrent(torrent_path)
|
|
files = []
|
|
for path, size in tf.files:
|
|
files.append((os.path.join(data_dir, path), size))
|
|
missing = [p for p, _ in files if not os.path.exists(p)]
|
|
if missing:
|
|
raise FileNotFoundError(f"data files not found under {data_dir}: {missing}")
|
|
meta = tf.metadata
|
|
return (meta.info_hash, meta.piece_length, meta.num_pieces,
|
|
meta.total_size, DataSource(files))
|
|
|
|
|
|
def generate_torrent(out_dir: str, size: int, piece_size: int = 256 * 1024):
|
|
"""Create a random data file + .torrent under out_dir. Returns
|
|
(torrent_path, info_hash, piece_length, num_pieces, total_size, DataSource)."""
|
|
import libtorrent as lt
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
data_path = os.path.join(out_dir, "data.bin")
|
|
with open(data_path, "wb") as f:
|
|
rem = size
|
|
while rem > 0:
|
|
n = min(rem, 8 << 20)
|
|
f.write(os.urandom(n))
|
|
rem -= n
|
|
fs = lt.file_storage()
|
|
lt.add_files(fs, data_path)
|
|
t = lt.create_torrent(fs, piece_size=piece_size)
|
|
t.set_priv(False)
|
|
lt.set_piece_hashes(t, out_dir)
|
|
torrent_path = os.path.join(out_dir, "test.torrent")
|
|
with open(torrent_path, "wb") as f:
|
|
f.write(lt.bencode(t.generate()))
|
|
ih, pl, npc, total, src = load_torrent(torrent_path, out_dir)
|
|
return torrent_path, ih, pl, npc, total, src
|
|
|
|
|
|
def parse_size(s: str) -> int:
|
|
s = s.strip().upper()
|
|
mult = 1
|
|
if s and s[-1] in "KMG":
|
|
mult = {"K": 1024, "M": 1024**2, "G": 1024**3}[s[-1]]
|
|
s = s[:-1]
|
|
return int(float(s) * mult)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Optional: drive the engine against every peer and verify + report rate.
|
|
# --------------------------------------------------------------------------- #
|
|
def _self_test(torrent_path: str, data_dir: str, endpoints, timeout: float):
|
|
import hashlib
|
|
from engine_ffi import Engine, EngineConfig, STATE_ERROR, ERROR_NAMES
|
|
from harness import load_metadata
|
|
|
|
meta = load_metadata(torrent_path)
|
|
lib = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"build", "libtorrentpeer.so")
|
|
loops = min(8, max(1, (os.cpu_count() or 1)))
|
|
with Engine(EngineConfig(loop_count=loops, slots_per_loop=4096,
|
|
max_pipeline=1024), lib_path=lib) as eng:
|
|
tid = eng.add_torrent(meta.info_hash, b"-PC0001-" + secrets.token_bytes(12),
|
|
meta.piece_length, meta.total_size, meta.num_pieces)
|
|
eng.set_priorities(tid, [1] * meta.num_pieces)
|
|
for ip, port in endpoints:
|
|
eng.add_peer(tid, ip, 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
|
|
t0 = time.time()
|
|
deadline = t0 + 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}")
|
|
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)
|
|
dt = time.time() - t0
|
|
mb = meta.total_size / 1e6
|
|
print(f"self-test OK: {mb:.1f} MB from {len(endpoints)} peers "
|
|
f"in {dt:.2f}s = {mb/dt:.1f} MB/s (peers={eng.status(tid).peers})")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("torrent", nargs="?", help="path to an existing .torrent")
|
|
ap.add_argument("--data", help="directory (or file) holding the torrent's data")
|
|
ap.add_argument("--generate", metavar="SIZE",
|
|
help="generate a random torrent of this size (e.g. 256M, 1G)")
|
|
ap.add_argument("--out", default="/tmp/seedtest",
|
|
help="output dir for --generate (default: /tmp/seedtest)")
|
|
ap.add_argument("--peers", type=int, default=32, help="number of listeners")
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--piece-size", type=int, default=256 * 1024)
|
|
ap.add_argument("--ports-file", help="write the endpoints (one ip:port/line) here")
|
|
ap.add_argument("--self-test", action="store_true",
|
|
help="drive the engine against all peers, verify, report rate")
|
|
ap.add_argument("--timeout", type=float, default=300.0)
|
|
args = ap.parse_args()
|
|
|
|
torrent_path = args.torrent
|
|
if args.generate:
|
|
size = parse_size(args.generate)
|
|
torrent_path, ih, pl, npc, total, src = generate_torrent(
|
|
args.out, size, args.piece_size)
|
|
data_dir = args.out
|
|
print(f"generated {torrent_path} ({total} bytes, {npc} pieces x {pl})")
|
|
else:
|
|
if not torrent_path or not args.data:
|
|
ap.error("provide a .torrent and --data, or use --generate SIZE")
|
|
data_dir = args.data if os.path.isdir(args.data) else os.path.dirname(args.data)
|
|
ih, pl, npc, total, src = load_torrent(torrent_path, data_dir)
|
|
|
|
swarm = SeedSwarm(ih, pl, npc, src, host=args.host)
|
|
swarm.start(args.peers)
|
|
print(f"seeding {total} bytes on {len(swarm.endpoints)} peers "
|
|
f"({args.host}:{swarm.endpoints[0][1]}..{swarm.endpoints[-1][1]})")
|
|
if args.ports_file:
|
|
with open(args.ports_file, "w") as f:
|
|
f.writelines(f"{ip}:{port}\n" for ip, port in swarm.endpoints)
|
|
print(f"endpoints written to {args.ports_file}")
|
|
|
|
try:
|
|
if args.self_test:
|
|
_self_test(torrent_path, data_dir, swarm.endpoints, args.timeout)
|
|
else:
|
|
print("serving; press Ctrl-C to stop")
|
|
while True:
|
|
time.sleep(1.0)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
swarm.stop()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|