Environment
ctrld version: 1.5.0 (2026-03-04)
- Platform: Asuswrt-Merlin 3006.102.7_2 (latest stable) on RT-AX88U Pro (ARM aarch64, 1 GB RAM)
- Install: JFFS via
sh -c "$(curl -sL https://api.controld.com/dl)" -s YOUR_RESOLVER_ID forced
- Config: Control-D-managed mode (
--cd flag) → single DoH upstream, timeout 5000 ms, bootstrap_ip 76.76.2.22
- Network: IPv4 only (IPv6 disabled on client devices), 1 Gbps residential ISP, average ~50 queries/min
Symptom
After ~3 days 11 hours of clean uptime, every subsequent DoH query from ctrld to https://dns.controld.com/YOUR_RESOLVER_ID fails with:
{"level":"error","error":"could not perform request: Get \"https://76.76.2.22/YOUR_RESOLVER_ID?dns=...\": dial tcp 76.76.2.22:443: i/o timeout","time":"2026-04-21T04:55:06-07:00.058","message":"[e1f340] failed to resolve query"}
{"level":"warn","time":"2026-04-21T04:55:06-07:00.058","message":"upstream \"upstream.0\" marked as down immediately (failure count: 2410)"}
Failure counter climbs into the low thousands in under an hour. No successful queries in between. verify.controld.com via nslookup still resolves (presumably from cache or the bootstrap-time resolve), but all fresh queries fail.
Key differentiator from a real network/upstream outage
From the same router shell, at the same moment, both of these succeed instantly:
$ ping -c 3 76.76.2.22
# 0% loss, 20ms RTT
$ curl --max-time 8 -sS -v 'https://dns.controld.com/YOUR_RESOLVER_ID?dns=<b64>'
# HTTP/1.1 200 OK, TLS 1.3 handshake clean, application/dns-message body returned
WAN is healthy (default route up, no interface flaps, no dnsmasq reload events in syslog). Router uptime 3d 11h, load avg 0.12, conntrack 838/300000, ~350 MB memory free. Nothing else on the box reports network trouble.
This is a ctrld-internal issue — its Go HTTP client pool is wedged — not a network/peering problem.
Reload also hangs
$ /jffs/controld/ctrld reload
[error] failed to reload ctrld: timeout waiting for ctrld reload
[notice] Service is running
Same process continues running, same wedged state. Only a full ctrld stop && ctrld start (where stop completes in ~1s cleanly) recovers. After restart: first query returns in 40 ms.
Hypothesis
Looks like a classic Go net/http2 clientConnPool wedge — a transient network event (NAT rebind, upstream NIC reset, CDN edge maintenance) leaves a half-dead HTTP/2 conn cached in the Transport. Every subsequent client.Do() attaches to it and fails on Transport.DialContext default timeout. The curl-from-same-shell test proves the network path and TLS endpoint are healthy — only the cached conn is dead.
Context from Go upstream:
Note: ctrld v1.4.9 fixed the DoH3 analog ("Fixed DoH3 connections closing incorrectly, preventing recovery during network outages") and v1.5.0 fixed the DoT analog ("connection validation before reuse to prevent io.EOF errors from server-side idle timeouts"). Plain DoH over HTTP/1.1+H2 appears to be the remaining unpatched transport in this class.
Suggested fix
Configure the HTTP/2 transport's keepalive-ping parameters so the Go stdlib detects wedged conns and drops them:
import "golang.org/x/net/http2"
h2t, _ := http2.ConfigureTransports(httpTransport)
h2t.ReadIdleTimeout = 15 * time.Second
h2t.PingTimeout = 5 * time.Second
This issues a PING frame if 15 s pass with no frame activity; if no ACK within 5 s, closes the conn. The pool re-dials a new one on next use. Same shape as the DoT/DoH3 fixes already shipped.
Related issues
- #271 — DoH3 sibling, same shape, open since 2025-12-08
- #290 —
dns_watchdog watchdog goroutine termination bug (different layer but same "silently broken" class)
- #249 — "No nameservers available for query", identical error strings, closed without public root-cause documentation
Workaround in production
Until an upstream fix lands I've added:
- A second upstream in
ctrld.toml (Quad9 DoT, different transport code path, different provider) with failover_rcodes = ["SERVFAIL","REFUSED"] so queries route around the wedged upstream.
- A 1-minute cron watchdog on the Merlin router that does a real DNS round-trip via the ctrld listener and
SIGKILL + restarts after 5 consecutive failed probes.
Happy to share either config if useful.
More info
Resolver ID redacted — I can share privately if you need it for repro. Full error log snippet and the ctrld.toml available on request.
Environment
ctrldversion: 1.5.0 (2026-03-04)sh -c "$(curl -sL https://api.controld.com/dl)" -s YOUR_RESOLVER_ID forced--cdflag) → single DoH upstream, timeout 5000 ms, bootstrap_ip76.76.2.22Symptom
After ~3 days 11 hours of clean uptime, every subsequent DoH query from
ctrldtohttps://dns.controld.com/YOUR_RESOLVER_IDfails with:Failure counter climbs into the low thousands in under an hour. No successful queries in between.
verify.controld.comvianslookupstill resolves (presumably from cache or the bootstrap-time resolve), but all fresh queries fail.Key differentiator from a real network/upstream outage
From the same router shell, at the same moment, both of these succeed instantly:
WAN is healthy (default route up, no interface flaps, no dnsmasq reload events in syslog). Router uptime 3d 11h, load avg 0.12, conntrack 838/300000, ~350 MB memory free. Nothing else on the box reports network trouble.
This is a
ctrld-internal issue — its Go HTTP client pool is wedged — not a network/peering problem.Reload also hangs
Same process continues running, same wedged state. Only a full
ctrld stop && ctrld start(wherestopcompletes in ~1s cleanly) recovers. After restart: first query returns in 40 ms.Hypothesis
Looks like a classic Go
net/http2clientConnPoolwedge — a transient network event (NAT rebind, upstream NIC reset, CDN edge maintenance) leaves a half-dead HTTP/2 conn cached in the Transport. Every subsequentclient.Do()attaches to it and fails onTransport.DialContextdefault timeout. Thecurl-from-same-shell test proves the network path and TLS endpoint are healthy — only the cached conn is dead.Context from Go upstream:
Note:
ctrldv1.4.9 fixed the DoH3 analog ("Fixed DoH3 connections closing incorrectly, preventing recovery during network outages") and v1.5.0 fixed the DoT analog ("connection validation before reuse to prevent io.EOF errors from server-side idle timeouts"). Plain DoH over HTTP/1.1+H2 appears to be the remaining unpatched transport in this class.Suggested fix
Configure the HTTP/2 transport's keepalive-ping parameters so the Go stdlib detects wedged conns and drops them:
This issues a
PINGframe if 15 s pass with no frame activity; if no ACK within 5 s, closes the conn. The pool re-dials a new one on next use. Same shape as the DoT/DoH3 fixes already shipped.Related issues
dns_watchdogwatchdog goroutine termination bug (different layer but same "silently broken" class)Workaround in production
Until an upstream fix lands I've added:
ctrld.toml(Quad9 DoT, different transport code path, different provider) withfailover_rcodes = ["SERVFAIL","REFUSED"]so queries route around the wedged upstream.SIGKILL + restarts after 5 consecutive failed probes.Happy to share either config if useful.
More info
Resolver ID redacted — I can share privately if you need it for repro. Full error log snippet and the
ctrld.tomlavailable on request.