Cover the engine, config.Size and metrics; fix the five bugs they found

internal/engine, internal/config and internal/metrics had no test file at all,
and writing them turned up five real bugs.

internal/engine (23 tests, new harness_test.go): a wired engine over a fake
upstream on loopback, a local zone, a blocklist, a cache, a query log and an
injectable clock. It pins the pipeline order end to end -- ACL deny before the
local zone, local zone before blocklist, blocklist before cache, drop sends
nothing, the concurrency and rate-limit refusals, serve-stale served with TTL 1
and refreshed once per key, truncation against a 512-byte client, and exactly
one metrics record and one query-log entry per query.

Five fixes:

- config.ParseSize ignored the unit multiplier, so 512MiB parsed as 512 bytes
  and the whole memory envelope was inert for anyone who wrote a unit.

- finalize (EDNS0 and the UDP size) ran only on the cache-hit path, so a large
  uncached answer left oversized for a 512-byte client. It now runs on the
  upstream, local-zone and blocklist paths too.

- The upstream UDP client read buffer was hard-coded to 1232 while we advertise
  dns.max_udp_size, so with a larger buffer configured every answer above 1232
  bytes was read truncated and became SERVFAIL. The pool takes the size now.

- LRU.prefetchGate was a 64-slot global cap, not the single flight its comment
  promised: a burst of stale hits launched up to 64 duplicate refreshes of one
  name. It is a per-key latch now; distinct names still refresh in parallel.

- metrics.RecordMiss and RecordStaleHit moved only the Prometheus collector, so
  the Cache page's misses and stale hits read 0 for the life of the process.

AGENTS.md gains the trap that cost the most time: this version of miekg/dns
reports a character-string over 255 bytes as zero length, so such a message
silently fails to pack, and the write failure looks like a client timeout.
This commit is contained in:
developer committed 2026-09-27 16:03:21 +00:00
1 parent 48d37db15b
commit 4f49033f1b
12 files changed
+1837 -37

No files matched your search

+25 -2
View File
@@ -283,13 +283,36 @@ type Pool struct {
// rng backs the "random" policy.
rngMu sync.Mutex
rng *rand.Rand
// udpSize is the read buffer for UDP clients. It must be at least as large
// as the EDNS0 buffer we advertise upstream: a UDP datagram bigger than
// this buffer is read truncated, fails to unpack, and turns into a
// SERVFAIL. See clientFor.
udpSize uint16
}
// DefaultClientUDPSize is the read buffer used when the caller does not say
// otherwise. It matches the historical hard-coded client buffer.
const DefaultClientUDPSize uint16 = 1232
// NewPool builds a pool from config. Bootstraps are used for named upstreams;
// anything unresolved falls back to the system resolver.
func NewPool(cfg config.Upstream) *Pool {
return NewPoolWithUDPSize(cfg, DefaultClientUDPSize)
}
// NewPoolWithUDPSize builds a pool whose UDP clients read up to udpSize bytes.
// Callers that advertise an EDNS0 buffer to their upstreams must pass that
// same value here, or every answer larger than it is lost.
func NewPoolWithUDPSize(cfg config.Upstream, udpSize uint16) *Pool {
// 512 is the floor a resolver may assume; below it we would truncate
// replies a plain client asked for.
if udpSize < dns.MinMsgSize {
udpSize = dns.MinMsgSize
}
p := &Pool{
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
udpSize: udpSize,
}
p.state.Store(buildState(cfg, nil, false))
return p
@@ -406,7 +429,7 @@ func (p *Pool) clientFor(u *Upstream) *dns.Client {
if c, ok := p.clients.Load(u); ok {
return c.(*dns.Client)
}
nc := &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second}
nc := &dns.Client{Net: "udp", UDPSize: p.udpSize, Timeout: 3 * time.Second}
switch u.cfg.Proto {
case "tls":
nc = &dns.Client{