Naut-Peer/README.md
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

176 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# torrent-peer
A fast, BitTorrent-wire-compatible **leech peer** written in C, driven by a
Python **test harness**. The normal `.torrent` path parses metainfo locally and
uses the neighboring `torrent-tracker` library for DHT get_peers and HTTP/UDP
tracker announces; libtorrent remains in the tests for seed fixtures and as a
magnet-metadata fallback.
Milestone 1 scope: download from connected peers as fast as possible. No
seeding, PEX, or native magnet metadata resolution yet.
The peer **never hashes or persists data**. It only fetches the blocks the
harness asks for and hands the raw bytes back. The harness owns *what* to
download, reassembles pieces, and verifies SHA-1.
## How data moves (and why it's fast)
```
enqueue_piece(i) request pipeline (BDP-deep)
harness ───────────────────▶ C peer ───────────────────────────▶ remote peer
▲ ▲ │ net thread (epoll + TCP)
│ │ ready_ring (SPSC) │ recv() steered directly into arena slots
│ └──────────────────────────┘
│ block_desc{piece,begin,len,slot} ← zero-copy: payload lives in arena
└────── free_ring (SPSC) ── peer_release_slot(slot) ─────────────┐
return spent slots = credit that lets new requests go out ┘
```
- **SPSC lock-free rings (LMAX Disruptor pattern).** The net thread is the sole
producer of completed blocks (`ready_ring`) and sole consumer of returned
slots (`free_ring`); the harness thread is the mirror. Head/tail counters sit
on separate cache lines with acquire/release ordering — no locks on the hot
path. See `src/ring.h`.
- **Pre-allocated, page-faulted arena.** `num_slots × 16 KiB` of memory is
allocated and touched once up front (`src/arena.c`); there is **zero per-block
allocation** in steady state. The harness wraps it once as a `memoryview` and
reads each block zero-copy (`harness/peer_ffi.py`).
- **Payload steering.** The parser reads a piece message's 13-byte header from a
small staging buffer, then `recv()`s the bulk payload **straight into its
arena slot** — the only bytes copied are whatever were prefetched alongside
the header (`src/proto.c::rd_take_body`).
- **Credit-based flow control.** A block consumes one free slot on arrival;
requests are only issued while `outstanding < free_slots`, so a slow consumer
naturally throttles the wire without dropping data (`src/scheduler.c`).
- **Adaptive pipeline depth.** The in-flight target tracks the bandwidth-delay
product, `target ≈ 2 × rate × min_rtt / 16 KiB`, clamped to
`[32, max_pipeline]`. It uses the *minimum* observed request→block RTT (not an
average) so the estimate reflects the unloaded path instead of feeding back
the pipeline's own queueing delay (`src/scheduler.c`).
- **Request timeouts + re-queue.** Every issued block is tracked with its send
time; a block that goes unanswered past `request_timeout_ms` is re-queued and
re-issued ahead of fresh requests, so a silently dropped request can't stall
the download. Incoming blocks are matched against outstanding requests — an
unsolicited/duplicate block is drained and dropped without consuming a slot or
disturbing the credit accounting (`src/reqtab.c`, `src/proto.c`).
## Piece selection
The harness supplies a **priority vector — one `uint8_t` per piece** — via
`peer_set_priorities()` / `peer_set_priority()`. At each piece boundary the peer
picks the highest-priority piece that
1. the **remote peer actually has** (tracked from its `bitfield`/`have`
messages — the peer never assumes availability), and
2. has **not already been fully requested**,
breaking ties toward the lowest piece index. Priority `0` means "skip". Because
selection re-reads the vector live, the harness can implement any scheme by
rewriting priorities over time — sequential, rarest-first, or a deadline ramp
(e.g. fetch episode 1 first, then rarest-first, while slowly raising episode 2's
priorities so it lands inside 24 minutes) — without any peer-side changes. A
piece that fails verification is re-armed with `peer_request_piece()`.
This replaces a fixed in-order schedule, which stalled whenever the connected
peer lacked the next piece. Selection is one O(num_pieces) scan per *piece* (not
per block); `src/scheduler.c`.
io_uring zero-copy receive is the natural next upgrade and would drop into
`src/net.c` without touching the protocol parser or the arena handoff.
## Layout
| Path | Role |
|------|------|
| `include/peer.h` | public C ABI (the only surface ctypes binds to) |
| `src/peer.c` | lifecycle, helpers, control-plane piece queue |
| `src/net.c` | TCP connect, socket tuning, epoll loop, outgoing buffer |
| `src/proto.c` | handshake + wire framing + payload steering |
| `src/scheduler.c` | piece selection, adaptive depth, timeouts |
| `src/reqtab.c` | in-flight request table (match + timeout) |
| `src/ring.c/.h` | SPSC rings |
| `src/arena.c` | aligned, pre-faulted block arena |
| `harness/peer_ffi.py` | ctypes bindings + zero-copy arena view |
| `harness/torrent_meta.py` | local bencoded `.torrent` parser |
| `harness/tracker_ffi.py` | ctypes bindings for `../torrent-tracker` DHT/tracker helpers |
| `harness/harness.py` | tracker discovery + reassembly + SHA-1 verify |
| `tests/test_localseed.py` | end-to-end test against a local libtorrent seed |
## Build
```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build # -> build/libtorrentpeer.so
```
Options: `-DPEER_NATIVE=OFF` (portable build, no `-march=native`),
`-DPEER_ASAN=ON` (AddressSanitizer/UBSan).
## Test
The end-to-end test creates a random file, seeds it with libtorrent on
localhost, downloads every piece through the C peer, and asserts the bytes match
(it builds the library automatically if needed):
```sh
python tests/test_localseed.py # standalone
# or, if pytest is available: python -m pytest tests/
```
Run the full path under ASan:
```sh
cmake -S . -B build-asan -DPEER_ASAN=ON && cmake --build build-asan
ASAN_OPTIONS=detect_leaks=0 \
LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
python tests/test_localseed.py
```
For offline interoperability against multiple seed clients, use the Docker
Compose harness in `interop/`. It creates a private trackerless fixture torrent,
starts libtorrent, Transmission, and aria2 seeders on an internal-only Docker
network, then verifies that this engine can download and hash-check the fixture
from each one:
```sh
docker compose -f interop/docker-compose.yml up --build \
--abort-on-container-exit --exit-code-from runner
```
## Use against a real torrent
```sh
# explicit peer (skip tracker):
python harness/harness.py file.torrent --peer 1.2.3.4:51413 -o out.bin
# or discover peers via DHT and the torrent's HTTP/UDP trackers:
python harness/harness.py file.torrent -o out.bin
```
Tunables: `--slots N` (arena depth, 16 KiB each), `--pipeline N` (cap on
outstanding requests; the live depth adapts to the BDP under this cap),
`--timeout SECONDS`. Per-block request timeout and an optional `SO_RCVBUF`
override are `peer_config` fields (`request_timeout_ms`, `recv_buffer_bytes`).
## Status
Verified byte-for-byte on local-seed transfers up to 256 MiB; ~400+ MB/s over
loopback with the arena (16 MiB) far smaller than the file, i.e. with
credit-based backpressure fully engaged. Clean under ASan/UBSan. The test suite
covers full download + byte verification, a **partial seed** (peer downloads
only the pieces the remote has), **priority ordering**, and — via a raw-socket
mock peer — **request-timeout recovery** and **unsolicited-block rejection**.
### Known gaps / next steps
- Real-swarm auto-discovery is still hit-or-miss: many announced peers are
unreachable, behind NAT, or only usable with a different transport/encryption
mode. Use `harness/swarm_download.py --encryption` and `--utp` to choose the
dial mode.
- Single peer / single net thread. Aggregate throughput needs a multi-peer
connection manager with a shared piece picker (enables endgame mode + CANCEL).
- io_uring registered buffers + multishot recv would give a true zero-copy,
near-syscall-free receive path (drops into `src/net.c`).
- Optional in-library SHA-NI hashing on a worker pool, so wire speed isn't held
hostage to Python hash throughput at multi-GB/s.
- Wider peer reach: µTP, MSE/PE encryption, IPv6, Fast Extension (BEP-6).