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:
ookami125 2026-06-21 23:12:32 -04:00
commit d8208685a2
55 changed files with 9989 additions and 0 deletions

196
REVIEW.md Normal file
View file

@ -0,0 +1,196 @@
# torrent-peer — Performance & Correctness Review (work items)
Audience: an AI/engineer who will implement fixes. Each item has a location, the
problem, why it matters for a high-performance BitTorrent client, and a concrete
fix direction. Items are grouped and roughly ordered by value.
Context: this library is currently a **single-peer, leech-only, TCP-only** block
fetcher. The C peer connects to one peer, runs the request pipeline, and hands
raw blocks to a Python harness (which owns metadata/tracker/reassembly/SHA-1).
Several of the highest-value performance items live in scope the README has
explicitly deferred (multi-peer, µTP, encryption). They are still listed here
because the question is about hitting peak performance.
---
## P0 — correctness / hard stalls (fix first)
### C1. No per-request timeout → permanent stall on a dropped request
- Where: `src/scheduler.c` (request issue), `src/proto.c:247` (`outstanding--`),
`src/peer.c:148` (`peer_request_piece`).
- Problem: when a request is issued, `outstanding` is incremented and the piece
is marked `requested[i]=1` (`scheduler.c:38`). There is no timeout. If the
peer accepts the request but never sends the block (silent drop, or choked
without Fast-Extension Reject), `outstanding` stays high and `requested[i]`
stays 1 forever. The piece never completes and never recovers — the harness
only re-arms on hash *failure*, but it never receives bytes to hash.
- Why it matters: a single misbehaving/slow peer hangs the whole download.
- Fix: track per-block in-flight requests with a deadline (issue time + RTT-based
timeout). On expiry, decrement `outstanding`, clear the block's requested
state, and re-queue it. Add an internal timer tick (the net loop already wakes
every `RUNNING_TICK_MS`). Expose a configurable timeout in `peer_config`.
### C2. Unsolicited / unmatched piece messages corrupt flow control
- Where: `src/proto.c:223-251` (`RS_PIECE_HDR` pops a free slot; `RS_PIECE_BODY`
pushes to harness and decrements `outstanding`).
- Problem: there is no check that the received `(piece, begin, len)` matches an
outstanding request. A buggy or malicious peer can push blocks you never
requested: this consumes arena slots, hands garbage to the harness, and
decrements `outstanding` incorrectly (`proto.c:247`), desyncing the
`outstanding <= free_slots` credit invariant.
- Why it matters: correctness + DoS resistance; also required before C1's
in-flight tracking can be trusted.
- Fix: maintain a set/multiset of outstanding `(piece, begin)` requests (a small
hash set keyed on piece*blocks+block index works). On a piece message, look it
up; if absent, drop the payload without consuming a slot and without touching
`outstanding`. Only decrement `outstanding` for a matched request.
---
## P1 — performance ceilings in the current single-peer path
### P1a. Slot release is not event-driven (2 ms polling latency)
- Where: `src/peer.c:198` (`peer_release_slot`), `src/net.c:30,154`
(`RUNNING_TICK_MS = 2`).
- Problem: `peer_release_slot` only pushes to `free_ring`; it does **not** poke
`ctrl_efd`. When the pipeline has drained (harness is the bottleneck), request
refill waits for the 2 ms epoll timeout instead of firing immediately on
returned credit. While blocks are actively arriving this is masked (EPOLLIN
drives `scheduler_tick`), but it is a real latency floor on the credit→request
loop whenever the wire idles on slots.
- Fix: signal `ctrl_efd` from `peer_release_slot`, coalesced so a burst of
releases costs at most one wakeup (e.g. only write the eventfd if a "needs
wake" flag transitions). Keep the periodic tick as a fallback.
### P1b. "Zero-copy steering" mostly copies at high throughput
- Where: `src/net.c:29` (`RECV_STAGING_CAP = 256 KiB`), `src/proto.c:99-120`
(`rd_take_body`).
- Problem: `rd_refill` reads up to 256 KiB into staging; `rd_take_body` first
`memcpy`s the already-staged portion of the body and only `recv()`s the
remainder directly into the slot. When the socket is fast (kernel returns
256 KiB at once — the regime that matters), most of each block is already in
staging and is copied, not steered. The README's "only bytes copied are those
prefetched alongside the header" is inverted under load.
- Why it matters: the advertised zero-copy property does not hold at speed; cost
grows toward multi-GB/s.
- Fix (small): keep it; the hot 16 KiB memcpy is cheap at a few hundred MB/s.
- Fix (real): use `readv`/`recvmmsg` scatter directly into slots, or io_uring
registered buffers (see P2b). That removes the copy *and* cuts syscalls.
### P1c. `SO_RCVBUF` pinned to 8 MiB disables autotuning → caps high-BDP throughput
- Where: `src/net.c:96-97`, `SO_RCVBUF_BYTES = 8<<20`.
- Problem: manually setting `SO_RCVBUF` disables kernel receive-window
autotuning and clamps the window. On a long-fat path (e.g. 1 Gbps × 100 ms
≈ 12.5 MB BDP) this throttles below line rate.
- Fix: by default do **not** set `SO_RCVBUF` (let autotuning scale). Make it an
opt-in tunable for low-latency LAN cases. Optionally set `TCP_CONGESTION=bbr`
and consider `TCP_QUICKACK`.
### P1d. Adaptive pipeline depth
- Where: `src/peer.c:17` (`DEFAULT_PIPELINE = 2048`), `src/scheduler.c:46`.
- Problem: pipeline depth is a fixed cap, not tracking the measured
bandwidth-delay product. Too small under-fills on high RTT; too large wastes
arena. README acknowledges this.
- Fix: measure RTT (request→first-byte) and achieved rate (already sampled in
`update_rate`, `net.c:78`) and size in-flight bytes to ~BDP, clamped by arena.
### P1e. One piece in flight at a time starves small-piece torrents
- Where: `src/scheduler.c:53-70` (`have_cur_piece` gate).
- Problem: the scheduler fully requests `cur_piece` before selecting the next.
For small piece sizes the in-flight window collapses toward one piece's worth
of blocks, starving the pipeline.
- Fix: allow outstanding requests to span multiple pieces; select a new piece
whenever the in-flight window has room, not only at piece completion.
### P1f. Data race on `outstanding` (and `rate_bps`)
- Where: `src/peer.c:221` reads `hh->outstanding`; net thread writes it at
`src/proto.c:247` / `src/scheduler.c:68`.
- Problem: non-atomic cross-thread read = C11 data race (UB). Benign in
practice; `rate_bps` is similarly racy and acknowledged.
- Fix: make `outstanding` an `atomic_uint` with relaxed ordering, or snapshot it
into an atomic for status reads.
---
## P2 — features required to actually hit peak performance
### P2a. Multi-peer / multi-connection (biggest lever)
- Problem: a single TCP peer rarely saturates a fast link; BitTorrent throughput
is aggregate across many peers. The current thread-per-peer model
(`peer_start` spawns one net thread, `peer.c:115`) does not scale to
hundreds/thousands of sockets.
- Fix: introduce an event-loop pool that shards many sockets across a small set
of epoll/io_uring loops. The per-handle arena+rings already isolate state
cleanly; the work is a connection manager + a shared piece-picker across peers.
- Depends on / enables: endgame mode (P2d), rarest-first across the swarm.
### P2b. io_uring (registered buffers + multishot recv)
- Where: replaces the recv path in `src/net.c`; README flags this.
- Problem/fix: register the arena once, use multishot recv to deliver blocks
with a near-zero syscall hot path and genuine zero-copy (fixes P1b). The
protocol parser and arena handoff above it stay unchanged.
### P2c. In-library, multi-threaded, SHA-NI hashing
- Problem: because of the (good) credit backpressure, **wire speed is a hostage
to hash speed**, and hashing currently runs in Python in the harness. At
multi-GB/s, SHA-1 verification in Python becomes the real throttle and slots
return slowly.
- Fix: offer optional in-library piece verification using hardware SHA (SHA-NI)
on a small worker-thread pool, releasing slots as soon as a block is hashed
into its piece buffer. Keep the harness path for v2/custom schemes.
### P2d. Request timeouts → endgame mode + CANCEL
- Where: builds on C1; `CANCEL` is parsed but never sent (`proto.c` only handles
inbound control in `handle_control`).
- Fix: near completion, request the last outstanding blocks from multiple peers
and send `CANCEL` to the losers. Requires multi-peer (P2a) to be fully useful,
but the CANCEL send path and "duplicate request allowed near end" logic belong
here.
### P2e. Wider peer pool: µTP, MSE/PE encryption, IPv6, Fast Extension (BEP-6)
- Where: IPv4-only today (`AF_INET`, `net.c:90`); no extension handshake.
- Problem: µTP and MSE encryption are how you reach many real-swarm peers at
all (→ aggregate throughput). Fast Extension's **Reject Request** is directly
relevant: without it a peer that won't serve a request silently drops it —
exactly the case C1 must otherwise recover from via timeout. IPv4-only halves
reachability.
- Fix: implement BEP-6 (at least Reject Request, Have All/None, Allowed Fast),
BEP-10 extension protocol, then µTP and MSE as larger efforts.
---
## P3 — smaller / build
### P3a. Build flags
- Where: `CMakeLists.txt`.
- Fix: enable LTO (`-flto` / `INTERPROCEDURAL_OPTIMIZATION`). Consider per-call
`-mtune`. Keep `-march=native` opt-in as it already is.
### P3b. Huge pages + NUMA-local arena
- Where: `src/arena.c`.
- Fix: for the multi-GB/s regime, back the arena with huge pages
(`MAP_HUGETLB`/THP via `madvise(MADV_HUGEPAGE)`) to cut TLB pressure, and
allocate it NUMA-local to the net thread (or interleaved) on multi-socket
hosts.
### P3c. `pri_lock` held across the full O(num_pieces) scan
- Where: `src/scheduler.c:28-39` (`select_next_piece`).
- Problem: the lock is held for the whole scan, contending with harness priority
updates. Low impact (updates are rare).
- Fix: snapshot or use a finer structure (bucketed/heap) only if profiling shows
it matters; continuously changing priorities limits the payoff.
---
## Things that are already good (do not regress)
- SPSC rings (`src/ring.h`): power-of-two mask, free-running counters,
cache-line-isolated head/tail, correct acquire/release pairing.
- Pre-faulted, page-aligned arena with no hot-path allocation; correct
over-alignment handling in `peer_create` (`src/peer.c:47-54`).
- Credit-based backpressure invariant `outstanding <= free_slots`
(`src/scheduler.c:50`) — every arriving block is guaranteed a slot.
- Live priority-vector piece selection (`src/scheduler.c:25`) decouples policy
from mechanism.
- Resumable incremental parser, batched request writes, `TCP_NODELAY`,
ASan/UBSan build, end-to-end test.