Skip to content

Release 0.6.7 — four bugs the instruments called healthy - #21

Merged
daniboybye merged 9 commits into
masterfrom
fix/tracker-dns-and-rate-readout
Sep 12, 2026
Merged

daniboybye merged 9 commits into
masterfrom
fix/tracker-dns-and-rate-readout

Conversation

@daniboybye

@daniboybye daniboybye commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Four fixes plus the 0.6.7 release. They are grouped in the changelog by what they had in common rather than by subsystem: each one was the engine misbehaving while its own instruments read normal, which is why all four survived so long.

What changed

A tracker whose hostname no longer resolves is dropped for the session. Hackney raises :badarg out of its connect path for a name with no A and no AAAA record instead of returning :nxdomain, so the HTTP announce returned a reason with no retry_in and fell into the generic clause with the default retry interval. tracker.openbittorrent.com, long defunct, was re-announced six times an hour across four torrents. The UDP side never had this problem — it resolves up front and answers retry_in: "never", which PeerDiscovery.Announce already uses to drop a tracker from the rotation. 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, so the most actionable message in the log — a tracker's own bencoded reason asking the user to re-add a torrent — never said which torrent. 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 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.

Dependencies: the HTTP stack behind tracker announces and BEP 19 web seeds moved up (hackney 4.7.2 to 4.7.4, h2 0.11.0 to 0.12.0, webtransport 0.4.4 to 0.4.5, quic 1.8.0 to 1.8.2), which is why this is validated by the full gate rather than a compile.

What is deliberately not fixed

The rate readout is improved, not cured, and the commit says so. The source is still piece-granular, so a bursty swarm still oscillates and a torrent slower than one piece per window reads 0 until its first piece lands. The real fix is byte-granular reporting from Peer.Controller, which already tracks downloaded_bytes per connection — that touches the hot receive path and is a separate decision.

Two narrower approaches were tried against live traffic first and are recorded in the code comment because each looks obviously right: an EMA over the quantized samples (any time constant short enough for a fast torrent collapses between a slow one's pieces — measured decaying to 1e-39), and timing each arrival against the previous arrival (a piece completing inside one tick makes the interval ~5 s, so the readout alternated between a burst and 0). The subtle one inside the current design: applying the silence ceiling while the window held progress made a true 100 KB/s read as 11 KB/s, because the ceiling shrinks as the window ages.

Verification

Every gate of .github/workflows/build-and-publish.yml job build was run locally, in CI's order, before pushing. All 12 green:

Gate Result
mix test 1770 passed (12 properties, 1758 tests)
coverage 91.2%
mix dialyzer 0 errors
mix credo --all clean
mix format --check-formatted clean
mix compile --warnings-as-errors (dev + test) clean
mix sobelow clean
mix hex.audit no retired or advisory packages
mix deps.get --check-locked (dev + test) locked
mix deps.unlock --check-unused clean
scripts/compile-property-deps.sh ok

Deployed and verified live: the engine reports 0.6.7 and ET0-6-7 is on the wire (the peer-id prefix is version-derived, BEP 20). Both previously stalled torrents resumed within 45 seconds at 17.5 and 52.4 KB/s with sane ETAs.

Not reproducible locally, so not claimed green: the Trivy filesystem scan, the Codecov upload, and the SARIF upload to code scanning.

daniboybye and others added 8 commits September 12, 2026 17:28
hackney 4.7.2 -> 4.7.4, h2 0.11.0 -> 0.12.0, webtransport 0.4.4 -> 0.4.5
and quic 1.8.0 -> 1.8.2 are the HTTP stack behind tracker announces and
BEP 19 web seeds, so the bump is validated by the full local CI gate, not
just a compile. dialyxir and ex_doc are dev-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
:add_peer_failed is emitted by add_peer/6 only after TCP connect and the
full BEP 3 handshake have completed -- we already hold the peer's id and
reserved bytes -- and means Swarm.add/4 could not start the peer
supervisor. That is the same class as :socket_handoff_failed, which was
moved out of the reachability signals in an earlier pass; the sibling one
step earlier on the same path was missed.

As a result it counted as a DialStats family failure, depressing the
measured v4/v6 yield that drives the per-family dial throttle, and wrote a
DialBackoff row toward @hard_fail_threshold. Live, one torrent held 61 of
its 62 known endpoints sticky-blocked with 4 peers connected.

The catch-all also hid the outcome that actually produced it: peer
supervisors register under {peer_id, hash}, so a fake-peer farm handing one
id out from many IPs loses the race at start_child. That is
:already_connected, which every stage downstream already understood.

Co-authored-by: Cursor <cursoragent@cursor.com>
The line read 'request failure reason: <term>' with no info_hash and no
announce URL, 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 named. Logging moves out of retry_interval_seconds/2, which is now
pure, into parallel_tracker_error/4, where the request ref has been
resolved back to its announce URL.

Two classification gaps went with it. HTTPoison surfaces hackney's connect
timeout as {:timeout, {:gen_statem, :call, [pid, :connect, 8000]}}, so the
connect-timeout case expected_tracker_failure_reason?/1 exists to cover
never matched -- 42 of 89 warnings in a 21-minute window. HTTP 4xx/5xx was
not classified at all, though a 403/404/521 from a public announce-list
entry is a dead tracker and BEP 12 already fails over tiers. Both are
expected now; a bencoded failure reason still warns.

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

Every shape a tracker task is meant to return is matched by an earlier
handle_info clause, so the catch-all fires only on something nobody wrote.
It used to drop the reply silently; the previous commit made it audible as
:unexpected_reply, which named the category and no evidence -- the same
gap that commit set out to close. It now carries the term.

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

`tracker.openbittorrent.com` has neither an A nor an AAAA record, and Hackney
raises `:error, :badarg` out of its connect path for a name with no addresses
rather than returning `:nxdomain`. `http_announce_bound/4` caught that and
returned `%Error{reason: :badarg}` with no `retry_in`, which falls into the
generic failure clause and gets the default retry interval — so the dead host
was re-announced every cycle forever, six times an hour across four torrents.

The UDP side has never had this problem: it resolves up front via
`resolve_hosts/1` and answers `retry_in: "never"`, which `PeerDiscovery.Announce`
uses to drop a tracker from the rotation for the session. That clause was written
for the defunct rarbg trackers; openbittorrent is the same species of host and
simply took a code path that could not reach it.

The `:badarg` catch now resolves the host itself and claims `{:nxdomain, host}`
with `retry_in: "never"` only when DNS genuinely has nothing. `:badarg` has other
possible sources and a permanent disable is too destructive to apply on a guess,
so anything else keeps the old opaque reason.

Found one hour after the announce-failure logging started naming the reason:
`reason=:badarg` is self-evidently not a network condition. It is the third
instance of one root pattern — a library wraps or replaces the error shape our
classification was written against, and the mismatch fails open into "retry
forever / warn forever" rather than into an error, so nothing signals that the
guard has gone dead.

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

`Torrent.Model` differenced `downloaded` over its 5 s tick, but `downloaded`
advances only when a whole piece completes and verifies. The sample was therefore
quantized to piece size: at 55 KB/s with 1 MiB pieces a piece lands every ~19 s,
so three ticks in four read exactly 0.0. Live, two torrents at 39.7% and 52.0%
both reported 0 B/s while gaining a combined 196 KB/s, and `compute_eta/4` in the
WebUI saw `kbps <= 0` and answered `:infinity` for everything.

`download_rate/1` now averages `delta / elapsed` over a 60 s window — the same
measurement a human audit performs by summing `left` deltas — holds the last
published average while a window containing progress matures, and clamps by
`piece_length / elapsed` only when nothing has arrived, since were the torrent
still running that fast the first piece of the window would already have landed.
Nothing completed for 10 minutes reports 0.

Two narrower approaches were tried against live traffic first and are recorded in
the code comment, because each looks obviously right:

  * An EMA over the quantized samples. Any time constant short enough to track a
    fast torrent still collapses between a slow torrent's pieces — measured
    decaying to 1e-39 — and one long enough for the slow torrent is uselessly
    laggy for the fast one.
  * Timing each arrival against the previous arrival. A piece completing inside a
    single tick makes the measured interval ~5 s, a real burst rate but a bad
    basis for a stall cutoff, so the readout alternated between the burst and 0.

The subtle one inside the current design: applying the silence ceiling *while* the
window had progress in it made a true 100 KB/s read as 11 KB/s, because the
ceiling shrinks as the window ages. Progress in the window has to disable it.

Measured before/after on the same live torrent: constantly 0.0 while gaining
~100 KB/s, to readings of 12-157 KB/s around a true 64 KB/s average. The residual
oscillation is inherent to a piece-granular source on a bursty swarm; a complete
fix needs byte-granular reporting from `Peer.Controller`, which already tracks
`downloaded_bytes` per connection.

Co-authored-by: Cursor <cursoragent@cursor.com>
`mark_bad`/`mark_query_failed` on an id that is not in the table are no-ops, and
the assertions said so by comparing the result against a second
`RoutingTables.new(@hash)`. The struct carries `last_changed_ms` from the
monotonic clock, so under full-suite load the two constructions land in different
milliseconds and the equality fails; it passed in isolation and failed once in ten
full runs.

The claim is that the call is a no-op, not that `new/1` is deterministic, so bind
the table once and compare against that value.

Co-authored-by: Cursor <cursoragent@cursor.com>
The peer-id prefix is derived from the package version (BEP 20), so this bump is
visible on the wire as `ET0-6-7` and worth its own commit: every peer that sees
us during this release identifies the build by it.

The changelog entry groups the four fixes by what they had in common rather than
by subsystem — each one was the engine misbehaving while its own instruments read
normal, which is why all four survived so long.

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

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/elixir_torrent/torrent/merkle.ex 80.00% 3 Missing ⚠️
lib/elixir_torrent/tracker.ex 71.42% 2 Missing ⚠️
lib/elixir_torrent/peer_discovery/announce.ex 95.65% 1 Missing ⚠️
lib/elixir_torrent/torrent/model.ex 95.65% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

`Merkle.leaf_range_response_from_disk/7` documents `{:ok, [hash()]} | {:error,
term()}` and already handles a failed `:file.open/2` that way, but the per-leaf
reads underneath it pattern-matched `{:ok, block} = :file.pread(fd, offset,
size)`. An I/O error, or a file truncated between the stat that produced
`file_length` and the read, therefore raised `MatchError` out of the middle of a
function whose contract says it returns errors.

`HashServe` catches the raise and answers `hash_reject`, which is the correct
thing to put on the wire for a hash request we cannot serve (BEP 52) — so this was
low severity in the shipped path, and wrong everywhere else: any other caller got
an exception for an ordinary disk condition, and the reject was reached by
accident rather than by decision.

`read_leaf_cache/4` now short-circuits on the first unreadable leaf, since the
cache is only useful complete — every consumer does `Map.fetch!/2` on it.

One subtlety worth naming: `:file.pread/3` answers a bare `:eof`, not an error
tuple, when there is nothing at the offset. That is *not* the same as the
legitimate case of a leaf beyond the end of the file, which BEP 52's power-of-two
leaf padding makes routine and which hashes to the zero hash without reading at
all. The padded case is now a guard clause on `offset >= file_length`, so a bare
`:eof` from an actual read means `file_length` no longer describes the file and is
reported as a failure.

+2 tests: the truncated-file contract, and a whole-file read whose last block is
short, so the ragged tail the new guard touches stays covered.

Co-authored-by: Cursor <cursoragent@cursor.com>
@daniboybye
daniboybye merged commit 4964bc4 into master Sep 12, 2026
4 checks passed
@daniboybye
daniboybye deleted the fix/tracker-dns-and-rate-readout branch September 12, 2026 19:37
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