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
202
harness/engine_ffi.py
Normal file
202
harness/engine_ffi.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
ctypes bindings for the multi-peer engine ABI (include/engine.h).
|
||||
|
||||
The engine owns a pool of event-loop threads; torrents are pinned to a loop and
|
||||
every connection of a torrent lives there. Each loop has its own arena, so a
|
||||
delivered block names both the loop and the slot. We wrap each loop's arena once
|
||||
as a zero-copy ``memoryview`` and slice per block; returning the slot via
|
||||
``release()`` is what lets the engine issue new requests (credit-based flow
|
||||
control).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes as C
|
||||
import os
|
||||
|
||||
# peer_state / peer_error mirrors of include/engine.h
|
||||
STATE_IDLE, STATE_CONNECTING, STATE_HANDSHAKE, STATE_CHOKED, \
|
||||
STATE_RUNNING, STATE_STOPPED, STATE_ERROR = range(7)
|
||||
STATE_NAMES = ["IDLE", "CONNECTING", "HANDSHAKE", "CHOKED",
|
||||
"RUNNING", "STOPPED", "ERROR"]
|
||||
ERROR_NAMES = ["OK", "CONNECT", "HANDSHAKE", "CLOSED", "PROTOCOL", "IO", "NOMEM"]
|
||||
|
||||
BLOCK_SIZE = 16384
|
||||
|
||||
|
||||
class EngineConfig(C.Structure):
|
||||
_fields_ = [
|
||||
("loop_count", C.c_uint32),
|
||||
("slots_per_loop", C.c_uint32),
|
||||
("max_pipeline", C.c_uint32),
|
||||
("request_timeout_ms", C.c_uint32),
|
||||
("recv_buffer_bytes", C.c_uint32),
|
||||
("encryption", C.c_uint32),
|
||||
("utp", C.c_uint32),
|
||||
("connect_timeout_ms", C.c_uint32),
|
||||
("fallback", C.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
class EngineBlock(C.Structure):
|
||||
_fields_ = [
|
||||
("torrent", C.c_uint32),
|
||||
("piece", C.c_uint32),
|
||||
("begin", C.c_uint32),
|
||||
("len", C.c_uint32),
|
||||
("loop", C.c_uint32),
|
||||
("slot", C.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
class TorrentStatus(C.Structure):
|
||||
_fields_ = [
|
||||
("state", C.c_int32),
|
||||
("error", C.c_int32),
|
||||
("bytes_received", C.c_uint64),
|
||||
("blocks_received", C.c_uint64),
|
||||
("peers", C.c_uint32),
|
||||
("peers_connected", C.c_uint32),
|
||||
("peers_failed", C.c_uint32),
|
||||
("outstanding", C.c_uint32),
|
||||
("free_slots", C.c_uint32),
|
||||
("pipeline_target", C.c_uint32),
|
||||
("rate_bps", C.c_double),
|
||||
("rtt_min_ms", C.c_double),
|
||||
]
|
||||
|
||||
|
||||
def _default_lib_path() -> str:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cand = [
|
||||
os.path.join(here, "..", "build", "libtorrentpeer.so"),
|
||||
os.path.join(here, "..", "build", "lib", "libtorrentpeer.so"),
|
||||
]
|
||||
for p in cand:
|
||||
if os.path.exists(p):
|
||||
return os.path.abspath(p)
|
||||
return os.path.abspath(cand[0])
|
||||
|
||||
|
||||
def _load(lib_path: str | None) -> C.CDLL:
|
||||
lib = C.CDLL(lib_path or _default_lib_path())
|
||||
lib.engine_create.restype = C.c_void_p
|
||||
lib.engine_create.argtypes = [C.POINTER(EngineConfig)]
|
||||
lib.engine_destroy.restype = None
|
||||
lib.engine_destroy.argtypes = [C.c_void_p]
|
||||
lib.engine_add_torrent.restype = C.c_int32
|
||||
lib.engine_add_torrent.argtypes = [
|
||||
C.c_void_p, C.POINTER(C.c_uint8), C.POINTER(C.c_uint8),
|
||||
C.c_uint64, C.c_uint64, C.c_uint32,
|
||||
]
|
||||
lib.engine_add_peer.restype = C.c_int
|
||||
lib.engine_add_peer.argtypes = [C.c_void_p, C.c_uint32, C.c_char_p, C.c_uint16]
|
||||
lib.engine_set_priorities.restype = C.c_int
|
||||
lib.engine_set_priorities.argtypes = [
|
||||
C.c_void_p, C.c_uint32, C.POINTER(C.c_uint8), C.c_uint32]
|
||||
lib.engine_set_priority.restype = C.c_int
|
||||
lib.engine_set_priority.argtypes = [C.c_void_p, C.c_uint32, C.c_uint32, C.c_uint8]
|
||||
lib.engine_request_piece.restype = C.c_int
|
||||
lib.engine_request_piece.argtypes = [C.c_void_p, C.c_uint32, C.c_uint32]
|
||||
lib.engine_poll_ready.restype = C.c_uint32
|
||||
lib.engine_poll_ready.argtypes = [C.c_void_p, C.POINTER(EngineBlock), C.c_uint32]
|
||||
lib.engine_release_slot.restype = None
|
||||
lib.engine_release_slot.argtypes = [C.c_void_p, C.c_uint32, C.c_uint32]
|
||||
lib.engine_wait.restype = C.c_int
|
||||
lib.engine_wait.argtypes = [C.c_void_p, C.c_int]
|
||||
lib.engine_arena_base.restype = C.c_void_p
|
||||
lib.engine_arena_base.argtypes = [C.c_void_p, C.c_uint32]
|
||||
lib.engine_arena_bytes.restype = C.c_uint64
|
||||
lib.engine_arena_bytes.argtypes = [C.c_void_p, C.c_uint32]
|
||||
lib.engine_loop_count.restype = C.c_uint32
|
||||
lib.engine_loop_count.argtypes = [C.c_void_p]
|
||||
lib.engine_torrent_status.restype = None
|
||||
lib.engine_torrent_status.argtypes = [C.c_void_p, C.c_uint32, C.POINTER(TorrentStatus)]
|
||||
return lib
|
||||
|
||||
|
||||
class Engine:
|
||||
"""Pythonic wrapper around one engine instance (a pool of loops)."""
|
||||
|
||||
def __init__(self, cfg: EngineConfig | None = None,
|
||||
lib_path: str | None = None, poll_batch: int = 1024):
|
||||
self._lib = _load(lib_path)
|
||||
self._e = self._lib.engine_create(C.byref(cfg) if cfg else None)
|
||||
if not self._e:
|
||||
raise RuntimeError("engine_create failed")
|
||||
|
||||
# One zero-copy memoryview per loop arena.
|
||||
self.nloops = self._lib.engine_loop_count(self._e)
|
||||
self._arenas = []
|
||||
self.arenas = []
|
||||
for i in range(self.nloops):
|
||||
base = self._lib.engine_arena_base(self._e, i)
|
||||
nbytes = self._lib.engine_arena_bytes(self._e, i)
|
||||
buf = (C.c_char * nbytes).from_address(base)
|
||||
self._arenas.append(buf)
|
||||
self.arenas.append(memoryview(buf).cast("B"))
|
||||
|
||||
self._batch = poll_batch
|
||||
self._blocks = (EngineBlock * poll_batch)()
|
||||
|
||||
def add_torrent(self, info_hash: bytes, peer_id: bytes, piece_length: int,
|
||||
total_size: int, num_pieces: int) -> int:
|
||||
ih = (C.c_uint8 * 20).from_buffer_copy(info_hash)
|
||||
pid = (C.c_uint8 * 20).from_buffer_copy(peer_id)
|
||||
tid = self._lib.engine_add_torrent(self._e, ih, pid, piece_length,
|
||||
total_size, num_pieces)
|
||||
if tid < 0:
|
||||
raise RuntimeError("engine_add_torrent failed")
|
||||
return tid
|
||||
|
||||
def add_peer(self, torrent_id: int, ip: str, port: int) -> None:
|
||||
if self._lib.engine_add_peer(self._e, torrent_id, ip.encode(), port) != 0:
|
||||
raise RuntimeError("engine_add_peer failed")
|
||||
|
||||
def set_priorities(self, torrent_id: int, priorities) -> None:
|
||||
buf = bytes(priorities)
|
||||
arr = (C.c_uint8 * len(buf)).from_buffer_copy(buf)
|
||||
if self._lib.engine_set_priorities(self._e, torrent_id, arr, len(buf)) != 0:
|
||||
raise ValueError("set_priorities: length must equal num_pieces")
|
||||
|
||||
def set_priority(self, torrent_id: int, piece_index: int, priority: int) -> None:
|
||||
if self._lib.engine_set_priority(self._e, torrent_id, piece_index, priority) != 0:
|
||||
raise ValueError(f"set_priority({piece_index}) out of range")
|
||||
|
||||
def request_piece(self, torrent_id: int, piece_index: int) -> None:
|
||||
if self._lib.engine_request_piece(self._e, torrent_id, piece_index) != 0:
|
||||
raise ValueError(f"request_piece({piece_index}) out of range")
|
||||
|
||||
def poll_ready(self):
|
||||
"""Return a list of EngineBlock for completed blocks (may be empty)."""
|
||||
n = self._lib.engine_poll_ready(self._e, self._blocks, self._batch)
|
||||
return [self._blocks[i] for i in range(n)]
|
||||
|
||||
def block_data(self, loop: int, slot: int, length: int) -> memoryview:
|
||||
off = slot * BLOCK_SIZE
|
||||
return self.arenas[loop][off:off + length]
|
||||
|
||||
def release(self, loop: int, slot: int) -> None:
|
||||
self._lib.engine_release_slot(self._e, loop, slot)
|
||||
|
||||
def wait(self, timeout_ms: int) -> int:
|
||||
return self._lib.engine_wait(self._e, timeout_ms)
|
||||
|
||||
def status(self, torrent_id: int) -> TorrentStatus:
|
||||
st = TorrentStatus()
|
||||
self._lib.engine_torrent_status(self._e, torrent_id, C.byref(st))
|
||||
return st
|
||||
|
||||
def close(self) -> None:
|
||||
if self._e:
|
||||
for mv in self.arenas:
|
||||
mv.release()
|
||||
self.arenas = []
|
||||
self._arenas = []
|
||||
self._lib.engine_destroy(self._e)
|
||||
self._e = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
202
harness/harness.py
Normal file
202
harness/harness.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
Test/driver harness for the C peer.
|
||||
|
||||
The Python harness parses .torrent metadata and can discover peers through the
|
||||
sibling torrent-tracker C library's DHT and HTTP/UDP tracker helpers. The C peer
|
||||
does the fast part: pull the requested blocks from one peer. This harness owns
|
||||
what to download, reassembles pieces, and verifies SHA-1 hashes -- the peer
|
||||
never hashes or persists anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from peer_ffi import Peer, PeerConfig, STATE_ERROR, STATE_NAMES, ERROR_NAMES
|
||||
from torrent_meta import Metadata, load_metadata, load_torrent
|
||||
from tracker_ffi import DHTClient, TrackerClient
|
||||
|
||||
|
||||
def make_peer_id() -> bytes:
|
||||
return b"-PC0001-" + secrets.token_bytes(12)
|
||||
|
||||
|
||||
def discover_peers(torrent_path: str, max_wait: float = 20.0) -> list[tuple[str, int]]:
|
||||
"""Discover peer endpoints through DHT first, then torrent trackers."""
|
||||
tf = load_torrent(torrent_path)
|
||||
peer_id = make_peer_id()
|
||||
key = secrets.randbits(32)
|
||||
deadline = time.time() + max_wait
|
||||
seen: set[tuple[str, int]] = set()
|
||||
|
||||
dht_budget = max(0.5, min(6.0, max_wait / 2.0))
|
||||
dht_result = DHTClient().lookup(tf.metadata.info_hash, timeout=dht_budget)
|
||||
seen.update(dht_result.peers)
|
||||
if seen:
|
||||
return sorted(seen)
|
||||
|
||||
client = TrackerClient()
|
||||
for url in tf.trackers:
|
||||
if time.time() >= deadline:
|
||||
break
|
||||
result = client.announce(
|
||||
url, tf.metadata, peer_id, port=6881, key=key, numwant=50,
|
||||
event="started", timeout=max(0.5, deadline - time.time()))
|
||||
if result.ok:
|
||||
seen.update(result.peers)
|
||||
if seen:
|
||||
break
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
class Downloader:
|
||||
def __init__(self, meta: Metadata, *, peer_id: bytes | None = None,
|
||||
num_slots: int = 0, max_pipeline: int = 0,
|
||||
request_timeout_ms: int = 0, recv_buffer_bytes: int = 0,
|
||||
lib_path: str | None = None):
|
||||
self.meta = meta
|
||||
cfg = PeerConfig()
|
||||
cfg.info_hash[:] = meta.info_hash
|
||||
cfg.peer_id[:] = peer_id or make_peer_id()
|
||||
cfg.piece_length = meta.piece_length
|
||||
cfg.total_size = meta.total_size
|
||||
cfg.num_pieces = meta.num_pieces
|
||||
cfg.num_slots = num_slots
|
||||
cfg.max_pipeline = max_pipeline
|
||||
cfg.request_timeout_ms = request_timeout_ms
|
||||
cfg.recv_buffer_bytes = recv_buffer_bytes
|
||||
self.peer = Peer(cfg, lib_path)
|
||||
|
||||
self.buffers: list[bytearray | None] = [None] * meta.num_pieces
|
||||
self.received = [0] * meta.num_pieces
|
||||
self.done = bytearray(meta.num_pieces)
|
||||
self.done_count = 0
|
||||
|
||||
def download(self, ip: str, port: int, pieces=None, priorities=None,
|
||||
timeout: float = 60.0, progress_every: float = 1.0,
|
||||
output: str | None = None) -> bytes | None:
|
||||
meta = self.meta
|
||||
pieces = list(pieces) if pieces is not None else list(range(meta.num_pieces))
|
||||
|
||||
out_fh = open(output, "wb") if output else None
|
||||
if out_fh:
|
||||
out_fh.truncate(meta.total_size)
|
||||
|
||||
# Build the priority vector: caller-supplied scheme, or uniform "1" over
|
||||
# the wanted pieces (0 = not wanted). The peer masks this with what the
|
||||
# remote actually has, so a peer missing a piece is simply skipped.
|
||||
prio = bytearray(priorities) if priorities is not None \
|
||||
else bytearray(meta.num_pieces)
|
||||
for i in pieces:
|
||||
self.buffers[i] = bytearray(meta.piece_len(i))
|
||||
if priorities is None:
|
||||
prio[i] = 1
|
||||
|
||||
self.peer.start(ip, port)
|
||||
self.peer.set_priorities(prio)
|
||||
|
||||
want = len(pieces)
|
||||
deadline = time.time() + timeout
|
||||
last_print = 0.0
|
||||
last_progress_bytes = 0
|
||||
last_progress_time = time.time()
|
||||
|
||||
while self.done_count < want:
|
||||
st = self.peer.status()
|
||||
if st.state == STATE_ERROR:
|
||||
raise RuntimeError(f"peer error: {ERROR_NAMES[st.error]}")
|
||||
|
||||
descs = self.peer.poll_ready()
|
||||
if not descs:
|
||||
self.peer.wait(100)
|
||||
now = time.time()
|
||||
if st.bytes_received != last_progress_bytes:
|
||||
last_progress_bytes = st.bytes_received
|
||||
last_progress_time = now
|
||||
if now > deadline and now - last_progress_time > timeout:
|
||||
raise TimeoutError(
|
||||
f"stalled: {self.done_count}/{want} pieces, "
|
||||
f"state={STATE_NAMES[st.state]}")
|
||||
if now - last_print >= progress_every:
|
||||
last_print = now
|
||||
print(f" {self.done_count}/{want} pieces "
|
||||
f"{st.rate_bps/1e6:.1f} MB/s "
|
||||
f"outstanding={st.outstanding} free={st.free_slots}")
|
||||
continue
|
||||
|
||||
for d in descs:
|
||||
buf = self.buffers[d.piece]
|
||||
buf[d.begin:d.begin + d.len] = self.peer.block_data(d.slot, d.len)
|
||||
self.peer.release(d.slot)
|
||||
self.received[d.piece] += d.len
|
||||
if (not self.done[d.piece]
|
||||
and self.received[d.piece] >= meta.piece_len(d.piece)):
|
||||
digest = hashlib.sha1(bytes(buf)).digest()
|
||||
if digest != meta.piece_hashes[d.piece]:
|
||||
raise ValueError(f"piece {d.piece} hash mismatch")
|
||||
self.done[d.piece] = 1
|
||||
self.done_count += 1
|
||||
# Drop priority so a verified piece is no longer a
|
||||
# selection candidate (the peer also won't re-request it).
|
||||
self.peer.set_priority(d.piece, 0)
|
||||
if out_fh:
|
||||
out_fh.seek(d.piece * meta.piece_length)
|
||||
out_fh.write(buf)
|
||||
if not output:
|
||||
pass # keep in memory for return
|
||||
else:
|
||||
self.buffers[d.piece] = None # free once flushed
|
||||
|
||||
if out_fh:
|
||||
out_fh.close()
|
||||
return None
|
||||
return b"".join(bytes(self.buffers[i]) for i in pieces)
|
||||
|
||||
def close(self):
|
||||
self.peer.stop()
|
||||
self.peer.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Drive the C peer to download a torrent.")
|
||||
ap.add_argument("torrent", help="path to .torrent file")
|
||||
ap.add_argument("--peer", help="explicit peer ip:port (skip tracker)")
|
||||
ap.add_argument("--output", "-o", help="write downloaded data here")
|
||||
ap.add_argument("--slots", type=int, default=0, help="arena slots (16 KiB each)")
|
||||
ap.add_argument("--pipeline", type=int, default=0, help="max outstanding requests")
|
||||
ap.add_argument("--timeout", type=float, default=120.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
meta = load_metadata(args.torrent)
|
||||
print(f"torrent: {meta.name} {meta.total_size} bytes "
|
||||
f"{meta.num_pieces} pieces x {meta.piece_length}")
|
||||
|
||||
if args.peer:
|
||||
host, port = args.peer.rsplit(":", 1)
|
||||
endpoints = [(host, int(port))]
|
||||
else:
|
||||
print("discovering peers via tracker/DHT...")
|
||||
endpoints = discover_peers(args.torrent)
|
||||
if not endpoints:
|
||||
print("no peers found")
|
||||
return 1
|
||||
print(f"found {len(endpoints)} peer(s); using {endpoints[0]}")
|
||||
|
||||
dl = Downloader(meta, num_slots=args.slots, max_pipeline=args.pipeline)
|
||||
try:
|
||||
ip, port = endpoints[0]
|
||||
t0 = time.time()
|
||||
dl.download(ip, port, timeout=args.timeout, output=args.output)
|
||||
dt = time.time() - t0
|
||||
mb = meta.total_size / 1e6
|
||||
print(f"done: {mb:.1f} MB in {dt:.2f}s = {mb/dt:.1f} MB/s")
|
||||
finally:
|
||||
dl.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
181
harness/peer_ffi.py
Normal file
181
harness/peer_ffi.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
ctypes bindings for libtorrentpeer.so.
|
||||
|
||||
The arena is wrapped once as a zero-copy ``memoryview``; ``block_data()`` returns
|
||||
a slice into it, so the harness never copies a block until it chooses to (e.g.
|
||||
into a per-piece buffer for hashing). Returning the slot via ``release()`` is what
|
||||
lets the peer issue new requests (credit-based flow control).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes as C
|
||||
import os
|
||||
|
||||
# peer_state / peer_error mirrors of include/peer.h
|
||||
STATE_IDLE, STATE_CONNECTING, STATE_HANDSHAKE, STATE_CHOKED, \
|
||||
STATE_RUNNING, STATE_STOPPED, STATE_ERROR = range(7)
|
||||
STATE_NAMES = ["IDLE", "CONNECTING", "HANDSHAKE", "CHOKED",
|
||||
"RUNNING", "STOPPED", "ERROR"]
|
||||
ERROR_NAMES = ["OK", "CONNECT", "HANDSHAKE", "CLOSED", "PROTOCOL", "IO", "NOMEM"]
|
||||
|
||||
BLOCK_SIZE = 16384
|
||||
|
||||
|
||||
class PeerConfig(C.Structure):
|
||||
_fields_ = [
|
||||
("info_hash", C.c_uint8 * 20),
|
||||
("peer_id", C.c_uint8 * 20),
|
||||
("piece_length", C.c_uint64),
|
||||
("total_size", C.c_uint64),
|
||||
("num_pieces", C.c_uint32),
|
||||
("num_slots", C.c_uint32),
|
||||
("max_pipeline", C.c_uint32),
|
||||
("request_timeout_ms", C.c_uint32),
|
||||
("recv_buffer_bytes", C.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
class BlockDesc(C.Structure):
|
||||
_fields_ = [
|
||||
("piece", C.c_uint32),
|
||||
("begin", C.c_uint32),
|
||||
("len", C.c_uint32),
|
||||
("slot", C.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
class PeerStatus(C.Structure):
|
||||
_fields_ = [
|
||||
("state", C.c_int32),
|
||||
("error", C.c_int32),
|
||||
("bytes_received", C.c_uint64),
|
||||
("blocks_received", C.c_uint64),
|
||||
("outstanding", C.c_uint32),
|
||||
("free_slots", C.c_uint32),
|
||||
("pipeline_target", C.c_uint32),
|
||||
("rate_bps", C.c_double),
|
||||
("rtt_min_ms", C.c_double),
|
||||
]
|
||||
|
||||
|
||||
def _default_lib_path() -> str:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cand = [
|
||||
os.path.join(here, "..", "build", "libtorrentpeer.so"),
|
||||
os.path.join(here, "..", "build", "lib", "libtorrentpeer.so"),
|
||||
]
|
||||
for p in cand:
|
||||
if os.path.exists(p):
|
||||
return os.path.abspath(p)
|
||||
return os.path.abspath(cand[0])
|
||||
|
||||
|
||||
def _load(lib_path: str | None) -> C.CDLL:
|
||||
lib = C.CDLL(lib_path or _default_lib_path())
|
||||
lib.peer_create.restype = C.c_void_p
|
||||
lib.peer_create.argtypes = [C.POINTER(PeerConfig)]
|
||||
lib.peer_start.restype = C.c_int
|
||||
lib.peer_start.argtypes = [C.c_void_p, C.c_char_p, C.c_uint16]
|
||||
lib.peer_set_priorities.restype = C.c_int
|
||||
lib.peer_set_priorities.argtypes = [C.c_void_p, C.POINTER(C.c_uint8), C.c_uint32]
|
||||
lib.peer_set_priority.restype = C.c_int
|
||||
lib.peer_set_priority.argtypes = [C.c_void_p, C.c_uint32, C.c_uint8]
|
||||
lib.peer_request_piece.restype = C.c_int
|
||||
lib.peer_request_piece.argtypes = [C.c_void_p, C.c_uint32]
|
||||
lib.peer_stop.restype = None
|
||||
lib.peer_stop.argtypes = [C.c_void_p]
|
||||
lib.peer_destroy.restype = None
|
||||
lib.peer_destroy.argtypes = [C.c_void_p]
|
||||
lib.peer_arena_base.restype = C.c_void_p
|
||||
lib.peer_arena_base.argtypes = [C.c_void_p]
|
||||
lib.peer_arena_bytes.restype = C.c_uint64
|
||||
lib.peer_arena_bytes.argtypes = [C.c_void_p]
|
||||
lib.peer_poll_ready.restype = C.c_uint32
|
||||
lib.peer_poll_ready.argtypes = [C.c_void_p, C.POINTER(BlockDesc), C.c_uint32]
|
||||
lib.peer_release_slot.restype = None
|
||||
lib.peer_release_slot.argtypes = [C.c_void_p, C.c_uint32]
|
||||
lib.peer_wait.restype = C.c_int
|
||||
lib.peer_wait.argtypes = [C.c_void_p, C.c_int]
|
||||
lib.peer_get_status.restype = None
|
||||
lib.peer_get_status.argtypes = [C.c_void_p, C.POINTER(PeerStatus)]
|
||||
return lib
|
||||
|
||||
|
||||
class Peer:
|
||||
"""Pythonic wrapper around one peer_handle."""
|
||||
|
||||
def __init__(self, cfg: PeerConfig, lib_path: str | None = None,
|
||||
poll_batch: int = 1024):
|
||||
self._lib = _load(lib_path)
|
||||
self._h = self._lib.peer_create(C.byref(cfg))
|
||||
if not self._h:
|
||||
raise RuntimeError("peer_create failed (bad config or OOM)")
|
||||
|
||||
base = self._lib.peer_arena_base(self._h)
|
||||
nbytes = self._lib.peer_arena_bytes(self._h)
|
||||
arena_t = (C.c_char * nbytes)
|
||||
self._arena = arena_t.from_address(base)
|
||||
# zero-copy view over the slab, as unsigned bytes for clean slicing
|
||||
self.arena = memoryview(self._arena).cast("B")
|
||||
|
||||
self._batch = poll_batch
|
||||
self._descs = (BlockDesc * poll_batch)()
|
||||
|
||||
def start(self, ip: str, port: int) -> None:
|
||||
rc = self._lib.peer_start(self._h, ip.encode(), port)
|
||||
if rc != 0:
|
||||
raise RuntimeError("peer_start failed")
|
||||
|
||||
def set_priorities(self, priorities) -> None:
|
||||
"""Set the whole per-piece priority vector (len must == num_pieces)."""
|
||||
buf = bytes(priorities)
|
||||
arr = (C.c_uint8 * len(buf)).from_buffer_copy(buf)
|
||||
if self._lib.peer_set_priorities(self._h, arr, len(buf)) != 0:
|
||||
raise ValueError("set_priorities: length must equal num_pieces")
|
||||
|
||||
def set_priority(self, piece_index: int, priority: int) -> None:
|
||||
if self._lib.peer_set_priority(self._h, piece_index, priority) != 0:
|
||||
raise ValueError(f"set_priority({piece_index}) out of range")
|
||||
|
||||
def request_piece(self, piece_index: int) -> None:
|
||||
"""Re-arm a piece for (re-)download (e.g. after a hash failure)."""
|
||||
if self._lib.peer_request_piece(self._h, piece_index) != 0:
|
||||
raise ValueError(f"request_piece({piece_index}) out of range")
|
||||
|
||||
def poll_ready(self):
|
||||
"""Return a list of BlockDesc for completed blocks (may be empty)."""
|
||||
n = self._lib.peer_poll_ready(self._h, self._descs, self._batch)
|
||||
return [self._descs[i] for i in range(n)]
|
||||
|
||||
def block_data(self, slot: int, length: int) -> memoryview:
|
||||
off = slot * BLOCK_SIZE
|
||||
return self.arena[off:off + length]
|
||||
|
||||
def release(self, slot: int) -> None:
|
||||
self._lib.peer_release_slot(self._h, slot)
|
||||
|
||||
def wait(self, timeout_ms: int) -> int:
|
||||
return self._lib.peer_wait(self._h, timeout_ms)
|
||||
|
||||
def status(self) -> PeerStatus:
|
||||
st = PeerStatus()
|
||||
self._lib.peer_get_status(self._h, C.byref(st))
|
||||
return st
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._h:
|
||||
self._lib.peer_stop(self._h)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._h:
|
||||
# Drop the memoryview before freeing the arena it points into.
|
||||
self.arena.release()
|
||||
del self._arena
|
||||
self._lib.peer_destroy(self._h)
|
||||
self._h = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
364
harness/seed_server.py
Normal file
364
harness/seed_server.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""
|
||||
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())
|
||||
543
harness/swarm_download.py
Normal file
543
harness/swarm_download.py
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
"""
|
||||
swarm_download.py - Download a real torrent from a real swarm using the engine,
|
||||
to measure how it performs against actual peers.
|
||||
|
||||
Division of labour: for .torrent files, this harness parses metainfo locally and
|
||||
uses the sibling torrent-tracker library for DHT get_peers and HTTP/UDP tracker
|
||||
announces. Magnet metadata resolution still falls back to libtorrent. Every
|
||||
discovered peer endpoint is fed to the engine, which does all the data transfer.
|
||||
Pieces are reassembled and SHA-1-verified here; the engine never hashes or
|
||||
persists anything.
|
||||
|
||||
Reality check (important for interpreting the numbers): a real public swarm
|
||||
contains unreachable peers, peers behind NAT, peers that only accept a different
|
||||
transport/encryption combination, and peers that do not actually have useful
|
||||
pieces. Those show up as "failed". The headline metric this prints is therefore
|
||||
how many discovered peers were actually usable, and the sustained rate across
|
||||
them. That is the honest "how well does it work today" answer.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python harness/swarm_download.py path/to/file.torrent
|
||||
python harness/swarm_download.py 'magnet:?xt=urn:btih:...'
|
||||
python harness/swarm_download.py file.torrent --max-peers 200 --output /tmp/out --timeout 600
|
||||
python harness/swarm_download.py file.torrent --output /tmp/out --resume
|
||||
|
||||
Pick a well-seeded torrent (e.g. a current Linux distro ISO) for a meaningful
|
||||
test; obscure or dead torrents will show few usable peers regardless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "harness"))
|
||||
|
||||
import libtorrent as lt # noqa: E402 # only used for magnet metadata fallback
|
||||
|
||||
from harness import load_metadata # noqa: E402
|
||||
from engine_ffi import (BLOCK_SIZE, Engine, EngineConfig, STATE_NAMES, # noqa: E402
|
||||
ERROR_NAMES)
|
||||
from torrent_meta import load_torrent # noqa: E402
|
||||
from tracker_ffi import DHTClient, TrackerClient # noqa: E402
|
||||
|
||||
LIB = os.path.join(ROOT, "build", "libtorrentpeer.so")
|
||||
|
||||
|
||||
def _discovery_session() -> lt.session:
|
||||
s = lt.session({
|
||||
"listen_interfaces": "0.0.0.0:0,[::]:0",
|
||||
"enable_dht": True, "enable_lsd": True,
|
||||
"enable_upnp": True, "enable_natpmp": True,
|
||||
"alert_mask": 0,
|
||||
})
|
||||
for host, port in (("router.bittorrent.com", 6881),
|
||||
("dht.transmissionbt.com", 6881),
|
||||
("router.utorrent.com", 6881)):
|
||||
try:
|
||||
s.add_dht_node((host, port))
|
||||
except Exception:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
class LibtorrentDiscovery:
|
||||
def __init__(self, handle, session):
|
||||
self.handle = handle
|
||||
self.session = session
|
||||
|
||||
def collect(self, _max_peers: int) -> set[tuple[str, int]]:
|
||||
eps = set()
|
||||
try:
|
||||
for pi in self.handle.get_peer_info():
|
||||
ip = pi.ip
|
||||
if isinstance(ip, tuple) and len(ip) == 2 and ip[1]:
|
||||
eps.add((ip[0], int(ip[1])))
|
||||
except Exception:
|
||||
pass
|
||||
return eps
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.session.remove_torrent(self.handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TrackerDiscovery:
|
||||
def __init__(self, trackers: list[str], meta, *, timeout: float,
|
||||
max_trackers: int, use_dht: bool, dht_timeout: float,
|
||||
dht_queries: int):
|
||||
self.trackers = trackers[:max_trackers] if max_trackers > 0 else trackers
|
||||
self.meta = meta
|
||||
self.timeout = timeout
|
||||
self.client = TrackerClient()
|
||||
self.dht = DHTClient() if use_dht else None
|
||||
self.dht_timeout = dht_timeout
|
||||
self.dht_queries = dht_queries
|
||||
self.dht_done = False
|
||||
self.peer_id = b"-PC0001-" + secrets.token_bytes(12)
|
||||
self.key = secrets.randbits(32)
|
||||
self.endpoints: set[tuple[str, int]] = set()
|
||||
self.index = 0
|
||||
self.next_cycle_at = 0.0
|
||||
|
||||
def collect(self, max_peers: int) -> set[tuple[str, int]]:
|
||||
now = time.time()
|
||||
if len(self.endpoints) >= max_peers:
|
||||
return set(self.endpoints)
|
||||
|
||||
if self.dht and not self.dht_done:
|
||||
self.dht_done = True
|
||||
result = self.dht.lookup(
|
||||
self.meta.info_hash,
|
||||
timeout=self.dht_timeout,
|
||||
max_queries=self.dht_queries,
|
||||
max_peers=max_peers - len(self.endpoints),
|
||||
)
|
||||
self.endpoints.update(result.peers)
|
||||
msg = (f"dht: {len(result.peers)} peers, "
|
||||
f"{result.nodes_queried} queried/{result.nodes_discovered} learned "
|
||||
f"({result.elapsed_ms:.0f} ms)")
|
||||
if result.error:
|
||||
msg += f": {result.error}"
|
||||
print(msg, flush=True)
|
||||
if len(self.endpoints) >= max_peers:
|
||||
return set(self.endpoints)
|
||||
|
||||
if self.index >= len(self.trackers):
|
||||
if now < self.next_cycle_at:
|
||||
return set(self.endpoints)
|
||||
self.index = 0
|
||||
|
||||
if not self.trackers:
|
||||
return set()
|
||||
|
||||
url = self.trackers[self.index]
|
||||
self.index += 1
|
||||
if self.index >= len(self.trackers):
|
||||
self.next_cycle_at = now + 60.0
|
||||
|
||||
result = self.client.announce(
|
||||
url, self.meta, self.peer_id, port=6881, key=self.key,
|
||||
numwant=max(1, min(200, max_peers - len(self.endpoints))),
|
||||
event="started", timeout=self.timeout)
|
||||
if result.ok:
|
||||
self.endpoints.update(result.peers)
|
||||
print(f"tracker: {url} returned {len(result.peers)} peers "
|
||||
f"({result.elapsed_ms:.0f} ms)", flush=True)
|
||||
else:
|
||||
print(f"tracker: {url} failed: {result.error}", flush=True)
|
||||
return set(self.endpoints)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_magnet(arg: str, scratch: str):
|
||||
ses = _discovery_session()
|
||||
params = lt.parse_magnet_uri(arg)
|
||||
params.save_path = scratch
|
||||
params.flags |= lt.torrent_flags.upload_mode
|
||||
h = ses.add_torrent(params)
|
||||
print("resolving magnet metadata from the swarm using libtorrent...", flush=True)
|
||||
deadline = time.time() + 120
|
||||
while time.time() < deadline and not h.status().has_metadata:
|
||||
time.sleep(0.5)
|
||||
if not h.status().has_metadata:
|
||||
raise TimeoutError("could not fetch metadata for magnet within 120s")
|
||||
tpath = os.path.join(scratch, "resolved.torrent")
|
||||
with open(tpath, "wb") as f:
|
||||
f.write(lt.bencode(lt.create_torrent(h.torrent_file()).generate()))
|
||||
return tpath, LibtorrentDiscovery(h, ses)
|
||||
|
||||
|
||||
def _completed_bytes(meta, done: bytearray) -> int:
|
||||
return sum(meta.piece_len(i) for i, is_done in enumerate(done) if is_done)
|
||||
|
||||
|
||||
def verify_existing_output(out_fh, meta) -> tuple[bytearray, int, int]:
|
||||
"""Hash pieces already present in the output file.
|
||||
|
||||
Returns (done_bitfield, done_count, verified_bytes). Only pieces whose full
|
||||
range exists and whose SHA-1 matches the torrent metadata are marked done.
|
||||
This intentionally ignores partial pieces because the engine currently only
|
||||
persists data after a whole piece has passed verification.
|
||||
"""
|
||||
done = bytearray(meta.num_pieces)
|
||||
done_count = 0
|
||||
file_size = os.fstat(out_fh.fileno()).st_size
|
||||
|
||||
for piece in range(meta.num_pieces):
|
||||
piece_len = meta.piece_len(piece)
|
||||
offset = piece * meta.piece_length
|
||||
if offset + piece_len > file_size:
|
||||
continue
|
||||
|
||||
out_fh.seek(offset)
|
||||
data = out_fh.read(piece_len)
|
||||
if len(data) != piece_len:
|
||||
continue
|
||||
if hashlib.sha1(data).digest() != meta.piece_hashes[piece]:
|
||||
continue
|
||||
|
||||
done[piece] = 1
|
||||
done_count += 1
|
||||
|
||||
return done, done_count, _completed_bytes(meta, done)
|
||||
|
||||
|
||||
class PieceAssembler:
|
||||
"""Reassemble pieces while ignoring duplicate blocks.
|
||||
|
||||
Endgame mode deliberately re-requests unfinished pieces from multiple peers.
|
||||
That means the same block may arrive more than once. Counting raw bytes would
|
||||
mark a piece complete too early, so completion is based on unique block
|
||||
offsets within each piece.
|
||||
"""
|
||||
|
||||
def __init__(self, meta, done: bytearray):
|
||||
self.meta = meta
|
||||
self.done = done
|
||||
self.buffers: dict[int, bytearray] = {}
|
||||
self.received = [0] * meta.num_pieces
|
||||
self.seen: dict[int, bytearray] = {}
|
||||
|
||||
def _block_count(self, piece: int) -> int:
|
||||
piece_len = self.meta.piece_len(piece)
|
||||
return (piece_len + BLOCK_SIZE - 1) // BLOCK_SIZE
|
||||
|
||||
def reset_piece(self, piece: int) -> int:
|
||||
previous = self.received[piece]
|
||||
self.received[piece] = 0
|
||||
self.buffers.pop(piece, None)
|
||||
self.seen.pop(piece, None)
|
||||
return previous
|
||||
|
||||
def add_block(self, piece: int, begin: int, data) -> tuple[bool, bool]:
|
||||
if self.done[piece]:
|
||||
return False, False
|
||||
if begin % BLOCK_SIZE != 0:
|
||||
raise ValueError(f"unaligned block for piece {piece}: begin={begin}")
|
||||
piece_len = self.meta.piece_len(piece)
|
||||
length = len(data)
|
||||
if begin + length > piece_len:
|
||||
raise ValueError(
|
||||
f"block overruns piece {piece}: begin={begin} len={length} "
|
||||
f"piece_len={piece_len}")
|
||||
|
||||
buf = self.buffers.get(piece)
|
||||
if buf is None:
|
||||
buf = bytearray(piece_len)
|
||||
self.buffers[piece] = buf
|
||||
seen = self.seen.get(piece)
|
||||
if seen is None:
|
||||
seen = bytearray(self._block_count(piece))
|
||||
self.seen[piece] = seen
|
||||
|
||||
block = begin // BLOCK_SIZE
|
||||
buf[begin:begin + length] = data
|
||||
if seen[block]:
|
||||
return False, self.received[piece] >= piece_len
|
||||
|
||||
seen[block] = 1
|
||||
self.received[piece] += length
|
||||
return True, self.received[piece] >= piece_len
|
||||
|
||||
def piece_bytes(self, piece: int) -> bytes:
|
||||
return bytes(self.buffers[piece])
|
||||
|
||||
def finish_piece(self, piece: int) -> None:
|
||||
self.done[piece] = 1
|
||||
self.buffers.pop(piece, None)
|
||||
self.seen.pop(piece, None)
|
||||
|
||||
|
||||
class EndgameController:
|
||||
"""Conservative piece-level endgame for slow tail pieces."""
|
||||
|
||||
def __init__(self, meta, *, min_pieces: int, peer_factor: float,
|
||||
interval: float):
|
||||
self.meta = meta
|
||||
self.min_pieces = min_pieces
|
||||
self.peer_factor = peer_factor
|
||||
self.interval = interval
|
||||
self.active = False
|
||||
self.last_rearm = 0.0
|
||||
|
||||
def maybe_rearm(self, eng: Engine, tid: int, done: bytearray,
|
||||
done_count: int, connected: int, now: float) -> None:
|
||||
remaining = self.meta.num_pieces - done_count
|
||||
if remaining <= 0:
|
||||
return
|
||||
|
||||
threshold = max(self.min_pieces,
|
||||
int(max(1, connected) * self.peer_factor))
|
||||
if remaining > threshold:
|
||||
return
|
||||
|
||||
if not self.active:
|
||||
self.active = True
|
||||
print(f"endgame: {remaining} pieces left; duplicating tail requests",
|
||||
flush=True)
|
||||
for piece, is_done in enumerate(done):
|
||||
if not is_done:
|
||||
eng.set_priority(tid, piece, 255)
|
||||
self.last_rearm = 0.0
|
||||
|
||||
if now - self.last_rearm < self.interval:
|
||||
return
|
||||
|
||||
for piece, is_done in enumerate(done):
|
||||
if not is_done:
|
||||
eng.request_piece(tid, piece)
|
||||
self.last_rearm = now
|
||||
|
||||
|
||||
def open_output(path: str, meta, resume: bool):
|
||||
if resume:
|
||||
existed = os.path.exists(path)
|
||||
out_fh = open(path, "r+b" if existed else "w+b")
|
||||
if existed:
|
||||
print("resume: verifying existing output pieces...", flush=True)
|
||||
done, done_count, verified_bytes = verify_existing_output(out_fh, meta)
|
||||
pct = 100.0 * done_count / meta.num_pieces if meta.num_pieces else 100.0
|
||||
print(f"resume: found {done_count}/{meta.num_pieces} verified pieces "
|
||||
f"({pct:4.1f}%, {verified_bytes/1e6:.1f} MB)",
|
||||
flush=True)
|
||||
else:
|
||||
done = bytearray(meta.num_pieces)
|
||||
done_count = 0
|
||||
print("resume: output file does not exist yet; starting fresh",
|
||||
flush=True)
|
||||
else:
|
||||
out_fh = open(path, "w+b")
|
||||
done = bytearray(meta.num_pieces)
|
||||
done_count = 0
|
||||
|
||||
out_fh.truncate(meta.total_size)
|
||||
return out_fh, done, done_count
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("torrent", help="path to a .torrent file or a magnet: URI")
|
||||
ap.add_argument("--max-peers", type=int, default=100,
|
||||
help="cap on peers fed to the engine (default 100)")
|
||||
ap.add_argument("--output", "-o", help="write verified data here (else discard)")
|
||||
ap.add_argument("--resume", action="store_true",
|
||||
help="resume from an existing --output file by hashing "
|
||||
"verified pieces and skipping them")
|
||||
ap.add_argument("--timeout", type=float, default=600.0,
|
||||
help="overall stall timeout in seconds")
|
||||
ap.add_argument("--tracker-timeout", type=float, default=4.0,
|
||||
help="per-tracker announce timeout for direct tracker "
|
||||
"discovery (default 4)")
|
||||
ap.add_argument("--max-trackers", type=int, default=16,
|
||||
help="maximum trackers to announce to from a .torrent "
|
||||
"(0 = all, default 16)")
|
||||
ap.add_argument("--no-dht", action="store_true",
|
||||
help="disable DHT get_peers discovery for .torrent files")
|
||||
ap.add_argument("--dht-timeout", type=float, default=6.0,
|
||||
help="total DHT lookup budget in seconds (default 6)")
|
||||
ap.add_argument("--dht-queries", type=int, default=32,
|
||||
help="maximum DHT nodes to query per torrent (default 32)")
|
||||
ap.add_argument("--lib", default=LIB)
|
||||
ap.add_argument("--loops", type=int, default=0, help="engine loops (0=auto)")
|
||||
ap.add_argument("--encryption", type=int, default=1, choices=[0, 1, 2],
|
||||
help="0=plaintext, 1=MSE offer RC4+plaintext (default), "
|
||||
"2=MSE require RC4. Encryption reaches far more of a "
|
||||
"real swarm.")
|
||||
ap.add_argument("--utp", type=int, default=0, choices=[0, 1],
|
||||
help="0=TCP (default), 1=µTP/UDP. A swarm has a mix; this "
|
||||
"selects which transport the engine dials with.")
|
||||
ap.add_argument("--no-endgame", action="store_true",
|
||||
help="disable duplicate tail-piece requests")
|
||||
ap.add_argument("--endgame-min-pieces", type=int, default=16,
|
||||
help="enter endgame when remaining pieces are at or below "
|
||||
"this count, also scaled by connected peers "
|
||||
"(default 16)")
|
||||
ap.add_argument("--endgame-peer-factor", type=float, default=2.0,
|
||||
help="also enter endgame below peers*factor remaining "
|
||||
"pieces (default 2.0)")
|
||||
ap.add_argument("--endgame-interval", type=float, default=3.0,
|
||||
help="seconds between tail-piece re-arms in endgame "
|
||||
"(default 3.0)")
|
||||
args = ap.parse_args()
|
||||
if args.resume and not args.output:
|
||||
ap.error("--resume requires --output")
|
||||
|
||||
scratch = tempfile.mkdtemp(prefix="swarm_dl_")
|
||||
if args.torrent.startswith("magnet:"):
|
||||
tpath, discovery = _resolve_magnet(args.torrent, scratch)
|
||||
else:
|
||||
torrent_file = load_torrent(args.torrent)
|
||||
tpath = args.torrent
|
||||
discovery = TrackerDiscovery(
|
||||
torrent_file.trackers,
|
||||
torrent_file.metadata,
|
||||
timeout=args.tracker_timeout,
|
||||
max_trackers=args.max_trackers,
|
||||
use_dht=not args.no_dht,
|
||||
dht_timeout=args.dht_timeout,
|
||||
dht_queries=args.dht_queries,
|
||||
)
|
||||
meta = load_metadata(tpath)
|
||||
print(f"torrent: {meta.name} {meta.total_size/1e6:.1f} MB "
|
||||
f"{meta.num_pieces} pieces x {meta.piece_length}", flush=True)
|
||||
|
||||
out_fh = None
|
||||
done = bytearray(meta.num_pieces)
|
||||
done_count = 0
|
||||
if args.output:
|
||||
out_fh, done, done_count = open_output(args.output, meta, args.resume)
|
||||
|
||||
cfg = EngineConfig(loop_count=args.loops, slots_per_loop=4096,
|
||||
max_pipeline=1024, encryption=args.encryption,
|
||||
utp=args.utp)
|
||||
print(f"transport: {'µTP' if args.utp else 'TCP'}, "
|
||||
f"encryption={['off','offer','require'][args.encryption]}", flush=True)
|
||||
eng = Engine(cfg, lib_path=args.lib)
|
||||
tid = eng.add_torrent(meta.info_hash, b"-PC0001-" + secrets.token_bytes(12),
|
||||
meta.piece_length, meta.total_size, meta.num_pieces)
|
||||
eng.set_priorities(tid, [0 if done[i] else 1
|
||||
for i in range(meta.num_pieces)])
|
||||
|
||||
assembler = PieceAssembler(meta, done)
|
||||
endgame = None if args.no_endgame else EndgameController(
|
||||
meta,
|
||||
min_pieces=max(1, args.endgame_min_pieces),
|
||||
peer_factor=max(1.0, args.endgame_peer_factor),
|
||||
interval=max(0.5, args.endgame_interval),
|
||||
)
|
||||
added: set = set()
|
||||
peak_connected = 0
|
||||
useful_bytes = _completed_bytes(meta, done)
|
||||
|
||||
t0 = time.time()
|
||||
deadline = t0 + args.timeout
|
||||
last_print = 0.0
|
||||
last_useful_bytes = useful_bytes
|
||||
last_progress_t = t0
|
||||
|
||||
try:
|
||||
while done_count < meta.num_pieces:
|
||||
# Feed any newly-discovered peers to the engine, up to the cap.
|
||||
if len(added) < args.max_peers:
|
||||
for ip, port in discovery.collect(args.max_peers):
|
||||
if (ip, port) in added:
|
||||
continue
|
||||
added.add((ip, port))
|
||||
try:
|
||||
eng.add_peer(tid, ip, port)
|
||||
except Exception:
|
||||
pass
|
||||
if len(added) >= args.max_peers:
|
||||
break
|
||||
|
||||
st = eng.status(tid)
|
||||
peak_connected = max(peak_connected, st.peers_connected)
|
||||
now = time.time()
|
||||
if endgame:
|
||||
endgame.maybe_rearm(
|
||||
eng, tid, done, done_count, st.peers_connected, now)
|
||||
|
||||
descs = eng.poll_ready()
|
||||
if not descs:
|
||||
eng.wait(200)
|
||||
now = time.time()
|
||||
if useful_bytes != last_useful_bytes:
|
||||
last_useful_bytes = useful_bytes
|
||||
last_progress_t = now
|
||||
if now - last_print >= 1.0:
|
||||
last_print = now
|
||||
pct = 100.0 * done_count / meta.num_pieces
|
||||
print(f" {done_count}/{meta.num_pieces} pieces ({pct:4.1f}%) "
|
||||
f"{st.rate_bps/1e6:6.1f} MB/s "
|
||||
f"peers {st.peers_connected} up / {st.peers_failed} failed "
|
||||
f"/ {len(added)} tried outstanding={st.outstanding}",
|
||||
flush=True)
|
||||
if now > deadline or (now - last_progress_t) > args.timeout:
|
||||
print("stalled; giving up", flush=True)
|
||||
break
|
||||
continue
|
||||
|
||||
for x in descs:
|
||||
if done[x.piece]:
|
||||
# A block can arrive after the piece was completed and
|
||||
# de-prioritized because it was already in flight. Drop it.
|
||||
eng.release(x.loop, x.slot)
|
||||
continue
|
||||
|
||||
block = bytes(eng.block_data(x.loop, x.slot, x.len))
|
||||
eng.release(x.loop, x.slot)
|
||||
added_unique, complete = assembler.add_block(
|
||||
x.piece, x.begin, block)
|
||||
if added_unique:
|
||||
useful_bytes += x.len
|
||||
if complete:
|
||||
piece_data = assembler.piece_bytes(x.piece)
|
||||
if hashlib.sha1(piece_data).digest() != meta.piece_hashes[x.piece]:
|
||||
# Corrupt/garbage block from a misbehaving peer: re-arm.
|
||||
useful_bytes -= assembler.reset_piece(x.piece)
|
||||
eng.request_piece(tid, x.piece)
|
||||
continue
|
||||
done[x.piece] = 1
|
||||
done_count += 1
|
||||
eng.set_priority(tid, x.piece, 0)
|
||||
if out_fh:
|
||||
out_fh.seek(x.piece * meta.piece_length)
|
||||
out_fh.write(piece_data)
|
||||
out_fh.flush()
|
||||
assembler.finish_piece(x.piece) # free as we go
|
||||
|
||||
dt = time.time() - t0
|
||||
st = eng.status(tid)
|
||||
mb = _completed_bytes(meta, done) / 1e6
|
||||
print("-" * 70)
|
||||
print(f"downloaded {done_count}/{meta.num_pieces} pieces "
|
||||
f"({mb:.1f} MB) in {dt:.1f}s = {mb/dt:.1f} MB/s" if dt > 0 else "")
|
||||
print(f"peers: {len(added)} discovered+tried, "
|
||||
f"{peak_connected} usable at peak, "
|
||||
f"{st.peers_failed} failed (likely incompatible transport/"
|
||||
f"encryption or unreachable)")
|
||||
if done_count < meta.num_pieces:
|
||||
print(f"engine state: {STATE_NAMES[st.state]} "
|
||||
f"err={ERROR_NAMES[st.error]}")
|
||||
return 0 if done_count == meta.num_pieces else 2
|
||||
finally:
|
||||
if out_fh:
|
||||
out_fh.close()
|
||||
eng.close()
|
||||
discovery.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
223
harness/torrent_meta.py
Normal file
223
harness/torrent_meta.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BencodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BDecoder:
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.info_span: tuple[int, int] | None = None
|
||||
|
||||
def parse(self) -> Any:
|
||||
value, pos = self._value(0, top=True)
|
||||
if pos != len(self.data):
|
||||
raise BencodeError(f"trailing data at byte {pos}")
|
||||
return value
|
||||
|
||||
def _value(self, pos: int, *, top: bool = False) -> tuple[Any, int]:
|
||||
if pos >= len(self.data):
|
||||
raise BencodeError("unexpected end of bencode")
|
||||
c = self.data[pos]
|
||||
if c == ord("i"):
|
||||
return self._int(pos)
|
||||
if c == ord("l"):
|
||||
return self._list(pos)
|
||||
if c == ord("d"):
|
||||
return self._dict(pos, top=top)
|
||||
if ord("0") <= c <= ord("9"):
|
||||
return self._bytes(pos)
|
||||
raise BencodeError(f"invalid bencode byte {c!r} at {pos}")
|
||||
|
||||
def _int(self, pos: int) -> tuple[int, int]:
|
||||
end = self.data.find(b"e", pos)
|
||||
if end < 0:
|
||||
raise BencodeError("unterminated integer")
|
||||
raw = self.data[pos + 1:end]
|
||||
if not raw:
|
||||
raise BencodeError("empty integer")
|
||||
return int(raw), end + 1
|
||||
|
||||
def _bytes(self, pos: int) -> tuple[bytes, int]:
|
||||
colon = self.data.find(b":", pos)
|
||||
if colon < 0:
|
||||
raise BencodeError("unterminated byte string length")
|
||||
n = int(self.data[pos:colon])
|
||||
start = colon + 1
|
||||
end = start + n
|
||||
if end > len(self.data):
|
||||
raise BencodeError("byte string exceeds input")
|
||||
return self.data[start:end], end
|
||||
|
||||
def _list(self, pos: int) -> tuple[list[Any], int]:
|
||||
out: list[Any] = []
|
||||
pos += 1
|
||||
while pos < len(self.data) and self.data[pos] != ord("e"):
|
||||
value, pos = self._value(pos)
|
||||
out.append(value)
|
||||
if pos >= len(self.data):
|
||||
raise BencodeError("unterminated list")
|
||||
return out, pos + 1
|
||||
|
||||
def _dict(self, pos: int, *, top: bool = False) -> tuple[dict[bytes, Any], int]:
|
||||
out: dict[bytes, Any] = {}
|
||||
pos += 1
|
||||
while pos < len(self.data) and self.data[pos] != ord("e"):
|
||||
key, pos = self._bytes(pos)
|
||||
value_start = pos
|
||||
value, pos = self._value(pos)
|
||||
if top and key == b"info":
|
||||
self.info_span = (value_start, pos)
|
||||
out[key] = value
|
||||
if pos >= len(self.data):
|
||||
raise BencodeError("unterminated dict")
|
||||
return out, pos + 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
info_hash: bytes
|
||||
piece_length: int
|
||||
total_size: int
|
||||
num_pieces: int
|
||||
piece_hashes: list[bytes]
|
||||
name: str
|
||||
|
||||
def piece_len(self, index: int) -> int:
|
||||
if index + 1 == self.num_pieces:
|
||||
return self.total_size - index * self.piece_length
|
||||
return self.piece_length
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentFile:
|
||||
path: str
|
||||
raw: bytes
|
||||
metainfo: dict[bytes, Any]
|
||||
info: dict[bytes, Any]
|
||||
info_raw: bytes
|
||||
metadata: Metadata
|
||||
trackers: list[str]
|
||||
files: list[tuple[str, int]]
|
||||
|
||||
|
||||
def _text(value: Any, default: str = "") -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace")
|
||||
return default
|
||||
|
||||
|
||||
def _file_tree_size(node: Any) -> int:
|
||||
if not isinstance(node, dict):
|
||||
return 0
|
||||
total = 0
|
||||
file_marker = node.get(b"")
|
||||
if isinstance(file_marker, dict):
|
||||
total += int(file_marker.get(b"length", 0))
|
||||
for key, child in node.items():
|
||||
if key != b"":
|
||||
total += _file_tree_size(child)
|
||||
return total
|
||||
|
||||
|
||||
def _total_size(info: dict[bytes, Any]) -> int:
|
||||
if b"length" in info:
|
||||
return int(info[b"length"])
|
||||
if b"files" in info:
|
||||
return sum(int(f.get(b"length", 0)) for f in info[b"files"])
|
||||
if b"file tree" in info:
|
||||
return _file_tree_size(info[b"file tree"])
|
||||
return 0
|
||||
|
||||
|
||||
def _trackers(meta: dict[bytes, Any]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
announce = meta.get(b"announce")
|
||||
if isinstance(announce, bytes):
|
||||
urls.append(_text(announce))
|
||||
tiers = meta.get(b"announce-list")
|
||||
if isinstance(tiers, list):
|
||||
for tier in tiers:
|
||||
if not isinstance(tier, list):
|
||||
continue
|
||||
for item in tier:
|
||||
if isinstance(item, bytes):
|
||||
urls.append(_text(item))
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for url in urls:
|
||||
if url and url not in seen:
|
||||
seen.add(url)
|
||||
out.append(url)
|
||||
return out
|
||||
|
||||
|
||||
def _path_text(parts: list[Any]) -> str:
|
||||
decoded = []
|
||||
for part in parts:
|
||||
if not isinstance(part, bytes):
|
||||
raise BencodeError("file path component is not bytes")
|
||||
decoded.append(part.decode("utf-8", "replace"))
|
||||
return os.path.join(*decoded) if decoded else ""
|
||||
|
||||
|
||||
def _files(info: dict[bytes, Any]) -> list[tuple[str, int]]:
|
||||
name = _text(info.get(b"name"), "")
|
||||
if b"length" in info:
|
||||
return [(name, int(info[b"length"]))]
|
||||
files = info.get(b"files")
|
||||
if isinstance(files, list):
|
||||
out = []
|
||||
for entry in files:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = entry.get(b"path")
|
||||
if not isinstance(path, list):
|
||||
continue
|
||||
out.append((os.path.join(name, _path_text(path)),
|
||||
int(entry.get(b"length", 0))))
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def load_torrent(path: str) -> TorrentFile:
|
||||
raw = open(path, "rb").read()
|
||||
dec = BDecoder(raw)
|
||||
meta = dec.parse()
|
||||
if not isinstance(meta, dict) or dec.info_span is None:
|
||||
raise BencodeError("metainfo does not contain a top-level info dict")
|
||||
info = meta.get(b"info")
|
||||
if not isinstance(info, dict):
|
||||
raise BencodeError("metainfo info value is not a dict")
|
||||
info_raw = raw[dec.info_span[0]:dec.info_span[1]]
|
||||
|
||||
pieces = info.get(b"pieces")
|
||||
if not isinstance(pieces, bytes) or len(pieces) % 20 != 0:
|
||||
raise BencodeError("only v1/hybrid torrents with a valid pieces string are supported")
|
||||
piece_length = int(info.get(b"piece length", 0))
|
||||
if piece_length <= 0:
|
||||
raise BencodeError("missing or invalid piece length")
|
||||
piece_hashes = [pieces[i:i + 20] for i in range(0, len(pieces), 20)]
|
||||
total_size = _total_size(info)
|
||||
if total_size <= 0:
|
||||
raise BencodeError("missing torrent payload size")
|
||||
metadata = Metadata(
|
||||
info_hash=hashlib.sha1(info_raw).digest(),
|
||||
piece_length=piece_length,
|
||||
total_size=total_size,
|
||||
num_pieces=len(piece_hashes),
|
||||
piece_hashes=piece_hashes,
|
||||
name=_text(info.get(b"name"), os.path.basename(path)),
|
||||
)
|
||||
return TorrentFile(path, raw, meta, info, info_raw, metadata, _trackers(meta),
|
||||
_files(info))
|
||||
|
||||
|
||||
def load_metadata(path: str) -> Metadata:
|
||||
return load_torrent(path).metadata
|
||||
498
harness/tracker_ffi.py
Normal file
498
harness/tracker_ffi.py
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ctypes as C
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
TRACKER_OK = 0
|
||||
TRACKER_EVENT_NONE = 0
|
||||
TRACKER_EVENT_COMPLETED = 1
|
||||
TRACKER_EVENT_STARTED = 2
|
||||
TRACKER_EVENT_STOPPED = 3
|
||||
TRACKER_ADDR_IPV4 = 4
|
||||
TRACKER_ADDR_IPV6 = 6
|
||||
TRACKER_MAX_PEERS = 256
|
||||
TRACKER_MAX_URL_DATA = 512
|
||||
DHT_MAX_TRANSACTION = 16
|
||||
DHT_MAX_TOKEN = 64
|
||||
DHT_MAX_NODES = 256
|
||||
DHT_MAX_ERROR = 128
|
||||
DHT_MSG_QUERY = 1
|
||||
DHT_MSG_RESPONSE = 2
|
||||
DHT_QUERY_GET_PEERS = 3
|
||||
DEFAULT_DHT_BOOTSTRAP = (
|
||||
("router.bittorrent.com", 6881),
|
||||
("dht.transmissionbt.com", 6881),
|
||||
("router.utorrent.com", 6881),
|
||||
)
|
||||
|
||||
|
||||
class TrackerPeer(C.Structure):
|
||||
_fields_ = [
|
||||
("family", C.c_uint8),
|
||||
("addr", C.c_uint8 * 16),
|
||||
("port", C.c_uint16),
|
||||
("peer_id", C.c_uint8 * 20),
|
||||
("has_peer_id", C.c_uint8),
|
||||
]
|
||||
|
||||
|
||||
class TrackerAnnounceRequest(C.Structure):
|
||||
_fields_ = [
|
||||
("info_hash", C.c_uint8 * 20),
|
||||
("peer_id", C.c_uint8 * 20),
|
||||
("port", C.c_uint16),
|
||||
("uploaded", C.c_uint64),
|
||||
("downloaded", C.c_uint64),
|
||||
("left", C.c_uint64),
|
||||
("numwant", C.c_int32),
|
||||
("key", C.c_uint32),
|
||||
("ip4", C.c_uint32),
|
||||
("event", C.c_int),
|
||||
("compact", C.c_uint8),
|
||||
("no_peer_id", C.c_uint8),
|
||||
("has_key", C.c_uint8),
|
||||
("has_ip4", C.c_uint8),
|
||||
("ip", C.c_char * 64),
|
||||
("tracker_id", C.c_char * 128),
|
||||
("url_data", C.c_char * TRACKER_MAX_URL_DATA),
|
||||
]
|
||||
|
||||
|
||||
class TrackerAnnounceResponse(C.Structure):
|
||||
_fields_ = [
|
||||
("interval", C.c_uint32),
|
||||
("min_interval", C.c_uint32),
|
||||
("complete", C.c_uint32),
|
||||
("incomplete", C.c_uint32),
|
||||
("tracker_id", C.c_char_p),
|
||||
("peers", C.POINTER(TrackerPeer)),
|
||||
("peer_count", C.c_size_t),
|
||||
("compact", C.c_uint8),
|
||||
]
|
||||
|
||||
|
||||
class DHTNode(C.Structure):
|
||||
_fields_ = [
|
||||
("id", C.c_uint8 * 20),
|
||||
("family", C.c_uint8),
|
||||
("addr", C.c_uint8 * 16),
|
||||
("port", C.c_uint16),
|
||||
]
|
||||
|
||||
|
||||
class DHTMessage(C.Structure):
|
||||
_fields_ = [
|
||||
("type", C.c_int),
|
||||
("query", C.c_int),
|
||||
("transaction", C.c_uint8 * DHT_MAX_TRANSACTION),
|
||||
("transaction_len", C.c_size_t),
|
||||
("id", C.c_uint8 * 20),
|
||||
("target", C.c_uint8 * 20),
|
||||
("info_hash", C.c_uint8 * 20),
|
||||
("port", C.c_uint16),
|
||||
("implied_port", C.c_uint8),
|
||||
("want_ipv4", C.c_uint8),
|
||||
("want_ipv6", C.c_uint8),
|
||||
("token", C.c_uint8 * DHT_MAX_TOKEN),
|
||||
("token_len", C.c_size_t),
|
||||
("nodes", DHTNode * DHT_MAX_NODES),
|
||||
("node_count", C.c_size_t),
|
||||
("peers", TrackerPeer * TRACKER_MAX_PEERS),
|
||||
("peer_count", C.c_size_t),
|
||||
("error_code", C.c_int),
|
||||
("error_message", C.c_char * DHT_MAX_ERROR),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerResult:
|
||||
tracker: str
|
||||
ok: bool
|
||||
protocol: str
|
||||
peers: list[tuple[str, int]]
|
||||
interval: int = 0
|
||||
complete: int = 0
|
||||
incomplete: int = 0
|
||||
error: str = ""
|
||||
elapsed_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DHTResult:
|
||||
peers: list[tuple[str, int]]
|
||||
nodes_queried: int
|
||||
nodes_discovered: int
|
||||
elapsed_ms: float
|
||||
error: str = ""
|
||||
|
||||
|
||||
def _default_lib_path() -> str:
|
||||
env = os.environ.get("TORRENT_TRACKER_LIB")
|
||||
if env:
|
||||
return env
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
candidates = [
|
||||
os.path.join(root, "..", "torrent-tracker", "build", "libtorrenttracker.so"),
|
||||
os.path.join(root, "build", "libtorrenttracker.so"),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return os.path.abspath(path)
|
||||
return os.path.abspath(candidates[0])
|
||||
|
||||
|
||||
def _load(lib_path: str | None = None) -> C.CDLL:
|
||||
lib = C.CDLL(lib_path or _default_lib_path())
|
||||
lib.tracker_http_write_announce_query.restype = C.c_int
|
||||
lib.tracker_http_write_announce_query.argtypes = [
|
||||
C.POINTER(TrackerAnnounceRequest), C.c_char_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
lib.tracker_http_parse_announce_response.restype = C.c_int
|
||||
lib.tracker_http_parse_announce_response.argtypes = [
|
||||
C.c_void_p, C.c_size_t, C.POINTER(TrackerPeer), C.c_size_t,
|
||||
C.POINTER(TrackerAnnounceResponse)]
|
||||
lib.tracker_udp_write_connect_request.restype = C.c_int
|
||||
lib.tracker_udp_write_connect_request.argtypes = [
|
||||
C.c_uint32, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
lib.tracker_udp_parse_connect_response.restype = C.c_int
|
||||
lib.tracker_udp_parse_connect_response.argtypes = [
|
||||
C.c_void_p, C.c_size_t, C.c_uint32, C.POINTER(C.c_uint64)]
|
||||
lib.tracker_udp_write_announce_request.restype = C.c_int
|
||||
lib.tracker_udp_write_announce_request.argtypes = [
|
||||
C.c_uint64, C.c_uint32, C.POINTER(TrackerAnnounceRequest),
|
||||
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
lib.tracker_udp_parse_announce_response.restype = C.c_int
|
||||
lib.tracker_udp_parse_announce_response.argtypes = [
|
||||
C.c_void_p, C.c_size_t, C.c_uint32, C.c_int, C.POINTER(TrackerPeer),
|
||||
C.c_size_t, C.POINTER(TrackerAnnounceResponse)]
|
||||
lib.dht_write_get_peers_query.restype = C.c_int
|
||||
lib.dht_write_get_peers_query.argtypes = [
|
||||
C.c_void_p, C.c_size_t, C.c_void_p, C.c_void_p, C.c_uint8, C.c_uint8,
|
||||
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
lib.dht_parse_message.restype = C.c_int
|
||||
lib.dht_parse_message.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(DHTMessage)]
|
||||
lib.dht_write_peers_response.restype = C.c_int
|
||||
lib.dht_write_peers_response.argtypes = [
|
||||
C.c_void_p, C.c_size_t, C.c_void_p, C.c_void_p, C.c_size_t,
|
||||
C.POINTER(TrackerPeer), C.c_size_t, C.c_void_p, C.c_size_t,
|
||||
C.POINTER(C.c_size_t)]
|
||||
return lib
|
||||
|
||||
|
||||
class TrackerClient:
|
||||
def __init__(self, lib_path: str | None = None):
|
||||
self.lib = _load(lib_path)
|
||||
|
||||
def _request(self, meta, peer_id: bytes, port: int, key: int, numwant: int,
|
||||
event: str) -> TrackerAnnounceRequest:
|
||||
req = TrackerAnnounceRequest()
|
||||
C.memset(C.byref(req), 0, C.sizeof(req))
|
||||
req.info_hash[:] = meta.info_hash
|
||||
req.peer_id[:] = peer_id
|
||||
req.port = port
|
||||
req.left = meta.total_size
|
||||
req.numwant = numwant
|
||||
req.key = key
|
||||
req.has_key = 1
|
||||
req.compact = 1
|
||||
req.no_peer_id = 1
|
||||
req.event = {
|
||||
"completed": TRACKER_EVENT_COMPLETED,
|
||||
"started": TRACKER_EVENT_STARTED,
|
||||
"stopped": TRACKER_EVENT_STOPPED,
|
||||
}.get(event, TRACKER_EVENT_NONE)
|
||||
return req
|
||||
|
||||
@staticmethod
|
||||
def _peers(peers, count: int) -> list[tuple[str, int]]:
|
||||
out: list[tuple[str, int]] = []
|
||||
for i in range(count):
|
||||
p = peers[i]
|
||||
if p.family == TRACKER_ADDR_IPV4:
|
||||
host = socket.inet_ntop(socket.AF_INET, bytes(p.addr[:4]))
|
||||
elif p.family == TRACKER_ADDR_IPV6:
|
||||
host = socket.inet_ntop(socket.AF_INET6, bytes(p.addr[:16]))
|
||||
else:
|
||||
continue
|
||||
out.append((host, int(p.port)))
|
||||
return out
|
||||
|
||||
def announce_http(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
||||
numwant: int, event: str, timeout: float) -> TrackerResult:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
req = self._request(meta, peer_id, port, key, numwant, event)
|
||||
query = C.create_string_buffer(2048)
|
||||
written = C.c_size_t()
|
||||
rc = self.lib.tracker_http_write_announce_query(
|
||||
C.byref(req), query, C.sizeof(query), C.byref(written))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"tracker_http_write_announce_query failed: {rc}")
|
||||
|
||||
parts = urlsplit(url)
|
||||
q = parts.query
|
||||
suffix = query.value.decode("ascii")
|
||||
q = f"{q}&{suffix}" if q else suffix
|
||||
announce_url = urlunsplit((parts.scheme, parts.netloc, parts.path, q,
|
||||
parts.fragment))
|
||||
request = Request(announce_url,
|
||||
headers={"User-Agent": "torrent-peer/0.1"})
|
||||
ctx = ssl.create_default_context()
|
||||
with urlopen(request, timeout=timeout, context=ctx) as resp:
|
||||
raw = resp.read(2 * 1024 * 1024)
|
||||
|
||||
raw_buf = C.create_string_buffer(raw, len(raw))
|
||||
out_peers = (TrackerPeer * TRACKER_MAX_PEERS)()
|
||||
parsed = TrackerAnnounceResponse()
|
||||
rc = self.lib.tracker_http_parse_announce_response(
|
||||
raw_buf, len(raw), out_peers, TRACKER_MAX_PEERS, C.byref(parsed))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"tracker_http_parse_announce_response failed: {rc}")
|
||||
return TrackerResult(
|
||||
tracker=url,
|
||||
ok=True,
|
||||
protocol=parts.scheme,
|
||||
peers=self._peers(out_peers, parsed.peer_count),
|
||||
interval=int(parsed.interval),
|
||||
complete=int(parsed.complete),
|
||||
incomplete=int(parsed.incomplete),
|
||||
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
return TrackerResult(url, False, "http", [], error=str(exc),
|
||||
elapsed_ms=(time.monotonic() - start) * 1000.0)
|
||||
|
||||
@staticmethod
|
||||
def _url_data(url: str) -> bytes:
|
||||
parts = urlsplit(url)
|
||||
data = (parts.path or "").encode("utf-8")
|
||||
if parts.query:
|
||||
data += b"?" + parts.query.encode("utf-8")
|
||||
return data[:TRACKER_MAX_URL_DATA - 1]
|
||||
|
||||
@staticmethod
|
||||
def _roundtrip(sock: socket.socket, packet: bytes, txid: int,
|
||||
timeout: float) -> bytes:
|
||||
deadline = time.monotonic() + timeout
|
||||
delay = min(timeout, 1.0)
|
||||
while True:
|
||||
sock.send(packet)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("UDP tracker timed out")
|
||||
sock.settimeout(min(delay, remaining))
|
||||
try:
|
||||
raw = sock.recv(65535)
|
||||
except socket.timeout:
|
||||
delay = min(delay * 2.0, 8.0)
|
||||
continue
|
||||
if len(raw) >= 8 and struct.unpack_from("!I", raw, 4)[0] == txid:
|
||||
return raw
|
||||
|
||||
def announce_udp(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
||||
numwant: int, event: str, timeout: float) -> TrackerResult:
|
||||
start = time.monotonic()
|
||||
parts = urlsplit(url)
|
||||
if not parts.hostname:
|
||||
return TrackerResult(url, False, "udp", [], error="missing UDP tracker host")
|
||||
tracker_port = parts.port or 80
|
||||
try:
|
||||
infos = socket.getaddrinfo(parts.hostname, tracker_port, 0,
|
||||
socket.SOCK_DGRAM)
|
||||
last_error: Exception | None = None
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
try:
|
||||
with socket.socket(family, socktype, proto) as sock:
|
||||
sock.connect(sockaddr)
|
||||
|
||||
txid = random.getrandbits(32)
|
||||
buf = C.create_string_buffer(2048)
|
||||
written = C.c_size_t()
|
||||
rc = self.lib.tracker_udp_write_connect_request(
|
||||
txid, buf, C.sizeof(buf), C.byref(written))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"connect request failed: {rc}")
|
||||
raw = self._roundtrip(sock, buf.raw[:written.value], txid, timeout)
|
||||
conn_id = C.c_uint64()
|
||||
raw_buf = C.create_string_buffer(raw, len(raw))
|
||||
rc = self.lib.tracker_udp_parse_connect_response(
|
||||
raw_buf, len(raw), txid, C.byref(conn_id))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"connect response failed: {rc}")
|
||||
|
||||
req = self._request(meta, peer_id, port, key, numwant, event)
|
||||
url_data = self._url_data(url)
|
||||
if url_data:
|
||||
req.url_data = url_data
|
||||
txid = random.getrandbits(32)
|
||||
rc = self.lib.tracker_udp_write_announce_request(
|
||||
conn_id.value, txid, C.byref(req), buf, C.sizeof(buf),
|
||||
C.byref(written))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"announce request failed: {rc}")
|
||||
raw = self._roundtrip(sock, buf.raw[:written.value], txid, timeout)
|
||||
raw_buf = C.create_string_buffer(raw, len(raw))
|
||||
out_peers = (TrackerPeer * TRACKER_MAX_PEERS)()
|
||||
parsed = TrackerAnnounceResponse()
|
||||
tracker_family = (TRACKER_ADDR_IPV6 if family == socket.AF_INET6
|
||||
else TRACKER_ADDR_IPV4)
|
||||
rc = self.lib.tracker_udp_parse_announce_response(
|
||||
raw_buf, len(raw), txid, tracker_family, out_peers,
|
||||
TRACKER_MAX_PEERS, C.byref(parsed))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"announce response failed: {rc}")
|
||||
return TrackerResult(
|
||||
tracker=url,
|
||||
ok=True,
|
||||
protocol="udp",
|
||||
peers=self._peers(out_peers, parsed.peer_count),
|
||||
interval=int(parsed.interval),
|
||||
complete=int(parsed.complete),
|
||||
incomplete=int(parsed.incomplete),
|
||||
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
raise RuntimeError(str(last_error or "no usable tracker address"))
|
||||
except Exception as exc:
|
||||
return TrackerResult(url, False, "udp", [], error=str(exc),
|
||||
elapsed_ms=(time.monotonic() - start) * 1000.0)
|
||||
|
||||
def announce(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
||||
numwant: int = 50, event: str = "started",
|
||||
timeout: float = 8.0) -> TrackerResult:
|
||||
scheme = urlsplit(url).scheme.lower()
|
||||
if scheme in ("http", "https"):
|
||||
return self.announce_http(url, meta, peer_id, port, key, numwant,
|
||||
event, timeout)
|
||||
if scheme == "udp":
|
||||
return self.announce_udp(url, meta, peer_id, port, key, numwant,
|
||||
event, timeout)
|
||||
return TrackerResult(url, False, scheme or "unknown", [],
|
||||
error=f"unsupported tracker scheme {scheme!r}")
|
||||
|
||||
|
||||
class DHTClient:
|
||||
def __init__(self, lib_path: str | None = None,
|
||||
bootstrap: tuple[tuple[str, int], ...] = DEFAULT_DHT_BOOTSTRAP):
|
||||
self.lib = _load(lib_path)
|
||||
self.bootstrap = bootstrap
|
||||
self.node_id = os.urandom(20)
|
||||
self._tx = random.randrange(1, 0xffff)
|
||||
|
||||
@staticmethod
|
||||
def _peer_endpoint(peer: TrackerPeer) -> tuple[str, int] | None:
|
||||
if peer.family == TRACKER_ADDR_IPV4:
|
||||
host = socket.inet_ntop(socket.AF_INET, bytes(peer.addr[:4]))
|
||||
elif peer.family == TRACKER_ADDR_IPV6:
|
||||
host = socket.inet_ntop(socket.AF_INET6, bytes(peer.addr[:16]))
|
||||
else:
|
||||
return None
|
||||
return host, int(peer.port)
|
||||
|
||||
@staticmethod
|
||||
def _node_endpoint(node: DHTNode) -> tuple[str, int] | None:
|
||||
if node.family == TRACKER_ADDR_IPV4:
|
||||
host = socket.inet_ntop(socket.AF_INET, bytes(node.addr[:4]))
|
||||
elif node.family == TRACKER_ADDR_IPV6:
|
||||
host = socket.inet_ntop(socket.AF_INET6, bytes(node.addr[:16]))
|
||||
else:
|
||||
return None
|
||||
return host, int(node.port)
|
||||
|
||||
def _next_tx(self) -> bytes:
|
||||
self._tx = (self._tx + 1) & 0xffff
|
||||
return self._tx.to_bytes(2, "big")
|
||||
|
||||
def _get_peers_packet(self, info_hash: bytes, tx: bytes) -> bytes:
|
||||
buf = C.create_string_buffer(2048)
|
||||
written = C.c_size_t()
|
||||
tx_buf = C.create_string_buffer(tx, len(tx))
|
||||
id_buf = C.create_string_buffer(self.node_id, len(self.node_id))
|
||||
hash_buf = C.create_string_buffer(info_hash, len(info_hash))
|
||||
rc = self.lib.dht_write_get_peers_query(
|
||||
tx_buf, len(tx), id_buf, hash_buf, 1, 1, buf, C.sizeof(buf),
|
||||
C.byref(written))
|
||||
if rc != TRACKER_OK:
|
||||
raise RuntimeError(f"dht_write_get_peers_query failed: {rc}")
|
||||
return buf.raw[:written.value]
|
||||
|
||||
def lookup(self, info_hash: bytes, *, timeout: float = 6.0,
|
||||
max_queries: int = 32, max_peers: int = 100) -> DHTResult:
|
||||
start = time.monotonic()
|
||||
deadline = start + timeout
|
||||
peers: set[tuple[str, int]] = set()
|
||||
queue: list[tuple[str, int]] = list(self.bootstrap)
|
||||
seen_nodes: set[tuple[str, int]] = set()
|
||||
queried = 0
|
||||
discovered = 0
|
||||
last_error = ""
|
||||
|
||||
while queue and queried < max_queries and len(peers) < max_peers:
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
host, port = queue.pop(0)
|
||||
if (host, port) in seen_nodes:
|
||||
continue
|
||||
seen_nodes.add((host, port))
|
||||
queried += 1
|
||||
|
||||
remaining = max(0.05, deadline - time.monotonic())
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_DGRAM)
|
||||
except OSError as exc:
|
||||
last_error = str(exc)
|
||||
continue
|
||||
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
tx = self._next_tx()
|
||||
packet = self._get_peers_packet(info_hash, tx)
|
||||
try:
|
||||
with socket.socket(family, socktype, proto) as sock:
|
||||
sock.settimeout(min(1.0, remaining))
|
||||
sock.sendto(packet, sockaddr)
|
||||
raw, _addr = sock.recvfrom(4096)
|
||||
except OSError as exc:
|
||||
last_error = str(exc)
|
||||
continue
|
||||
|
||||
msg = DHTMessage()
|
||||
raw_buf = C.create_string_buffer(raw, len(raw))
|
||||
rc = self.lib.dht_parse_message(raw_buf, len(raw), C.byref(msg))
|
||||
if rc != TRACKER_OK:
|
||||
last_error = f"dht_parse_message failed: {rc}"
|
||||
continue
|
||||
got_tx = bytes(msg.transaction[:msg.transaction_len])
|
||||
if got_tx != tx or msg.type != DHT_MSG_RESPONSE:
|
||||
continue
|
||||
|
||||
for i in range(int(msg.peer_count)):
|
||||
ep = self._peer_endpoint(msg.peers[i])
|
||||
if ep:
|
||||
peers.add(ep)
|
||||
for i in range(int(msg.node_count)):
|
||||
ep = self._node_endpoint(msg.nodes[i])
|
||||
if ep and ep not in seen_nodes and ep not in queue:
|
||||
queue.append(ep)
|
||||
discovered += 1
|
||||
break
|
||||
|
||||
return DHTResult(
|
||||
peers=sorted(peers),
|
||||
nodes_queried=queried,
|
||||
nodes_discovered=discovered,
|
||||
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
||||
error="" if peers else last_error,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue