Skip to content

release 0.6.6 — swarm health under CGNAT - #16

Merged
daniboybye merged 23 commits into
masterfrom
release/0.6.6
Aug 27, 2026
Merged

release 0.6.6 — swarm health under CGNAT#16
daniboybye merged 23 commits into
masterfrom
release/0.6.6

Conversation

@daniboybye

Copy link
Copy Markdown
Owner

Cuts 0.6.6 on top of 20 swarm-health fixes. The BEP 20 peer-id prefix is derived from the package version, so this also moves the advertised prefix from ET0-6-5 to ET0-6-6 (recorded in README.md and PROTOCOL.md).

Why

On a CGNAT host a torrent runs on a handful of peers, and three separate mechanisms were shedding peers we could not afford to lose:

  • Cancelled requests were punished as protocol errors. A cancel is not atomic — BEP 6 obliges the peer to answer every request exactly once, so a block cancelled, choked or repinned away still arrives one RTT later. Both the piece and the reject hit the "not outstanding" branch, which disconnected and blacklisted the peer ID across every torrent. 46 bans from handle_piece and 29 from handle_reject in five minutes, hitting Fast clients hardest precisely because they are the ones obliged to reject.
  • Fast-extension messages did the same. 71 of 98 disconnects in one three-minute window were mainstream qBittorrent and Transmission builds.
  • Bans never expired. The blacklist was a MapSet that only grew: 352 IDs in ten minutes, while eight of nine torrents could not exceed three connections.

Meanwhile the piece scheduler parked the survivors on work they were not allowed to do — torrents sat at 99% for hours with unchoked idle peers beside unclaimed blocks.

What changed

Peer bans / protocol errors

  • Withdrawn blocks are remembered as a bounded per-block count (one answer per request, so arrival order stops mattering), evicted by generation rather than cleared wholesale. An answer matching neither set is wasteful, not malicious: it ends the connection at 512 blocks without banning the ID.
  • have_all/have_none go through the normal bitfield handler — a seeder recorded as having nothing is a peer we can never request from. The advisory Fast messages are logged and ignored. A truncated frame still drops the connection but no longer bans.
  • Bans last 30 min with a cap and a background sweep, and each records the rule that fired.
  • A disconnect logs why it ended. Peer.Endpoints monitors a supervisor with auto_shutdown: :any_significant, which exits with a bare :shutdown whatever the child's reason was, so every disconnect read reason=:shutdown.

Piece scheduling

  • Endgame reaches pieces already in flight at the transition. A worker read the mode once at State.download/3, so exactly the pieces endgame exists for ran without redundancy — one 1 MiB piece held a torrent at 99.939% for over an hour.
  • A pin is released from an unchoked peer that delivers nothing (snubbing). The old test required choke_me, but a choked peer holds no requests at all — the harmful case was the one it skipped.
  • A peer is no longer repinned off the piece it is fetching. Measured over seven minutes live: requests per block 17:1 → 1.1:1, re-request factor 5.66× → 1.08×, swarm 28 → 73 peers, 1.79 → 2.47 MB/s.
  • Two more endgame defects: the drained-pin probe branched on the torrent's mode rather than the worker's, and endgame_preferred_index/2 destructured the peer key backwards, funnelling the whole swarm onto one index.
  • A worker whose holders have all disconnected is released (was 7 of 12 slots held forever).
  • A piece failing its SHA-1 check now blames the peer that supplied it.

Dial / transport

  • The dial layer stops writing off a candidate pool a CGNAT host cannot refill: registration clears a failure row, escalation counts failures within a 10 min streak window, and sticky blocks became last-resort under min_count pressure instead of absolute (1151 of 1162 active blocks were refusing resurrection).
  • perf(dial): batches overlap instead of serialising on their slowest endpoint, bounded by endpoints in flight (40) and concurrent batches (3).
  • Sender writes are bounded — a TCP socket defaults to send_timeout: :infinity, and one sender held a 20863-message mailbox. Both accepted and dialled sockets now take a 30 s send timeout.
  • The upload delivery task treats "this block is never reaching that peer" as a cancellation instead of crashing (was 333 crash reports in fifteen minutes).
  • One pending pump wake per torrent — self-rescheduling {:next_piece} chains accumulated to ~2600 discovery dial cycles/min until the tracker answered 403.
  • The background DHT metadata-lookup dedup table has a permanent supervised owner.

Validation

  • mix compile --warnings-as-errors clean.
  • Version-derived tests pass (test/peer_id_test.exs, test/dht_config_test.exs — the DHT v field takes the first 4 bytes of the prefix): 11 passed.
  • Rebased onto origin/master (picks up the dependabot github-actions bump); linear, no conflicts.

Full detail per fix is in the CHANGELOG.md entry.

🤖 Generated with Claude Code

daniboybye and others added 23 commits August 25, 2026 11:27
The dedup table for background metadata lookups was created by whichever
Fetcher happened to run first, so it died with that torrent's fetcher and
every later background task crashed with ArgumentError on insert. An ETS
table has to be owned by a process that outlives its users, so own it from
a supervised GenServer instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
…check

BEP 3 verifies a piece against the SHA-1 in the metainfo, but a failure only
told us the piece was bad, never who sent it: the worker dropped the data,
re-requested every block, and a peer serving garbage was picked again. One
torrent sat at 99.84% for hours re-downloading the same index.

A piece now remembers which peer supplied each block, so a failure whose
blocks all came from one peer is reported to that peer's controller. A peer
stops being asked for an index it has already corrupted, and is dropped once
it has ruined @max_hash_failures distinct pieces — distinct, because retrying
one poisoned piece must not count as several offences.

Co-authored-by: Cursor <cursoragent@cursor.com>
Whether a piece can still be sourced is a question about that index, not
about the torrent. The abort check also required the swarm to be empty, so
on any torrent with peers a worker whose holders had all disconnected was
never released: it held one of the @max_parallel_pieces slots with no peer
monitored and its full block list unclaimed, forever.

Live this was 7 of 12 slots, capping a torrent with 26 unchoked peers at
5 pieces in flight. Ask whether any connected peer still has this index.

Co-authored-by: Cursor <cursoragent@cursor.com>
"Does this piece still have blocks" and "does it have blocks for you" are
different questions, and piece_has_waiting?/2 answered the first: it counts
blocks in flight to other peers. Outside endgame such a block cannot be
handed to a second peer, so a peer pinned to a fully claimed piece just sat
there until something else moved it.

Live: 27 of 37 peers pinned to 4 claimed pieces while 8 active pieces with
49-64 free blocks had no peer at all. Add piece_has_unclaimed?/2 and use it
both to refuse a repin onto such a piece and to free a pin that has drained.
Endgame keeps the old test, since duplicating in-flight blocks is its point.

Co-authored-by: Cursor <cursoragent@cursor.com>
A {:next_piece} that cannot place a piece reschedules itself, and every
independent trigger started its own such chain: the 2s reconcile tick, each
peer handoff kick, every piece's requests_are_dealt closure. Nothing
deduplicated them, so the chains accumulated. A live debug build went from
99 to about 2600 discovery dial cycles per minute over twelve minutes with
no change in swarm size, and the tracker began answering 403.

Every wake runs the same full pump attempt, so one pending wake per
controller is enough. Route them all through one timer, cancelling the
previous, and fold the queued strategy into the new one.

Co-authored-by: Cursor <cursoragent@cursor.com>
A TCP socket defaults to send_timeout: :infinity, so once a peer stopped
draining its receive window our sender blocked inside :prim_inet.send/4 and
never came back. Live one Peer.Sender held a 20863-message mailbox that only
grows: every block, have and keepalive queues behind a write that will not
complete.

Give both accepted and dialled sockets a 30s send timeout and close on it,
so the write fails and Peer.Sender.do_send/2 can stop the connection. 30s is
long enough for a genuinely slow peer on a congested path, short enough that
a dead one cannot park memory indefinitely.

Co-authored-by: Cursor <cursoragent@cursor.com>
…e task

The delivery task only tolerated :noproc, so a peer that shut down mid-call
or was too slow to accept the block took the task down with it: 333 [error]
crash reports in fifteen minutes, all restating one fact once per in-flight
block, drowning the log we read to find real faults.

Treat "this block is never reaching that peer" as a cancellation and log one
debug line. A timeout counts, because it means the peer's socket has stopped
draining; BEP 3 lets us simply not answer a request, and the peer will ask
again if it still wants it. Any other exit still crashes, so a genuine fault
in the delivery path is not swallowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
…fill

Three compounding effects starved the swarm. Live, eight of nine torrents sat
at 1-3 connected peers while discovery knew 37-53 candidates each: only 3-8
were dialable, and the scarce v6 pool — the only family with real yield on this
host — was blocked 2-6 of 5-8. With 1-3 peers and no pieces to trade nothing
unchokes us, so nothing downloaded at all.

Only mark_productive/3 cleared a row, which asks "was this endpoint useful"
rather than "is it reachable". A leecher with nothing to trade is kept choked,
so a reachable peer never delivers bytes and carries its failure history for
the whole session. Registration now clears it, for inbound peers too: if they
reached us, our timeouts were about their NAT, not a dead host.

Retention was refreshed on every failure, so a row re-dialled at least once
per 30 min never aged out and its counter only climbed — rows reached
fail_count 107-109, a permanent sticky block on an endpoint that merely fails
intermittently. Escalation now counts failures within a 10 min streak window.

Sticky blocks were absolute, which is right while anything else is dialable and
starves the torrent once nothing is: 1151 of 1162 active blocks refused
resurrection, so a 50-endpoint batch request returned 3-8. Under min_count
pressure they are now last-resort, ordered productive, then soft before sticky,
then v6 before v4, then fewest failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Peer.Endpoints monitors the per-peer supervisor, and that supervisor is
auto_shutdown: :any_significant — it exits with a bare :shutdown as soon as
either child stops, whatever the child's reason was. So every disconnect logged
reason=:shutdown: true of the supervisor, and useless for the one question worth
asking when a swarm keeps shrinking, which is whether the peer left or we
dropped it.

The controller now hands its reason over from terminate/2, which runs before the
supervisor exits, so the note is in place when the :DOWN arrives. A note is
discarded when the endpoint registers again, since it describes the connection
that ended rather than the one starting.

Co-authored-by: Cursor <cursoragent@cursor.com>
The blacklist was a MapSet that only grew, so one bad frame excluded a peer for
the rest of the session, across every torrent. A ten-minute live run banned 352
peer IDs while eight of nine torrents could not get past three connections — on
a CGNAT host, where a torrent runs on 1-8 peers, that list *is* the swarm.

A ban is a hedge against a peer that will misbehave again, and re-testing it
costs one connection, so it does not need to outlive the failure by much: 30 min
with a cap, swept in the background. Each ban now also logs the rule that fired,
because "protocol_error" alone gave no way to tell a broken client from a rule of
ours that is too strict.

Co-authored-by: Cursor <cursoragent@cursor.com>
BEP 6 sends have_all, have_none, suggest_piece, allowed_fast and reject only once
both sides advertised the Fast extension, and we do advertise it, so a peer
sending one without it is at odds with the spec. Dropping the connection over it
was still wrong, because terminate/2 also blacklists the peer ID for every
torrent: 71 of 98 disconnects in one three-minute window, all of them qBittorrent
and Transmission builds that do support Fast.

have_all and have_none now go through the normal handler — they carry what a full
or empty bitfield carries, and a seeder we record as having nothing is a
connection we can never request from. The rest are advisory, or self-healing in
reject's case since the request timeout already covers it, so they are logged and
ignored.

A frame that stops half-way is a congested path, not a slow-loris: a uTP window
that collapsed, a peer that swapped. Dropping the connection is the defence
there; the ban only removed a mainstream client from every torrent at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
A protocol error drops the peer and blacklists its ID for every torrent, but the
log only carried the verdict, and a dozen rules share it. A run that banned 100
peers in five minutes gave no way to tell which one fired — so no way to tell a
broken client from a rule of ours that is too strict.

The generic cast handler now logs the wire message that was rejected, and the
piece-bounds check logs the block it refused alongside the torrent's own geometry,
which is what makes an off-by-one in our bookkeeping visible.

Also updates the DialBackoff state-machine model to the current resurrection
order: sticky blocks are last-resort now, not excluded.

Co-authored-by: Cursor <cursoragent@cursor.com>
A cancel is not atomic. When we cancel a block, get choked, or repin a peer to
another piece, we dropped the request locally the same instant — but the block
may already be on the wire, and BEP 6 requires the peer to answer every request
exactly once, so a cancelled request comes back as a piece or a reject one RTT
later. Both landed in the "not outstanding" branch, which returned
:protocol_error: disconnect plus a blacklist of the peer ID across every
torrent.

So the scheduler was feeding the blacklist. Every repin opened a window in which
the peer's correct answer got it banned, and the Fast clients — the ones obliged
to send the reject — were hit hardest. A five-minute window showed 46 bans from
handle_piece and 29 from handle_reject, with the swarm never growing past a
handful of peers.

Withdrawn blocks are now remembered for a bounded window: an answer for one is
expected, and a piece is still counted since the payload reaches storage either
way. A block matching neither set is unrequested — wasteful rather than
malicious, so it ends the connection at 512 blocks without banning the ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
The window was cleared wholesale once it passed its cap, because a MapSet has no
order to evict by. A peer that gets repinned often withdraws far more blocks
than the window holds, so the clear kept landing on blocks that were still
genuinely in flight: a four-minute run logged 4392 answers as unsolicited, and
six peers crossed the disconnect threshold on nothing but their own correct
rejects.

Retiring a full generation instead keeps at least one cap's worth of recent
withdrawals recognisable at all times, and eviction now drops the oldest rather
than everything.

Co-authored-by: Cursor <cursoragent@cursor.com>
The withdrawn-request window was a set, so it could only record *that* a block
had been withdrawn, not how many answers were still owed for it. Live logs show
the same block requested, cancelled and requested again up to seven times
against one peer, and the first answer erased the block from the window, so
every later one read as unsolicited: 71% of the 3891 "unsolicited" answers in a
25-minute run were for blocks we had demonstrably requested from that same peer.

BEP 6 gives an exact rule to count with — one answer per request, always — so
the window is now a count per block, decremented as answers arrive. That also
makes the bookkeeping independent of arrival order, which matters because a
re-requested block sits in both the live set and the window at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
…t peer

A dial batch resolved only when its slowest endpoint did, and one endpoint can
hold a slot for the whole connect + handshake budget: measured successes came
back at 16s, 42s and 42s, with a worst case near 55s. Until then the manager
refused to start anything new for that torrent.

That is worst exactly where it hurts. A starved torrent whose queue is all IPv4
gets capped to a four-endpoint probe batch, so it was making four dial attempts
per minute against a CGNAT success rate near 1% — one torrent sat at zero peers
for the whole run with 18 candidates queued. The torrents that did fine were the
ones with deep enough queues to fill a 40-endpoint batch.

Batches may now overlap, bounded by endpoints in flight (40) and concurrent
batches (3), with in-flight endpoints excluded from selection so overlapping
batches cannot dial the same peer twice. `dialing?` keeps its meaning as a hard
stop and is now set only at saturation.

Co-authored-by: Cursor <cursoragent@cursor.com>
A pin counted as "drained" whenever the piece had no unclaimed blocks
left, without asking who had claimed them. A peer that had claimed every
remaining block on its piece therefore made that piece look drained,
was moved off it, and the move cancelled the very requests that drained
it: the blocks returned to `waiting`, the piece looked workable again,
and the next reconcile tick pinned the peer straight back.

That oscillated at the 2 s tick. One peer re-sent the same block 79
times in ten minutes and delivered none of it, and across the swarm we
sent 17 wire requests per block received while the cancels churned the
withdrawn-answer window behind them.

A piece now counts as drained for a peer only when it has neither
unclaimed blocks nor in-flight requests from that peer, so the rule
still frees peers parked on someone else's in-flight blocks — its
original purpose — without evicting the peer doing the work.

Measured over seven minutes on the live node: requests per block
17:1 -> 1.1:1, re-request factor 5.66x -> 1.08x, worst peer 55.8x ->
1.0x, swarm 28 -> 73 peers, aggregate 1.79 -> 2.47 MB/s.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two defects held peers on a piece that had nothing left for them, both
in the endgame path, and together they parked torrents just short of
completion.

First, `pin_drained?/4` branched on the *torrent's* mode. A torrent
enters `:endgame` while the piece workers it already started keep
running in normal mode, and for those the endgame branch probed
`piece_has_waiting?/2`, which counts blocks in flight to *other* peers.
A normal-mode worker never hands out a block that is already in flight,
so that is exactly the wrong answer: the pin was held for work this peer
could never be given. The probe is now `piece_serves_peer?/3` in both
modes, which is well-defined because `waiting` already encodes the mode
— a normal worker drops a block when it hands it out, an endgame worker
keeps it until delivery, so redundant re-requests stay possible and the
endgame pin stays useful until the piece is genuinely finished.

Second, `endgame_preferred_index/2` destructured the peer key as
`{_hash, peer_id}`, but `Peer.make_key/2` builds `{id, hash}`. It was
hashing the torrent hash, so every peer on a torrent got the same
answer: the mechanism meant to spread peers across all remaining indices
funnelled the whole swarm onto one. That gate now also guards the
drained-pin path, which endgame needs because `target_accepts_repin?/3`
accepts any active index there — without it the peers freed by the first
fix would all follow `reconcile_refresh_interest/4` onto whichever index
it walks first.

Measured live on a torrent stopped at 99.18% for over fifteen minutes:
8 of its 10 peers pinned to one piece with no unclaimed blocks left,
three of them unchoked and idle, while six pieces sat with all 64 blocks
unclaimed and no peer at all.

Co-authored-by: Cursor <cursoragent@cursor.com>
`stale_useless_pin?/1` required `choke_me`, so the only pin it ever
released belonged to a peer the remote had choked — and a choked peer
holds no requests at all, because `handle_choke/1` clears them. The
harmful case was the one it skipped: an unchoked peer keeps a full
64-request pipeline, so every block it sits on is unavailable to anyone
else and is merely re-timed-out every `@timeout_request` and handed
straight back to it.

Live on a torrent stopped at 99%: two peers held one whole piece each,
61 and 64 blocks in flight, having delivered 48 KiB and 0 B, while two
pieces with all 64 blocks unclaimed had no peer at all. The same two
peers re-requested their 64 blocks 821 and 622 times in five minutes,
the interval alternating ~30 s / ~24 s — the block timeout reclaiming
them and the pump returning them to the peer that had just failed them.

Zero bytes on the pin now releases it whichever way the choke went, on a
longer threshold when unchoked (60 s) than when choked (15-20 s): longer
than a block timeout, so a merely slow peer gets a full request cycle to
produce something before it loses the pin. This is what other clients
call snubbing.

Co-authored-by: Cursor <cursoragent@cursor.com>
…sition

A piece worker reads the torrent's mode once, in State.download/3, so a piece
that was already downloading when the torrent crossed the endgame threshold ran
in normal mode for the rest of its life -- and normal mode gives a block to
exactly one peer. Redundancy was therefore absent from precisely the pieces
endgame exists for.

Live: a torrent sat at 99.939% for over an hour on one 1 MiB piece. The worker
was mode=nil under an :endgame torrent with waiting=[] and all 27 remaining
blocks in flight to a single peer that had logged 297 request timeouts on that
piece and zero hash failures, while three unchoked peers holding the piece had
nothing they were allowed to ask for. Each block was only retried on its own
30s timeout and handed straight back to the peer that had just failed it.

:reconcile_pump now upgrades active workers level-triggered. enter_endgame/1 is
idempotent and one-way, and re-queues the in-flight subpieces into waiting so
other peers may request them while the requests already out stay in place --
endgame adds sources for a block rather than taking it from the peer fetching
it. The pending stall/orphan timer is cancelled and flushed, since delivered
late it would abort a worker that has just started making progress. Redundancy
stays capped at @endgame_redundancy, and pieces started after the transition
already behaved this way.

Co-authored-by: Cursor <cursoragent@cursor.com>
Swarm health under CGNAT. Three separate mechanisms were shedding peers a
handful-of-peers swarm cannot afford — cancelled requests answered correctly and
punished as protocol errors, Fast messages treated the same way, and a blacklist
that never expired — while the piece scheduler parked the survivors on work they
were not allowed to do, leaving torrents at 99% with unchoked idle peers beside
unclaimed blocks. Also: endgame now reaches pieces already in flight, dial
batches overlap instead of serialising on their slowest endpoint, and writes to
a peer that stopped reading are bounded.

The peer ID prefix is derived from the package version, so this also moves the
advertised BEP 20 prefix from ET0-6-5 to ET0-6-6 — recorded in README and
PROTOCOL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Overlapping dial batches pushed two functions past Credo's ABC ceiling of 30,
which fails the strict Credo gate: dial_batch/2 reached 42, because selecting
the peers, the empty-queue replenish branch and the task launch all shared one
body, and the {:dial_done, ...} clause reached 35 for rebuilding six state
fields inline.

No behaviour change. select_dial_peers/3 and launch_dial_batch/3 split
dial_batch/2 along the branch it already had, and release_dial_batch/3 takes the
state rebuild out of handle_info/2 — which also gives the rule a name: a failed
endpoint stays queued so backoff ordering decides when to retry it, while the
rest leave the queue with the batch, and the slots are freed whatever the
results were so the next batch can start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mix sobelow --out writes sobelow.sarif into the repo root. CI regenerates it per
run on a throwaway runner, but reproducing the CI gates locally before a PR
leaves it in the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@daniboybye
daniboybye merged commit 073b9b5 into master Aug 27, 2026
3 of 4 checks passed
@daniboybye
daniboybye deleted the release/0.6.6 branch August 27, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant