Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2e8efa9
fix(magnet): give the background DHT lookup table a permanent owner
daniboybye Aug 25, 2026
33a170b
fix(download): blame the peer that supplied a piece failing its hash …
daniboybye Aug 25, 2026
6550886
fix(download): release a piece worker whose holders have all left
daniboybye Aug 25, 2026
fbc7667
fix(download): let a peer leave a piece whose blocks are all claimed
daniboybye Aug 25, 2026
037cbc5
fix(download): keep one pending pump wake per torrent
daniboybye Aug 25, 2026
bc14633
fix(peer): bound writes to a peer that has stopped reading
daniboybye Aug 25, 2026
9d2de40
fix(upload): drop a block the peer cannot take instead of crashing th…
daniboybye Aug 25, 2026
d649976
fix(dial): stop writing off the candidate pool a CGNAT host cannot re…
daniboybye Aug 25, 2026
30381d0
fix(peer): log why a peer connection ended instead of always :shutdown
daniboybye Aug 25, 2026
52d87ee
fix(acceptor): expire peer bans instead of holding them for the session
daniboybye Aug 25, 2026
00d6e2c
fix(peer): stop banning peers over Fast messages and stalled frames
daniboybye Aug 25, 2026
70bce5c
fix(peer): name the rule that ends a connection over a protocol error
daniboybye Aug 25, 2026
d7334a9
fix(peer): stop banning peers for answering requests we withdrew
daniboybye Aug 25, 2026
ff8386b
fix(peer): evict the withdrawn-request window by generation
daniboybye Aug 25, 2026
656042c
fix(peer): count owed answers per block instead of remembering a set
daniboybye Aug 25, 2026
4cb52ad
perf(dial): overlap dial batches instead of serialising on the slowes…
daniboybye Aug 25, 2026
cc0f3a2
fix(swarm): stop repinning a peer off the piece it is fetching
daniboybye Aug 26, 2026
ccda255
fix(swarm): unstick endgame torrents parked at 99%
daniboybye Aug 26, 2026
b1e7e30
fix(peer): release a pin from an unchoked peer that delivers nothing
daniboybye Aug 26, 2026
2756644
fix(downloads): apply endgame to pieces already in flight at the tran…
daniboybye Aug 26, 2026
b349ed5
chore: release 0.6.6
daniboybye Aug 27, 2026
d90bfb5
refactor(dial): split the batch launch and release paths
daniboybye Aug 27, 2026
7612c22
chore: gitignore the sobelow SARIF report
daniboybye Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ erl_crash.dump
# Ignore package tarball (built via "mix hex.build").
elixir_torrent-*.tar

# Sobelow SARIF report (built via "mix sobelow --out"); CI regenerates it per run,
# a local CI-parity run would otherwise leave it in the tree.
sobelow.sarif

# Local session state, DHT node id persistence, and downloaded data (engine default layout).
/.elixir_torrent/

Expand Down
123 changes: 123 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,128 @@
# Changelog

## 0.6.6 - 2026-08-27

A swarm-health release. Under CGNAT, where a torrent runs on a handful of peers,
three separate mechanisms were removing peers we could not afford to lose or
parking them on work they were not allowed to do — torrents sat at 99% for hours
with unchoked idle peers and unclaimed blocks side by side.

### Fixed

- Peers are no longer banned for correctly answering a request we withdrew.
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 landed in the "not outstanding" branch,
which disconnected the peer and blacklisted its ID across every torrent — 46
bans from `handle_piece` and 29 from `handle_reject` in five minutes, hitting
the Fast clients hardest precisely because they are the ones obliged to
reject. Withdrawn blocks are now remembered in 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.
- Fast-extension messages sent without the extension advertised, and frames that
stall half-way, no longer ban the peer. 71 of 98 disconnects in one
three-minute window were mainstream qBittorrent and Transmission builds.
`have_all`/`have_none` now go through the normal bitfield handler — a seeder
recorded as having nothing is a peer we can never request from — and the
advisory messages are logged and ignored. A truncated frame is a congested
path, so the connection still drops, but the ID is not blacklisted.
- Peer bans expire. The blacklist was a `MapSet` that only grew, so one bad
frame excluded a peer for the whole session across every torrent: 352 IDs in
ten minutes while eight of nine torrents could not exceed three connections.
Bans now last 30 minutes with a cap and a background sweep, and each records
the rule that fired.
- The dial layer no longer writes off a candidate pool a CGNAT host cannot
refill. A failure row was cleared only by `mark_productive/3`, which asks
whether an endpoint was *useful* rather than whether it is *reachable* — a
leecher we keep choked never delivers bytes and so carried its failure history
forever; registration now clears it, inbound peers included. Retention was
also refreshed on every failure, so a row re-dialled once per 30 minutes never
aged out and reached fail counts above 100; escalation now counts failures
within a 10-minute streak window. Sticky blocks became last-resort under
`min_count` pressure (productive, then soft before sticky, then v6 before v4,
then fewest failures) instead of refusing resurrection absolutely — 1151 of
1162 active blocks did, leaving a 50-endpoint request returning 3-8.
- Endgame now applies to pieces that were already in flight when the torrent
crossed the threshold. 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 with all 27 remaining blocks in
flight to a single peer that had logged 297 request timeouts on it, while
three unchoked peers holding the piece had nothing they were allowed to ask
for. `:reconcile_pump` upgrades active workers level-triggered; the transition
is idempotent and one-way and re-queues in-flight subpieces so endgame *adds*
sources for a block rather than taking it from the peer already fetching it.
- A pin is released from an unchoked peer that delivers nothing. The staleness
test required `choke_me`, but a choked peer holds no requests at all — the
harmful case was the one it skipped, an unchoked peer sitting on a full
64-request pipeline nobody else may touch. Two such peers re-requested their
64 blocks 821 and 622 times in five minutes while two pieces with every block
unclaimed had no peer. Zero bytes now releases the pin either way, on a longer
threshold when unchoked (60 s, more than a block timeout) than when choked.
- A peer is no longer repinned off the piece it is fetching. "Drained" ignored
*who* had claimed the blocks, so a peer that had claimed the rest of its piece
made it look finished, was moved away, and the move cancelled the very
requests that drained it — oscillating at the 2 s tick, 17 wire requests per
block received. Draining now also requires no in-flight requests from that
peer. 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 endgame defects that parked torrents just short of completion: the
drained-pin probe branched on the *torrent's* mode rather than the worker's,
holding a pin for work that worker could never hand out, and
`endgame_preferred_index/2` destructured the peer key backwards
(`{_hash, peer_id}` against `Peer.make_key/2`'s `{id, hash}`), hashing the
torrent hash and so funnelling the entire swarm onto one index.
- Outside endgame, a peer may leave a piece whose blocks are all claimed.
`piece_has_waiting?/2` counts blocks in flight to *other* peers, which a
normal-mode worker can never hand out — 27 of 37 peers were pinned to 4
claimed pieces while 8 pieces with 49-64 free blocks had no peer at all.
- A piece worker whose holders have all disconnected is released. The abort
check also required the swarm to be empty, so on any torrent with peers such a
worker held one of the `@max_parallel_pieces` slots forever — 7 of 12 slots
live, capping a torrent with 26 unchoked peers at 5 pieces in flight.
- A piece failing its SHA-1 check now blames the peer that supplied it. The
worker previously just re-requested every block and could pick the same peer
again; one torrent sat at 99.84% for hours re-downloading one index. Blocks
now remember their source, a peer stops being asked for an index it has
corrupted, and is dropped after `@max_hash_failures` *distinct* ruined pieces.
- Writes to a peer that has stopped reading are bounded. A TCP socket defaults
to `send_timeout: :infinity`, so a sender blocked inside `:prim_inet.send/4`
never returned and its mailbox only grew — one held 20863 messages. Accepted
and dialled sockets now take a 30 s send timeout and close on it.
- The upload delivery task no longer crashes when a peer cannot take a block.
Only `:noproc` was tolerated, so a peer shutting down mid-call produced one
crash report per in-flight block — 333 in fifteen minutes. BEP 3 permits
simply not answering a request, so this is now a cancellation with one debug
line; any other exit still crashes.
- One pending pump wake per torrent. Every trigger — the 2 s reconcile tick,
each peer handoff, every `requests_are_dealt` closure — started its own
self-rescheduling `{:next_piece}` chain, and they accumulated: 99 → ~2600
discovery dial cycles per minute over twelve minutes with no change in swarm
size, until the tracker answered 403.
- A peer 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` —
useless for the one question worth asking, whether the peer left or we dropped
it. A protocol error now also logs the rejected wire message, and the
piece-bounds check logs the block alongside the torrent's geometry.
- The background DHT metadata-lookup dedup table has a permanent owner. It was
created by whichever `Magnet.Fetcher` ran first and died with that torrent, so
every later background task crashed with `ArgumentError` on insert; a
supervised GenServer now owns it.

### Performance

- Dial batches overlap instead of serialising on their slowest endpoint. One
endpoint can hold a slot for the whole connect + handshake budget (measured
successes at 16 s, 42 s and 42 s, worst case near 55 s), and until it resolved
the manager would start nothing new for that torrent. That hurt worst where it
mattered most: a starved torrent with an all-IPv4 queue is capped to a
four-endpoint probe batch, so it made four attempts per minute against a
~1% CGNAT success rate. Batches are now bounded by endpoints in flight (40)
and concurrent batches (3), with in-flight endpoints excluded from selection
so two batches cannot dial the same peer.


## 0.6.5 - 2026-08-18

### Added
Expand Down
2 changes: 1 addition & 1 deletion PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ partial, and what is still missing — the same table the maintainer works from.
| [BEP 15](https://www.bittorrent.org/beps/bep_0015.html) | UDP tracker protocol | **Full** | Connect, announce, scrape, error packets; 60 s connection_id cache; full 15×2ⁿ s long-announce ladder reconnects after expiry, while scrape and under-target fast-fail use intentionally shorter ladders; compact IPv4/IPv6 peers |
| [BEP 16](https://www.bittorrent.org/beps/bep_0016.html) | Superseeding | **Full** | Automatically enters initial-seed mode when a live download completes with no confirmed remote seed: one rare fabricated `have` per peer, assignment rotation on propagation, hidden-piece rejection, and normal seeding restored for a complete remote bitfield or after restart |
| [BEP 19](https://www.bittorrent.org/beps/bep_0019.html) | WebSeed — HTTP/FTP seeding (GetRight-style) | **Partial** | HTTP/HTTPS `Range` fetches for v1/hybrid torrents share the peer verify/write path, with corrupt mirrors disabled per session; FTP, GetRight gap scheduling, and pure-v2 mapping are not implemented; BEP 17 is not planned |
| [BEP 20](https://www.bittorrent.org/beps/bep_0020.html) | Peer ID conventions | **Full** | Version-derived prefix (`ET0-6-5` for package 0.6.5); one runtime-generated 20-byte identity per application instance |
| [BEP 20](https://www.bittorrent.org/beps/bep_0020.html) | Peer ID conventions | **Full** | Version-derived prefix (`ET0-6-6` for package 0.6.6); one runtime-generated 20-byte identity per application instance |
| [BEP 23](https://www.bittorrent.org/beps/bep_0023.html) | Compact peer lists | **Full** | Compact IPv4 peers and dictionary-model IP literals; malformed values are ignored, while legacy dictionary hostnames are intentionally not DNS-resolved; combined with BEP 7 for `peers6` |
| [BEP 24](https://www.bittorrent.org/beps/bep_0024.html) | Tracker returns external IP | **Partial** | Decodes `external ip` from HTTP responses; not used for listen-address selection |
| [BEP 29](https://www.bittorrent.org/beps/bep_0029.html) | Micro Transport Protocol (uTP) | **Substantially Full** | SYN/STATE/DATA/FIN, LEDBAT, cumulative and selective ACK with SACK fast-loss recovery, owner-buffer-aware receive windows, type-preserving retransmission, symmetric FIN close, and dead zero-window probing; TCP-first dial with uTP fallback over DHT's shared UDP socket |
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ElixirTorrent

[![GitHub release](https://img.shields.io/badge/release-0.6.5-181717?logo=github)](https://github.com/daniboybye/ElixirTorrent/releases/tag/0.6.5) [![Changelog](https://img.shields.io/badge/changelog-blue)](https://hexdocs.pm/elixir_torrent/changelog.html) [![Hex.pm](https://img.shields.io/hexpm/v/elixir_torrent.svg)](https://hex.pm/packages/elixir_torrent/0.6.5) [![HexDocs](https://img.shields.io/badge/hexdocs-0.6.5-8E44AD)](https://hexdocs.pm/elixir_torrent/0.6.5) [![Hex.pm Downloads](https://img.shields.io/hexpm/dt/elixir_torrent.svg)](https://hex.pm/packages/elixir_torrent) [![License](https://img.shields.io/hexpm/l/elixir_torrent.svg)](https://github.com/daniboybye/ElixirTorrent/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/badge/release-0.6.6-181717?logo=github)](https://github.com/daniboybye/ElixirTorrent/releases/tag/0.6.6) [![Changelog](https://img.shields.io/badge/changelog-blue)](https://hexdocs.pm/elixir_torrent/changelog.html) [![Hex.pm](https://img.shields.io/hexpm/v/elixir_torrent.svg)](https://hex.pm/packages/elixir_torrent/0.6.6) [![HexDocs](https://img.shields.io/badge/hexdocs-0.6.6-8E44AD)](https://hexdocs.pm/elixir_torrent/0.6.6) [![Hex.pm Downloads](https://img.shields.io/hexpm/dt/elixir_torrent.svg)](https://hex.pm/packages/elixir_torrent) [![License](https://img.shields.io/hexpm/l/elixir_torrent.svg)](https://github.com/daniboybye/ElixirTorrent/blob/master/LICENSE)

[![build](https://img.shields.io/github/actions/workflow/status/daniboybye/ElixirTorrent/build-and-publish.yml?branch=master&label=build&logo=github)](https://github.com/daniboybye/ElixirTorrent/actions/workflows/build-and-publish.yml) [![codecov](https://codecov.io/gh/daniboybye/ElixirTorrent/branch/master/graph/badge.svg)](https://codecov.io/gh/daniboybye/ElixirTorrent) [![BEPs](https://img.shields.io/badge/BEPs-23%20implemented-E8A33D)](PROTOCOL.md) [![Last commit](https://img.shields.io/github/last-commit/daniboybye/ElixirTorrent/master)](https://github.com/daniboybye/ElixirTorrent/commits/master)

Expand Down Expand Up @@ -56,7 +56,7 @@ Full per-BEP status, including the known gaps: **[PROTOCOL.md](PROTOCOL.md)**.
```elixir
def deps do
[
{:elixir_torrent, "~> 0.6.5"}
{:elixir_torrent, "~> 0.6.6"}
]
end
```
Expand Down Expand Up @@ -173,7 +173,7 @@ Full reference: [`hexdocs.pm/elixir_torrent/ElixirTorrent.html`](https://hexdocs
| `stop_all_and_serialize/0` | Graceful stop + persist for every torrent |
| `remove/2` | Stop and drop from session; optional `delete_data: true` |
| `get/2` | Low-level field access (prefer `stats/2`) |
| `version/0` | Version-derived client peer ID prefix (`ET0-6-5`, BEP 20) |
| `version/0` | Version-derived client peer ID prefix (`ET0-6-6`, BEP 20) |

## ElixirTorrent Web (desktop app)

Expand Down
24 changes: 22 additions & 2 deletions lib/elixir_torrent/acceptor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,28 @@ defmodule Acceptor do

defdelegate handshakes(peers, hash), to: Handshakes

@tcp_performance [nodelay: true, recbuf: 262_144, sndbuf: 262_144]
@tcp_connect_fallback [nodelay: true]
# `:gen_tcp.send/2` defaults to `send_timeout: :infinity`, so a peer that stops
# reading blocks its `Peer.Sender` inside `:prim_inet.send/4` forever while
# wire casts keep arriving — an unbounded mailbox on a remote-controlled
# trigger. Live: one sender sat in that call with a 20 863-message mailbox and
# 3 MB of heap, still climbing. With a bound the write fails instead, and
# `Peer.Sender.do_send/2`'s existing `{:error, _} -> stop` path tears the peer
# down. `send_timeout_close` drops the socket too: a timed-out write may be
# partial, so the wire stream is no longer trustworthy. 30s is far beyond any
# healthy write and well under the 100s peer inactivity timeout.
@send_timeout_ms 30_000
@tcp_performance [
nodelay: true,
recbuf: 262_144,
sndbuf: 262_144,
send_timeout: @send_timeout_ms,
send_timeout_close: true
]
@tcp_connect_fallback [
nodelay: true,
send_timeout: @send_timeout_ms,
send_timeout_close: true
]

@spec socket_options() :: list()
def socket_options, do: [:binary, active: false, reuseaddr: true]
Expand Down
72 changes: 62 additions & 10 deletions lib/elixir_torrent/acceptor/blacklist.ex
Original file line number Diff line number Diff line change
@@ -1,25 +1,77 @@
defmodule Acceptor.BlackList do
@moduledoc """
GenServer holding peer IDs rejected after failed handshakes (BEP 3 peer churn guard).
Peer IDs to refuse for a while after they broke the wire protocol (BEP 3 peer
churn guard).

Entries expire. This used to be a `MapSet` that only grew, so one bad frame
excluded a peer for the rest of the session, across every torrent — and on a
CGNAT host, where a torrent runs on 1-8 peers, that is the swarm. A live
ten-minute debug run banned 352 peer IDs, mostly mainstream clients, while
eight of nine torrents could not get past three connections.

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.
"""

use GenServer, start: {GenServer, :start_link, [__MODULE__, nil, [name: __MODULE__]]}

@ttl_ms 30 * 60 * 1_000
@sweep_ms 60_000
# Bounds memory on a long session. Well past what a healthy run reaches, so
# hitting it means something is banning indiscriminately.
@max_entries 2_000

@spec put(Peer.id()) :: :ok
def put(peer_id), do: GenServer.cast(__MODULE__, peer_id)

@spec member?(Peer.id()) :: boolean()
def member?(peer_id), do: GenServer.call(__MODULE__, peer_id)

@spec init(term()) :: {:ok, MapSet.t(Peer.id())}
def init(_), do: {:ok, MapSet.new()}
@spec init(term()) :: {:ok, %{optional(Peer.id()) => integer()}}
def init(_) do
schedule_sweep()
{:ok, %{}}
end

@spec handle_call(Peer.id(), GenServer.from(), %{optional(Peer.id()) => integer()}) ::
{:reply, boolean(), %{optional(Peer.id()) => integer()}}
def handle_call(peer_id, _, state) do
case Map.fetch(state, peer_id) do
{:ok, expires_at} ->
if System.monotonic_time(:millisecond) < expires_at do
{:reply, true, state}
else
{:reply, false, Map.delete(state, peer_id)}
end

:error ->
{:reply, false, state}
end
end

@spec handle_cast(Peer.id(), %{optional(Peer.id()) => integer()}) ::
{:noreply, %{optional(Peer.id()) => integer()}}
def handle_cast(peer_id, state) do
state
|> Map.put(peer_id, System.monotonic_time(:millisecond) + @ttl_ms)
|> cap()
|> then(&{:noreply, &1})
end

@spec handle_info(:sweep, %{optional(Peer.id()) => integer()}) ::
{:noreply, %{optional(Peer.id()) => integer()}}
def handle_info(:sweep, state) do
now = System.monotonic_time(:millisecond)
schedule_sweep()
{:noreply, Map.reject(state, fn {_id, expires_at} -> expires_at <= now end)}
end

defp cap(state) when map_size(state) <= @max_entries, do: state

@spec handle_call(Peer.id(), GenServer.from(), MapSet.t(Peer.id())) ::
{:reply, boolean(), MapSet.t(Peer.id())}
def handle_call(peer_id, _, state),
do: {:reply, MapSet.member?(state, peer_id), state}
defp cap(state) do
{oldest, _} = Enum.min_by(state, fn {_id, expires_at} -> expires_at end)
Map.delete(state, oldest)
end

@spec handle_cast(Peer.id(), MapSet.t(Peer.id())) :: {:noreply, MapSet.t(Peer.id())}
def handle_cast(peer_id, state),
do: {:noreply, MapSet.put(state, peer_id)}
defp schedule_sweep, do: Process.send_after(self(), :sweep, @sweep_ms)
end
1 change: 1 addition & 0 deletions lib/elixir_torrent/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule ElixirTorrentApplication do
NAT.PortMapper,
Magnet.Fetcher.Supervisor,
Magnet.Fetcher.ConnectionLimit,
Magnet.Fetcher.DhtBackgroundStore,
Magnet.Bootstrap.Supervisor
]
|> Supervisor.start_link(strategy: :one_for_one)
Expand Down
5 changes: 5 additions & 0 deletions lib/elixir_torrent/magnet/fetcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,13 @@ defmodule Magnet.Fetcher do
Logger.debug("[magnet_fetch] dht_background_peers hash=#{hash_hex} count=#{length(peers)}")
end

# This task detaches from the round that spawned it and sleeps for seconds,
# so the table can legitimately be gone by now (application shutdown).
ensure_dht_bg_table()
:ets.insert(@dht_bg_table, {hash, {:done, peers}})
:ok
rescue
ArgumentError -> :ok
end

@doc false
Expand Down
Loading