Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,74 @@
# Changelog

## 0.6.7 - 2026-09-12

A measurement release. Every fix here is a case where the engine was doing
something wrong *and the instruments said it was fine* — a tracker retried
forever because its failure had no reason attached, healthy peers marked
unreachable because a local error was counted as a network one, and a download
speed of 0 B/s reported on a torrent moving at 100 KB/s.

### Fixed

- A tracker whose hostname no longer resolves is dropped for the session instead
of being announced to forever. Hackney raises `:badarg` out of its connect path
for a name with no A and no AAAA record rather than returning `:nxdomain`, so
the HTTP announce returned a reason with no `retry_in` and got the default retry
interval — `tracker.openbittorrent.com`, defunct, was re-announced six times an
hour across four torrents. The UDP side never had this problem because it
resolves up front and answers `retry_in: "never"`. The `:badarg` catch now
resolves the host itself and only writes a tracker off when DNS genuinely has
nothing; other sources of `:badarg` keep the old opaque reason, because a
permanent disable is too destructive to apply on a guess.
- Announce failures name the torrent and the announce URL. The line read
`request failure reason: <term>` with neither, which made the most actionable
message in the log useless: a tracker's own bencoded failure reason asks the
user to re-add a torrent it never identified. Two classification gaps went with
it — HTTPoison surfaces hackney's connect timeout as
`{:timeout, {:gen_statem, :call, [pid, :connect, 8000]}}`, which the
connect-timeout case never matched (42 of 89 warnings in a 21-minute window),
and HTTP 4xx/5xx was not classified at all even though a 403 or 521 from a
public announce-list entry is a dead tracker that BEP 12 already fails over.
- `:add_peer_failed` no longer counts as a peer being unreachable. It is emitted
only *after* TCP connect and the full BEP 3 handshake succeed — we already hold
the peer's id — and means the supervisor could not start, which is a local
fault. It was depressing the measured per-family dial yield that drives the
address-family throttle and writing `DialBackoff` rows toward the hard-fail
threshold: one torrent held 61 of its 62 known endpoints sticky-blocked while
connected to 4 peers. The catch-all also hid the outcome behind it — peer
supervisors register under `{peer_id, hash}`, so a farm handing one id out from
many IPs loses the race at `start_child`, which is `:already_connected`.
- The download speed readout no longer reports 0 B/s on a moving torrent.
`Torrent.Model` differenced `downloaded` over its 5 s tick, but that counter
advances only when a whole piece verifies, so the sample was quantized to piece
size: at 55 KB/s with 1 MiB pieces three ticks in four read exactly 0.0. Two
live torrents reported 0 B/s while gaining a combined 196 KB/s, and the ETA
derived from it was `:infinity` throughout. The rate is now averaged over a 60 s
window, held while a window containing progress matures, and clamped by
`piece_length / elapsed` only when nothing has arrived at all. A torrent with
nothing completed for 10 minutes reports 0. This is an improvement, not a cure:
the source is still piece-granular, so a swarm delivering in bursts still
oscillates, and a torrent slower than one piece per window reads 0 until its
first piece lands.

- A disk error while serving a BEP 52 hash request returns an error instead of
raising. `Merkle.leaf_range_response_from_disk/7` documents
`{:error, term()}` and handles a failed `:file.open/2` that way, but the
per-leaf reads underneath it pattern-matched `{:ok, block} = :file.pread/3`, so
an I/O error — or a file truncated between the stat that produced `file_length`
and the read — raised `MatchError` from the middle of the function. `HashServe`
catches that and answers `hash_reject`, which is correct on the wire but
reached by the wrong path; any other caller got an exception for a disk
condition. `:file.pread/3` also answers a bare `:eof` rather than an error
tuple, which is now distinguished from the legitimate padded-leaf case that
reads no bytes at all.

### Changed

- The HTTP stack behind tracker announces and BEP 19 web seeds moved up: hackney
4.7.2 → 4.7.4, h2 0.11.0 → 0.12.0, webtransport 0.4.4 → 0.4.5, quic 1.8.0 →
1.8.2.

## 0.6.6 - 2026-08-27

A swarm-health release. Under CGNAT, where a torrent runs on a handful of peers,
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.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)
[![GitHub release](https://img.shields.io/badge/release-0.6.7-181717?logo=github)](https://github.com/daniboybye/ElixirTorrent/releases/tag/0.6.7) [![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.7) [![HexDocs](https://img.shields.io/badge/hexdocs-0.6.7-8E44AD)](https://hexdocs.pm/elixir_torrent/0.6.7) [![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.6"}
{:elixir_torrent, "~> 0.6.7"}
]
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-6`, BEP 20) |
| `version/0` | Version-derived client peer ID prefix (`ET0-6-7`, BEP 20) |

## ElixirTorrent Web (desktop app)

Expand Down
24 changes: 18 additions & 6 deletions lib/elixir_torrent/acceptor/connection/handshakes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,17 @@ defmodule Acceptor.Connection.Handshakes do
defp increment_failure(failures, reason),
do: Map.update(failures, reason, 1, &(&1 + 1))

# Dial outcome → Peer.DialStats family bump. :socket_handoff_failed is emitted
# only after TCP/uTP connect and the BitTorrent handshake succeeded; the peer
# proved reachable and local OTP churn (register/handoff/activate) failed.
# Count that as a family :ok so v4/v6 yield isn't poisoned by our own process
# wiring. :already_connected / :not_connectable are neutral (:skip).
# Dial outcome → Peer.DialStats family bump. :socket_handoff_failed and
# :add_peer_failed are both emitted only after TCP/uTP connect and the
# BitTorrent handshake succeeded; the peer proved reachable and local OTP
# churn (supervisor start / register / handoff / activate) failed. Count them
# as a family :ok so v4/v6 yield isn't poisoned by our own process wiring.
# :already_connected / :not_connectable are neutral (:skip).
@doc false
@spec dial_reachability_outcome(term()) :: :ok | :skip | :fail
def dial_reachability_outcome(:socket_handoff_failed), do: :ok
def dial_reachability_outcome(reason)
when reason in [:socket_handoff_failed, :add_peer_failed],
do: :ok

def dial_reachability_outcome(reason) when reason in [:already_connected, :not_connectable],
do: :skip
Expand Down Expand Up @@ -946,6 +949,15 @@ defmodule Acceptor.Connection.Handshakes do
safe_close(socket)
{:error, :max_peers}

# Peer supervisors are registered under {peer_id, hash}, so a second
# endpoint presenting a peer id we already hold loses the race here.
# `already_connected?/2` screens the batch, but up to @batch dials run
# concurrently and a fake-peer farm hands the same id out from many IPs,
# so the duplicate is only observable at registration.
{:error, {:already_started, _pid}} ->
safe_close(socket)
{:error, :already_connected}

_ ->
safe_close(socket)
{:error, :add_peer_failed}
Expand Down
16 changes: 12 additions & 4 deletions lib/elixir_torrent/peer/dial_backoff.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,18 @@ defmodule Peer.DialBackoff do
@sticky_reasons [:churn | @hard_failures]

# These outcomes don't reflect endpoint reachability — don't count them toward
# the fail threshold and don't write a block row. :socket_handoff_failed means
# connect+handshake succeeded and only local handoff failed; Endpoints already
# records :churn when registration happened, so DialBackoff must not double-block.
@non_reachability_reasons [:already_connected, :not_connectable, :socket_handoff_failed]
# the fail threshold and don't write a block row. :socket_handoff_failed and
# :add_peer_failed both mean connect+handshake succeeded and only a local step
# failed (supervisor start, then handoff); Endpoints already records :churn
# when registration happened, so DialBackoff must not double-block. Blocking on
# them wrote off endpoints we had just spoken BEP 3 to: live, one torrent held
# 61 of 62 endpoints sticky-blocked with 4 peers connected.
@non_reachability_reasons [
:already_connected,
:not_connectable,
:socket_handoff_failed,
:add_peer_failed
]

@spec child_spec(term()) :: Supervisor.child_spec()
def child_spec(_) do
Expand Down
107 changes: 81 additions & 26 deletions lib/elixir_torrent/peer_discovery/announce.ex
Original file line number Diff line number Diff line change
Expand Up @@ -637,17 +637,22 @@ defmodule PeerDiscovery.Announce do

def handle_info({ref, %Tracker.Error{retry_in: retry_in} = error}, state)
when not is_nil(retry_in) do
timeout = retry_interval_seconds(retry_in, error.reason)
{:noreply, parallel_tracker_error(state, ref, timeout)}
timeout = retry_interval_seconds(retry_in)
{:noreply, parallel_tracker_error(state, ref, timeout, error.reason)}
end

def handle_info({ref, %Tracker.Error{reason: reason}}, state) do
timeout = retry_interval_seconds(nil, reason)
{:noreply, parallel_tracker_error(state, ref, timeout)}
timeout = retry_interval_seconds(nil)
{:noreply, parallel_tracker_error(state, ref, timeout, reason)}
end

def handle_info({ref, _}, state) do
{:noreply, parallel_tracker_error(state, ref, Tracker.default_failure_interval())}
# Every shape a tracker task is supposed to return is matched above, so this
# clause means we got something nobody wrote. Carry the term — naming only the
# category would leave the same "reason with no evidence" this module just
# stopped emitting.
def handle_info({ref, other}, state) do
timeout = Tracker.default_failure_interval()
{:noreply, parallel_tracker_error(state, ref, timeout, {:unexpected_reply, other})}
end

@spec start_parallel_tier(%__MODULE__{}, non_neg_integer(), [String.t()]) :: %__MODULE__{}
Expand Down Expand Up @@ -1131,29 +1136,43 @@ defmodule PeerDiscovery.Announce do
end
end

@spec parallel_tracker_error(%__MODULE__{}, reference(), non_neg_integer()) :: %__MODULE__{}
defp parallel_tracker_error(%__MODULE__{} = state, ref, timeout_seconds) do
@spec parallel_tracker_error(%__MODULE__{}, reference(), non_neg_integer(), term()) ::
%__MODULE__{}
defp parallel_tracker_error(%__MODULE__{} = state, ref, timeout_seconds, reason) do
{meta, requests} = Map.pop(state.requests, ref)

case meta do
nil ->
state

{:scrape, _announce} ->
{:scrape, announce} ->
# Scrape failures are non-fatal — the tracker's announce endpoint may
# still work. Drop the request, leave parallel state and `disabled`
# untouched. Retry on next @scrape_interval_ms tick.
log_scrape_failure(state.hash, announce, reason)
%{state | requests: requests}

{announce, tier_index, _tracker_index} ->
state
|> Map.put(:requests, requests)
|> Map.update!(:peers, &Map.delete(&1, announce))
|> put_tracker_retry_after(announce, timeout_seconds)
|> dec_tier_batch(tier_index)
log_tracker_failure(state.hash, announce, reason)
drop_failed_announce(state, requests, announce, tier_index, timeout_seconds)
end
end

@spec drop_failed_announce(
%__MODULE__{},
map(),
String.t(),
non_neg_integer(),
non_neg_integer()
) :: %__MODULE__{}
defp drop_failed_announce(state, requests, announce, tier_index, timeout_seconds) do
state
|> Map.put(:requests, requests)
|> Map.update!(:peers, &Map.delete(&1, announce))
|> put_tracker_retry_after(announce, timeout_seconds)
|> dec_tier_batch(tier_index)
end

@spec put_tracker_retry_after(%__MODULE__{}, String.t(), non_neg_integer()) :: %__MODULE__{}
defp put_tracker_retry_after(%__MODULE__{} = state, announce, timeout_seconds) do
deadline_ms = System.monotonic_time(:millisecond) + timeout_seconds * 1_000
Expand Down Expand Up @@ -1529,30 +1548,50 @@ defmodule PeerDiscovery.Announce do
end
end

@spec retry_interval_seconds(term(), term()) :: non_neg_integer()
defp retry_interval_seconds(retry_in, _reason) when is_integer(retry_in) and retry_in >= 0,
do: retry_in
@spec retry_interval_seconds(term()) :: non_neg_integer()
defp retry_interval_seconds(retry_in) when is_integer(retry_in) and retry_in >= 0, do: retry_in

defp retry_interval_seconds(retry_in, _reason) when retry_in in ["never", :never], do: 0
defp retry_interval_seconds(retry_in) when retry_in in ["never", :never], do: 0

defp retry_interval_seconds(retry_in, _reason) when is_binary(retry_in) do
defp retry_interval_seconds(retry_in) when is_binary(retry_in) do
case parse_retry_in_seconds(retry_in) do
nil -> Tracker.default_failure_interval()
n -> n
end
end

defp retry_interval_seconds(_, reason) do
# Dead public trackers (NXDOMAIN / black-hole UDP / connect timeouts) are the
# common case in real announce-lists — BEP 12 already fails over tiers. Warn
# only on unexpected reasons so server.log stays readable under CGNAT churn.
defp retry_interval_seconds(_), do: Tracker.default_failure_interval()

# Logged from `parallel_tracker_error/4`, where the request ref has been
# resolved back to its announce URL. The previous "request failure reason: …"
# line carried neither the info_hash nor the tracker, so nothing in it could
# be acted on — worst for the tracker's own bencoded `failure reason` text,
# which asks the *user* to do something ("Please redownload the torrent…")
# about a torrent it never named.
@spec log_tracker_failure(Torrent.hash(), String.t(), term()) :: :ok
defp log_tracker_failure(hash, announce, reason) do
message =
"[tracker] announce_failed hash=#{Torrent.hex_encoded_hash(hash)} " <>
"announce=#{announce} reason=#{inspect(reason)}"

# Dead public trackers are the common case in real announce-lists and BEP 12
# already fails over tiers, so only unexpected reasons warn — otherwise
# server.log is unreadable under CGNAT churn.
if expected_tracker_failure_reason?(reason) do
Logger.debug("request failure reason: #{inspect(reason)}")
Logger.debug(message)
else
Logger.warning("request failure reason: #{inspect(reason)}")
Logger.warning(message)
end
end

Tracker.default_failure_interval()
# BEP 48 scrape is an optional side channel; its failure never blocks announce,
# so it stays at :debug regardless of reason.
@spec log_scrape_failure(Torrent.hash(), String.t(), term()) :: :ok
defp log_scrape_failure(hash, announce, reason) do
Logger.debug(
"[tracker] scrape_failed hash=#{Torrent.hex_encoded_hash(hash)} " <>
"announce=#{announce} reason=#{inspect(reason)}"
)
end

@doc false
Expand All @@ -1571,6 +1610,22 @@ defmodule PeerDiscovery.Announce do
do: true

def expected_tracker_failure_reason?({:nxdomain, _}), do: true

# HTTPoison surfaces hackney's connect timeout as the `gen_statem` call that
# timed out, not as a bare `:timeout`, so the connect-timeout case this list
# exists to cover never matched and every one of them warned instead.
def expected_tracker_failure_reason?({:timeout, {:gen_statem, :call, _}}), do: true

# The endpoint answered, but not as a working tracker: 404 (gone), 403
# (private/banned without a passkey) and Cloudflare's origin 5xx (521 "web
# server is down") are all routine in a public announce-list, and the
# per-tracker retry cooldown already spaces the retries. A bencoded
# `failure reason` string is deliberately not covered — that is the tracker
# speaking BEP 3 to us and can be actionable.
def expected_tracker_failure_reason?({:http_status, status})
when is_integer(status) and status >= 400,
do: true

def expected_tracker_failure_reason?(_), do: false

@spec extract_tiers(map()) :: [list(String.t())]
Expand Down
Loading