Naut-Peer/harness/engine_ffi.py
ookami125 d8208685a2 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>
2026-06-21 23:12:32 -04:00

202 lines
7.7 KiB
Python

"""
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()