Skip to content

Decide a DHT values peer's family by transport, not by blob length - #23

Merged
daniboybye merged 2 commits into
masterfrom
fix/dht-values-transport-family
Sep 14, 2026
Merged

daniboybye merged 2 commits into
masterfrom
fix/dht-values-transport-family

Conversation

@daniboybye

Copy link
Copy Markdown
Owner

Stacked on #21 (whose branch now also carries the merged #22), so the diff here is one commit. Merge #21 first and this retargets to master cleanly.

Found while investigating why two torrents had zero IPv6 candidates out of 27 and 34 known endpoints, on a host where IPv6 is the family that works (measured 23.5% dial yield against 0.9% on IPv4).

The defect

decode_value_peers/1 tested the IPv6 stride before the IPv4 one:

rem(size, @ipv6_peer_info_size) == 0 -> Compact.decode_ipv6_peers(blob)
rem(size, @peer_info_size) == 0      -> Compact.decode_peers(blob)

18 is a multiple of 6. A values string packing three compact IPv4 peers has exactly the size of one compact IPv6 peer, and was decoded as one — an IPv6 address invented out of three IPv4 ones. BEP 5 nominally puts one peer per string, but implementations pack them, so this is a shape we actually meet.

Why it matters more than "one bad endpoint"

A phantom endpoint consumes a dial slot and can never connect. Worse, its failure is recorded against the IPv6 family in DialStats, which is what drives the per-family dial throttle. So the wrong guess teaches the engine that IPv6 does not work — on a host where IPv6 is the only family with a usable success rate.

That is the same failure shape as the :add_peer_failed misclassification fixed in #21: a local mistake counted as a network verdict, feeding a control loop that then makes a worse decision.

The fix

BEP 32 runs the IPv6 DHT as a separate DHT, which makes the transport the authority: a response that came back over IPv6 carries IPv6 peers. The family is already in hand at the call site — apply_trusted_response/6 has the remote ip — so it is passed down and consulted first.

Length remains the tie-breaker where the family's own unit does not divide the string (a lone IPv4 peer answered over the v6 DHT, say), and callers that pass no family keep the previous length-only behaviour, so nothing outside this path changes.

This is not the answer to the v6=0 question

Worth being explicit, because it would be easy to read this PR as closing that investigation. It does not: this bug invents IPv6 endpoints rather than losing them. What I established while looking:

Check Finding
DHT IPv6 routing table healthy — 90 IPv6 nodes against 63 IPv4
IPv6 socket open, bootstrapped, and select_socket/2 correctly routes IPv6 destinations to it
Lookup shortlist for the affected hash balanced 4 IPv6 / 4 IPv4, so IPv6 nodes are being queried
PEX decodes added6
Tracker decodes peers6

The instrument that would settle it already exists — [dht] get_peers ipv6_peers=N total=M in apply_trusted_response/6 — but it is Logger.debug, which a production release purges at compile time. That is precisely why the question has stayed open. The ELIXIR_TORRENT_DEBUG_BUILD=1 switch added in the WebUI repo keeps those call sites, so the next step is a diagnostic build rather than more code reading.

Verification

All 12 gates of .github/workflows/build-and-publish.yml job build, run locally in CI's order:

Gate Result
mix test 1776 passed (12 properties, 1764 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, mix hex.audit clean
mix deps.get --check-locked (dev + test), mix deps.unlock --check-unused clean
scripts/compile-property-deps.sh ok

+4 tests covering both readings of an 18-byte blob, the length fallback, and the unchanged no-family default. The changelog entry joins the unreleased 0.6.7 section.

Not reproducible locally, so not claimed green: the Trivy filesystem scan, the Codecov upload, and the SARIF upload to code scanning. CI does not trigger automatically on a PR whose base is not master, so the workflow was dispatched manually against this branch.

@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/elixir_torrent/dht/krpc.ex 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Base automatically changed from fix/tracker-dns-and-rate-readout to master September 12, 2026 19:37
daniboybye and others added 2 commits September 14, 2026 08:55
`decode_value_peers/1` tested `rem(size, @ipv6_peer_info_size) == 0` before the
IPv4 stride. **18 is a multiple of 6**, so a `values` string packing three compact
IPv4 peers has exactly the size of one compact IPv6 peer and was decoded as one —
an IPv6 address invented out of three IPv4 ones. BEP 5 nominally puts one peer per
string, but implementations pack them, so this is a shape we do meet.

A phantom endpoint is not merely wasted work. It consumes a dial slot, can never
connect, and its failure is recorded against the **IPv6** family in `DialStats` —
which is what drives the per-family dial throttle. So the wrong guess teaches the
engine that IPv6 does not work, on a host where IPv6 is the family that does
(measured 23.5% yield against 0.9% on v4). That is the same failure shape as the
`:add_peer_failed` misclassification fixed earlier: a local mistake counted as a
network verdict, feeding a control loop.

BEP 32 runs the IPv6 DHT as a separate DHT, which makes the transport the
authority — a response that came back over IPv6 carries IPv6 peers. The family is
already in hand at the call site (`apply_trusted_response/6` has the remote `ip`),
so it is now passed down and consulted first. Length remains the tie-breaker where
that family's own unit does not divide the string — a lone IPv4 peer answered over
the v6 DHT, say — and callers that pass no family keep the previous
length-only behaviour, so nothing else changes.

Found while investigating why two torrents had **zero** IPv6 candidates out of
27 and 34 known endpoints. This is not that answer: the bug invents v6 endpoints
rather than losing them, and both torrents' DHT shortlists were a balanced 4 v6 /
4 v4, so v6 nodes were being queried. Recorded so the real question stays open.

+4 tests covering both readings of an 18-byte blob, the length fallback, and the
unchanged no-family default.

Co-authored-by: Cursor <cursoragent@cursor.com>
The section was written on the 12th; the tag goes out on the 14th, and a
changelog that predates its own release is a small lie that ages badly.

Co-authored-by: Cursor <cursoragent@cursor.com>
@daniboybye
daniboybye force-pushed the fix/dht-values-transport-family branch from 12792ea to 60b7c47 Compare September 14, 2026 05:58
@daniboybye
daniboybye merged commit 504fd34 into master Sep 14, 2026
4 checks passed
@daniboybye
daniboybye deleted the fix/dht-values-transport-family branch September 14, 2026 06:04
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