Skip to content

DoH upstream wedges with dial tcp <bootstrap_ip>:443: i/o timeout after ~3-day uptime; ctrld reload hangs, only full stop+start recovers #308

Description

@git-jerk

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
  • #290dns_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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions