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

+10 -4
View File
@@ -314,7 +314,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Group-level domain policy.
if e.groupBlocks(decision, qname) {
res.Msg = blockedResponse(q, e.blockCfg(), nil)
res.Msg = e.finalize(blockedResponse(q, e.blockCfg(), nil), q, req)
res.Action = qlog.ActionBlock
res.BlockSource = "acl:" + decision.Group
e.finish(req, res, start, decision)
@@ -324,7 +324,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Local zone data.
if store := e.local.Get(); store != nil && store.Count() > 0 {
if local, ok := e.answerLocal(q, store); ok {
res.Msg = local
res.Msg = e.finalize(local, q, req)
res.Action = qlog.ActionAllow
e.finish(req, res, start, decision)
return res
@@ -348,7 +348,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
e.finish(req, res, start, decision)
return res
default:
res.Msg = e.blockedMsg(q, blocked)
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
res.Action = qlog.ActionBlock
res.BlockSource = blocked.Source
res.CacheHit = false
@@ -407,7 +407,13 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
return res
}
metrics.RecordUpstreamLatency(addr, time.Since(upStart))
reply := e.fromUpstream(q, up, req)
// finalize is what makes the answer safe to put on the wire: it echoes the
// client's question and id verbatim and truncates to the client's
// advertised buffer. Without it an uncached multi-kilobyte answer left
// here oversized for a 512-byte UDP client, which no responder can deliver
// and the client silently drops. (The cache-hit path above has always
// done this; the uncached path did not.)
reply := e.finalize(e.fromUpstream(q, up, req), q, req)
res.Msg = reply
res.Upstream = addr
res.Action = qlog.ActionAllow