DHT: one address family's bucket maintenance starves the other's neighbourhood maintenance

Author: dangowrtCreated Sep 1, 2026Updated Sep 4, 2026
Labelsbugscope:3rdpartydependencies

What is the issue?

third-party/dht is pinned at 38c9f261, whose dht_periodic gates neighbourhood maintenance for both address families on a single soon flag that either family's bucket_maintenance can set:

c
soon |= bucket_maintenance(AF_INET);
soon |= bucket_maintenance(AF_INET6);

if(!soon) {
    if(mybucket_grow_time >= now.tv_sec - 150)
        soon |= neighbourhood_maintenance(AF_INET);
    if(mybucket6_grow_time >= now.tv_sec - 150)
        soon |= neighbourhood_maintenance(AF_INET6);
}

The two do different jobs: bucket maintenance re-confirms nodes already in the table, neighbourhood maintenance is what grows it. So a family whose nodes never answer stops the other family from finding anyone.

A family gets stuck there permanently, not gradually. buckets6 is calloced so b->time starts at 0, and with max_count == 128 the root bucket is already stale on the first dht_periodic (to = MAX(600 / (128/8), 30) == 37). b->time only advances in new_node() under confirm == 2 — a reply to our own query. If nothing in that family ever replies, bucket_maintenance returns 1 on every round, forever, and the other family never runs neighbourhood maintenance again.

This needs no unusual configuration: a host with no working route for one family still learns that family's addresses, because they arrive in the nodes/ nodes6 of the other family's replies. Three such nodes were enough to reproduce.

Transmission is affected because tr_dht_impl creates a single dual-family instance — libtransmission/tr-dht.cc:162 passes both udp4_socket_ and udp6_socket_ to dht_init(), which is exactly the shape that hits this.

Secondary effect: soon also drives confirm_nodes_time (5–15 s when set, 60–180 s otherwise), so an affected client wakes up far more often while doing less.

Measurements. Offline harness, one host, no public DHT traffic — three unresponsive IPv6 nodes inserted with dht_insert_node, one live local IPv4 responder, 140 s per arm:

arm rounds IPv6 bucket maint. IPv4 neighbourhood maint.
as shipped, 3 unresponsive IPv6 nodes 19 19/19 0
patched, same 3 nodes 15 15/15 14/15
control: as shipped, no IPv6 nodes 16 0 15/16

The IPv6 half is equally dead in the first two arms, which isolates the scheduling as the only variable. Also confirmed on a v4-only OpenWrt router (MT7621) against the live DHT: 23 rounds, IPv6 bucket maintenance claimed 22, neighbourhood maintenance ran once.

Fix, gating each family on its own bucket maintenance and keeping the aggregate for the timer:

c
soon4 = bucket_maintenance(AF_INET);
soon6 = bucket_maintenance(AF_INET6);

if(!soon4 && mybucket_grow_time >= now.tv_sec - 150)
    soon4 |= neighbourhood_maintenance(AF_INET);
if(!soon6 && mybucket6_grow_time >= now.tv_sec - 150)
    soon6 |= neighbourhood_maintenance(AF_INET6);

soon = soon4 | soon6;

Within each family either bucket or neighbourhood maintenance runs, never both, so a round still sends at most two maintenance queries — the query budget is unchanged, the work is just redistributed.

Fixed upstream in jech/dht as be5684a. transmission/dht#6 cherry-picks it onto post-0.27-transmission, together with c723931, an unrelated one-character fix merged upstream alongside it where search_step could return without sending on a wakeup scheduled for exactly the right second. @tearfur — happy to raise the submodule bump here as a follow-up once that lands, or to leave it to you.

Possibly related: #6878 reports an IPv6-only host where DHT is suspected; this would be the mirror case.

Which application of Transmission?

transmission-daemon

Which version of Transmission?

All, including mainthird-party/dht @ jech/dht@38c9f261

Source: transmission/transmission