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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue