46 KiB
Milestone 7: serving pipeline composition (PLAN Phase 7)
Goal: nxdns run serves DNS end to end — rate limit → parse → group → local records →
forward zones → filtering → safe-search → cache → upstream → CNAME uncloaking → cache store →
async query log — with client auto-materialization, pause/resume, and a composition root that
starts every background loop and shuts down cleanly on SIGINT/SIGTERM.
Ground truth: PLAN.md §4 (pipeline diagram, lines 151-173), §6 (DNS behavior), §7.2 (auto-materialization), §8 (cache key), §10 (rate limit), §11.4/§11.6 (logging, disk), Phase 7 text at PLAN.md:606-608. Verified Zig facts: specs/research/zig-0.16-api-notes.md plus the scratchpad note m7-explore-zig.md (signals, Io.Group, cancelable net ops) — the load-bearing facts are restated inline below where a session needs them.
Rulings (engineering calls resolving PLAN ambiguities — binding)
- Scope: Phase 7 owns
nxdns runend to end:src/app.zig(composition root),src/server/shutdown.zig(signal → event), thecli.runRunbody, and both listener edits. - Signals: no signalfd/epoll/timerfd.
std.posix.sigactionhandler for INT and TERM that callsstd.Io.Event.seton a file-scope event (setis async-signal-safe on the Threaded Linux backend: raw futex wake, no locks — Threaded.zig:1105,17235). - Locking:
DnsCacheandRateLimitereach get onestd.Io.Mutexowned by the handler. Household scale makes contention irrelevant; sharding is over-engineering. - Null snapshot (
Manager.acquirereturns null before the first successful reload): fail open — skip group/filter/safe-search, use the default group semantics, countunfiltered_queries. DNS availability beats filtering for a household. The app attempts one synchronousreloadbefore serving; a failure warns and serving starts anyway. - Cache scope: one global cache, no group in the key (PLAN §8). Safe because blocked responses are never cached. Safe-search responses are not cached either (13 domains, rare; caching them under the rewritten name would force answer re-encoding on hit). Paused-mode upstream answers cache normally (they are ordinary upstream responses). Forward-zone responses cache normally (PLAN §6.5) under the same global key.
- Local vs forward zone: local records win (checked first; PLAN §6.4 puts them before everything but rate limit/parse).
- Forward zones bypass filtering, safe-search, and uncloaking (PLAN §6.5). They are logged
with
upstream= the resolver's canonical text. - Rate limit position: after the 12-byte header read (the REFUSED reply needs the ID),
before full parse. Refused queries are counted (
Stats.refused), never query-logged. Localhost is not exempt (PLAN §10's localhost relaxation is the Phase 8 API limiter only). - Non-IN qclass: skip filtering/cache/safe-search/uncloak, go straight upstream, log normally. Rare and harmless; special-casing CHAOS is scope creep.
- Safe-search mechanics (per safesearch.zig:56 doc): the outgoing query's QNAME becomes the target; the reply to the client keeps the original question, starts with a synthesized CNAME original→target, then copies only the A/AAAA answer records from the upstream response verbatim (their RDATA is raw bytes, so no compression-pointer re-encoding; other rtypes are skipped). Applies only on the upstream path — never to local records or forward zones.
- CNAME uncloaking (PLAN §6.3): walk the answer-section CNAME chain of the upstream
response — no re-resolution. Depth 8 = at most 8 CNAME links followed. Each target is
normalized and evaluated for the same group; a blocked target synthesizes a blocked
response for the ORIGINAL qname/qtype.
block_reason="cname:" ++ @tagName(reason)(fits the 32-byte Entry field). Uncloaked-blocked responses are not cached. Skipped when paused, for forward zones, and for non-IN. - Local CNAME records are returned as-is (writeAnswers emits the CNAME and stops, milestone-5 As-built). The client re-queries the target, which then flows through the full pipeline — that is how §6.4's "targets resolve through the normal pipeline" is satisfied.
- ECS (PLAN §6.1):
strip(default) removes a client-sent ECS option from the outgoing query by rebuilding it (header + question + OPT sans option 8); other options are preserved.forwardsends the query unchanged and puts the raw ECS option payload (≤dns_cache.max_ecs_len= 40 bytes) into the cache key. If a rewritten query cannot fit the 512-byte build buffer (freak OPT payloads), the original is forwarded unchanged,Stats.ecs_strip_failedcounts it, and the query is excluded from the cache in both directions (the subnet reached the resolver; the strip-mode key says nothing about it). - Client auto-materialization (PLAN §7.2): never a hot-path DB write. The handler records
the client in a mutex-guarded fixed-capacity pending table; a background loop flushes every
60 s with its OWN config.db connection:
INSERT ... hand_edited=0 ON CONFLICT(ip) DO UPDATE last_seen. At most one write per client per flush. Table full → drop silently (counted). - Snapshot staleness: a materialized client reaches the matcher snapshot only on the next
manager reload. Correct behavior does not depend on it:
groupForClientalready falls back to prefix/default, and materialized rows exist for UI visibility (Phase 8 reads the DB directly). No reload is triggered per client. - Client pruning (PLAN §7.2 + milestone-6 ruling 1): the same background loop prunes
daily:
DELETE FROM clients WHERE hand_edited=0 AND last_seen < now - retention_days(logging.retention_days, same knob as the query log — documented).retention.zigstays frozen and querylog-only.last_seenis the only signal (§3.6 forbids cross-DB joins). - Disk gate: the client flush/prune loop skips its pass when
writesAllowed()is false; the logger holds its batches. As built: milestone-6.md:410 assigned the manager's consumption to Phase 7 and no seam existed, so S6 addedManager.monitor: ?*disk_monitor.Monitor(set by the composition root afterinit, beforerunScheduler) plus arefreshes_gatedcounter read throughrefreshesGated(). Only scheduled passes are gated:runScheduler's periodic pass and the source-refresh half of the startup pass.reloadandrefreshAllstay ungated (operator actions). The startup pass'sreloadLockedruns ahead of the gate: publishing an already-compiled snapshot is a read, and a full disk must not cost the household its filtering as well as its downloads. A gated pass logs one warn line and counts. - Pause/resume (PLAN §13.1 mechanism): suspends FILTERING only (evaluate + safe-search +
uncloak are skipped); everything else — local records, forward zones, cache, upstream,
logging — runs unchanged. Global, not per-group. In-memory only; a restart resumes
filtering (safe default). State: one
std.atomic.Value(i64)— 0 = active, -1 = paused indefinitely, else = paused until that unix second (auto-resume by compare, no timer task). Phase 8's endpoints will callpauseFor/unpauseon the shared pointer. - Paused queries log
blocked=false, block_reason="";Stats.paused_queriescounts them. Evaluation is skipped entirely (not evaluated-then-ignored). - Log field semantics:
response_time_us= wall time fromhandleentry to reply ready.cache_hit: true/false on the upstream path, null for local/forward/refused-never-logged;upstream: "" on cache hit, "local" for local records, resolver text for forward zones, the pool endpoint's canonical text for upstream answers (pool already tracks which entry answered — if unavailable without new plumbing, "" and a spec note; do not rebuild the pool for it:upstreammay be "pool" as the compromise, S2 decides and records).block_reason=@tagName(matcher.Reason)truncated to 32,"cname:"-prefixed for uncloaking. Malformed/dropped queries are never query-logged (handler stats only). - Connections: config.db — one opened by the app (migrate/bootstrap/readConfig, then handed to the manager for reloads), plus one dedicated to the client loop. querylog.db — one for the log writer, one for retention (retention.zig:79 contract). Four total.
- Shutdown order: shutdown event fires →
logger.shutdown(io)(closes the queue; the writer drains and its task returns; a writer stuck in the disk gate holds its final batch — known, documented) →group.cancel(io)(requests cancel and joins every task) → deinit servers/pool/manager/state → close DBs → return. Canceled UDP recv / TCP accept returnerror.Canceledon the Threaded backend (Threaded.zig:12439,12877). - No runtime config re-read in Phase 7. The blocklist scheduler reloads snapshots; everything else is restart-required until Phase 8 mutates the DB and calls reload.
- No new config fields, no schema change. Pause is in-memory; pruning reuses
retention_days; ECS uses the existingedns.ecs_mode. - Performance (PLAN §18): no formal benchmark in this milestone; the integration test asserts correctness only. The targets stay in PLAN for a later measurement pass.
Sessions
Dependency graph: S1 + S4 parallel (wave 1) → S2 (needs S1's edns API) → S3 (needs S2's
signature in code) → S6 (needs S2, S3, S4) → S7 (needs S6). The orchestrator wires
src/tests.zig after each wave.
Session S1: ECS helpers in dns/edns.zig
Owns: src/dns/edns.zig (extend in place; existing API is frozen — additive only).
S1.1 API
/// The raw payload of the ECS option (code 8) in `opt`, or null when absent.
/// `pkt` is the packet `opt` was parsed from. error.BadOption on malformed option lengths.
pub fn ecsPayload(pkt: packet.Packet, opt: OptRecord) error{BadOption}!?[]const u8
/// Rebuilds `query` without its ECS option into `out`: header + question copied verbatim,
/// then the OPT re-encoded with every option except code 8. Returns `.unchanged` when the
/// query has no OPT or no ECS option (nothing written), else `.rewritten` with the slice
/// into `out`. `pkt`/`opt` are the caller's already-parsed views of `query`.
/// error.Overflow when `out` is too small.
pub const StripResult = union(enum) { unchanged, rewritten: []u8 };
pub fn stripEcs(query: []const u8, pkt: packet.Packet, opt: OptRecord, out: []u8)
error{ BadOption, Overflow }!StripResult
Constraints: pure, allocation-free, no Io. The rebuilt query keeps the original ID, flags and
question bytes verbatim (copy query[0..opt_record_offset_of_question_end] — concretely: copy
everything up to the OPT record's start; the OPT is the sole additional record in a query per
the existing handler validation, and packet.parse yields its offset). ARCOUNT stays 1 when
the OPT is re-emitted (it always is — only the option list shrinks). Use the existing
encodeOpt for the re-emission. The RDATA walk follows the same bounds checks findOption
uses.
S1.2 Acceptance
zig fmt --check+zig ast-checkclean.- Tests in-file: ECS present → payload returned, stripped query parses cleanly and
re-parse finds no option 8, other options survive byte-exact, DO bit and payload size
survive; no OPT →
.unchanged; OPT without ECS →.unchanged; malformed option length →error.BadOption;outone byte short →error.Overflow. - Temp-root run of the file's tests passes.
S1 As built
Two spec claims were wrong and the implementation corrects them. (1) The handler does NOT
guarantee the OPT is the sole additional record (handler.zig:105 rejects only misplaced OPTs;
packet.parse rejects only a second OPT) — stripEcs therefore copies the bytes after the OPT
verbatim and all four section counts survive. Caveat, documented on the function: a compression
pointer in a trailing record that targets a byte inside the OPT's option list shifts after the
rewrite — such a pointer is already malformed (RFC 1035 §4.1.4) and the consequence is an
upstream FORMERR. (2) packet.parse does not yield the OPT's start offset; a private
optRecordStart re-walks the sections and matches the record on its full RDATA span, so a
hand-built OptRecord cannot aim it at a different record (deriving the start backwards is
forgeable: a 0x00xx transaction ID makes a pointer's low byte decode as a root name).
encodeOpt takes one contiguous options slice, so stripEcs emits it with an empty list,
streams the survivors, and patches RDLENGTH at opt_rdlength_offset (9); a guard test pins
that offset against encodeOpt's real output. All options with code 8 are removed (a malformed
query can carry several). Walk failures map to error.BadOption (frozen error set; reachable
only for hand-assembled Packets, documented). After review: stripEcs asserts both that
query aliases pkt.bytes and that out is pointer-range disjoint from query in both
directions; the caller owns keeping the rebuild buffer separate from the received query
buffer (S2's scratch.build satisfies this). The import is aliased packet_mod (seven existing declarations
named packet). ecsPayload was built here, then removed in the review round when the
handler's subnetForKey single-walk replaced its only caller (see S2 As-built); the
forward-mode clamp policy lives there — a payload longer than dns_cache.max_ecs_len (40)
makes the query uncacheable (truncating the key would merge distinct subnets). 7 S1 tests
remain, 25 in file.
Session S2: pipeline in server/handler.zig (+ pause.zig)
Owns: src/server/handler.zig (rewrite around the existing skeleton), src/server/pause.zig
(new). The biggest session. Everything below is frozen interface — S3 and S6 build against it.
S2.1 pause.zig
pub const Pause = struct {
until: std.atomic.Value(i64) = .init(0),
pub fn isPaused(self: *const Pause, now_s: i64) bool // 0→false, -1→true, else now_s < until
pub fn pauseFor(self: *Pause, now_s: i64, duration_s: ?u32) void // null → -1 (indefinite)
pub fn unpause(self: *Pause) void // store 0
};
Auto-resume is the compare in isPaused — no timer. .monotonic ordering is sufficient (a
lone i64 flag, no dependent data). Tests: the three states, expiry boundary (now == until
is resumed), pauseFor overwrites a previous pause.
S2.2 Handler struct (frozen for S3/S6)
pub const Handler = struct {
upstream: transport.Client,
blocking: response.Options,
ecs_mode: model.EcsMode = .strip,
forward_read_timeout: std.Io.Clock.Duration,
manager: ?*manager.Manager = null,
records: *const records.Records,
zones: *const forward_zones.Zones,
cache: ?*dns_cache.DnsCache = null,
cache_mutex: std.Io.Mutex = .init,
limiter: ?*rate_limiter.RateLimiter = null,
limiter_mutex: std.Io.Mutex = .init,
logger: ?*logger_mod.Logger = null,
pause: ?*pause.Pause = null,
tracker: ?*clients.Tracker = null,
stats: Stats = .{},
};
Every ?* defaults to null so existing tests and the check command construct a bare handler
exactly as today (upstream + blocking + records/zones + timeout are the only required fields;
records/zones may point at the shared Records.empty/Zones.empty constants — if
Zones.empty does not exist yet, S2 adds it mirroring Records.empty).
pub const Scratch = struct {
normalize: [types.max_name_len]u8,
key: [dns_cache.max_key_len]u8,
build: [512]u8, // rewritten outgoing query (ECS strip / safe-search)
frame: [forward_client.min_frame_buf]u8, // forward-zone client frame buffer
};
pub fn handle(self: *Handler, io: std.Io, which: Transport, from: address.NetAddress,
query: []const u8, response_buf: []u8, scratch: *Scratch) Outcome
Outcome unchanged. Still no error union — every failure is a synthesized rcode or a counted
drop. New Stats counters (all std.atomic.Value(u64)): refused, blocked,
uncloak_blocked, local_answers, forward_zone_answers, cache_hits, paused_queries,
unfiltered_queries, safesearch_rewrites, ecs_strip_failed, tracker_full.
S2.3 Pipeline order inside handle (binding; rulings 4-13, 18-20 apply)
- Read the 12-byte header (existing code). QR/opcode/qdcount checks as today.
- Rate limit:
limiter_mutex.lock(io),check(now, from.key()), unlock. Refused → synthesize REFUSED (reusesynthesize), count, return.nowfromstd.Io.Clock.awake.now(io)(limiter windows are awake-anchored, milestone-6 As-built). - Full parse + OPT validation (existing code). Record
start_usonce fromstd.Io.Clock.real.now(io)for bothresponse_time_usand cachenow_s. tracker.track(from)(nonblocking; full table countstracker_full).- Snapshot:
if (self.manager) |m| m.acquire(io) else null; released once on every exit path (single defer). Null snapshot →unfiltered_queries, group features off (ruling 4). - Non-IN qclass → step 12 (upstream direct), no cache (ruling 9).
- Normalize qname into
scratch.normalize. - Local records:
hasName→lookup; answer viaResponseBuilder+writeAnswers+addOptEcho, authoritative. A name that exists with no records of the qtype → NODATA, authoritative. Log (upstream="local",cache_hit=null) and return. - Forward zones:
zones.match→ constructForwardClienton the stack (init(zone.resolver, &scratch.frame, self.forward_read_timeout)), cache get → miss →exchangeintoresponse_buf→ cache put (classify), log (upstream=resolver text), return. Filtering/safe-search/uncloak bypassed (ruling 7). Exchange failure → SERVFAIL. - Pause check (
isPaused(now_s)) → skip 11 and the uncloak in 14; countpaused_queries. - Filtering:
snapshot.evaluate(group, domain); blocked →response.writeBlockedintoresponse_buf, log (blocked=true, reason), return. Then safe-search:snapshot.safeSearch(group)andsafesearch.rewrite(domain)→ remember the target; outgoing QNAME = target (query rebuilt inscratch.build; countsafesearch_rewrites). - ECS (ruling 13):
.strip→edns.stripEcsintoscratch.build(compose with the safe-search rebuild: build once with both the final QNAME and the filtered OPT — one rebuild, not two);.forward→edns.ecsPayloadfor the key. Overflow →ecs_strip_failed, forward original. - Cache get (skip for safe-search, ruling 5): key =
buildKey(outgoing qname, qtype, qclass, do_bit, ecs_payload_or_null)undercache_mutex; hit → copy is already inresponse_buf-sizedout(passresponse_buf),packet.setId, UDP truncation check as today, log (cache_hit=true,upstream=""), return. - Upstream:
self.upstream.exchange(io, outgoing, response_buf)(existing error mapping to SERVFAIL/drop stays). Then uncloak (ruling 11) unless paused/non-IN: iteratepacket.answers, follow CNAMEs from the outgoing qname viarecord.rdataCname, depth ≤ 8, normalize each target,evaluate; blocked →writeBlockedfor the ORIGINAL question, countuncloak_blocked, log (blocked=true,"cname:"reason), return. - Safe-search synthesis (ruling 10): new
ResponseBuilderwith the original question,addAnswer(original, CNAME, target), then each A/AAAA answer record of the upstream response verbatim (owner = target name),addOptEcho. The upstream bytes are read fromresponse_bufwhile the builder writes into... the builder needs its own buffer: build into a second half — concretelyhandlerequiresresponse_buf.len >= 1024already via assert; S2 adds asynth: [4096]u8field toScratchfor the safe-search build, then copies the finished reply intoresponse_buf. A synthesized reply that cannot fit 4096 → SERVFAIL (safe-search answers are tiny in practice). - Cache put: upstream path only, not blocked/safe-search (ruling 5):
classify→cache_mutex→put. Allocation failure inside put → skip caching (alreadyAllocator.Error; count nothing, put's stats cover it). - UDP truncation check (existing). Log the entry:
Entry.init(.{ .timestamp = now_s, .domain = normalized domain, .client_ip = formatted from, .qtype, .blocked=false, .response_time_us, .cache_hit=false, .upstream = per ruling 20 }),logger.log(io, entry). Return reply.
The query-log write happens on every replied path (blocked, local, forward, cache, upstream,
paused) — one call site per return is acceptable; a tiny private logEntry helper keeps it
readable. Refused/malformed/dropped are never logged (rulings 8, 20).
S2.4 Acceptance
zig fmt --check+zig ast-checkclean; temp-root tests pass.- Existing handler tests still pass (bare handler, null optionals).
- New in-file tests (fake
transport.Clientfixtures, as the existing tests do): REFUSED on limit exhaustion; local A answer + NODATA; forward-zone match exchanges via the zone resolver fake and bypasses a blocklisted name; blocked domain → zero-IP answer with blocking TTL; allow-over-block passes; cache miss→put→hit with aged TTL + fresh ID; blocked responses not cached; uncloak: CNAME chain to a blocked target at depth 1 and at depth 8 blocks, depth 9 does not,cname:reason logged; safe-search: rewritten QNAME goes upstream, reply carries original question + CNAME + A records only; ECS strip: option 8 gone upstream, others preserved; ECS forward: payload lands in the cache key (two subnets → two entries); paused: blocklisted name answers normally and counts; non-IN bypasses filtering; null manager → unfiltered count; every path's log entry fields per ruling 20 (capture via a Logger with a small queue, drain and assert).
S2 As built
Handler gained one additive field: negative_ttl_max: u32 = 0 (set from
cfg.cache.negative_ttl_max — DnsCache keeps no copy of its config and classify needs the
value; S6 must wire it or negative caching is silently off). Scratch gained synth: [4096]u8 (spec-blessed) and uncloak: [types.max_name_len]u8 (target normalization must not
clobber normalize, which still holds the logged name). Zones.empty already existed
(forward_zones.zig:34) — the S2.2 parenthetical was stale. Both mutexes and the tracker use
lockUncancelable — std.Io.Mutex.lock returns Cancelable!void (Io.zig:1602) and handle
has no error union. Safe-search + ECS strip compose via two buffers: rebuild into
scratch.build, strip into scratch.synth (stripEcs requires non-overlapping output); synth
is reused for the reply after the upstream answers. Uncloaking is also skipped after a
safe-search rewrite (the answers belong to the provider's target, which the client never asked
about). safesearch_rewrites counts at the rebuild, not the table lookup. SERVFAIL paths are
counted but never query-logged (the Entry schema has no status field; a logged row would read
as success — accepted ruling). Forward zones are exempt from the ECS strip (accepted ruling:
the RFC 7871 concern is third-party upstreams, not the LAN resolver). Ruling 20's open choice:
upstream = "pool" for pool answers; forward-zone rows log cache_hit false/true (ruling 20's
"null" contradicted ruling 5 — null is now local records and blocked answers only). The
safe-search CNAME TTL is the minimum TTL of the copied A/AAAA records, zero when none.
handle keeps the 512-byte floor assert (the spec's "1024" was wrong); the copy out of
synth is length-checked with SERVFAIL fallback. tracker_full mirrors
Tracker.snapshotStats().dropped_full after each track (track returns nothing; an always-zero
counter would lie). max_cname_depth = 8 public. queries still means pool-answered queries.
23 new handler tests + 25 adapted + 6 pause tests. After review, two ECS cache holes fixed at
the root: (1) a failed ECS strip forwards the client's subnet upstream, so the answer may be
subnet-specific while the .strip-mode key names no subnet — outgoingQuery returns
Outgoing { bytes, cacheable } and a failed strip sets cacheable = false, skipping both the
cache get and the put; (2) .forward mode forwards the whole OPT but edns.findOption
reports only the first ECS option, so a query carrying several would key on one subnet while
the resolver answered for another — a local subnetForKey walks the option list once and
returns .uncacheable for a repeated option 8, for a payload over dns_cache.max_ecs_len,
and for a list that will not walk; cacheKey then declines to cache. RFC 7871 §6 permits one
ECS option, but a query is client-controlled, so a duplicate is treated as hostile. Both fixes
are mutation-checked. edns.ecsPayload lost its only caller (the key needs the option count
as well as the payload; one walk yields both) and is removed from edns.zig as dead surface. NOTE: the plain zig build test does not
typecheck the listener call sites (serve is never analyzed there) — only -Dintegration
validates S3's edit.
Session S3: listeners pass the client address and scratch
Owns: src/server/udp_server.zig, src/server/tcp_server.zig, and the three integration test
files whose Handler{…} literals the S2 struct change broke:
src/server/udp_server_integration_test.zig, src/server/tcp_server_integration_test.zig,
src/server/resolver_integration_test.zig (S2 measured nine stale literals missing
blocking/forward_read_timeout/records/zones at udp:90/128/163/204, tcp:153/183/216/268,
resolver:229, plus the handle-arity change once those are fixed).
Slot(udp) gainsscratch: handler.Scratch; the call becomesself.handler.handle(io, .udp, address.NetAddress.fromIp(slot.from), slot.query[0..slot.len], &slot.reply, &slot.scratch).Conn(tcp) gainspeer: std.Io.net.IpAddress(captured at claim fromstream.socket.address) andscratch: handler.Scratch; call updated the same way.- No other behavior change. Slot/Conn are pool-allocated already, so the added ~5 KiB per slot changes no lifetime.
- Acceptance: fmt/ast clean; both files' existing tests updated and passing; integration tests
(
-Dintegrationudp/tcp/resolver) still pass unchanged in behavior.
S3 As built
Conn.peer is captured in claim inside the same mutex-held section that stores the stream —
written once per connection, read only by that connection's task; verified from source that
accept fills Socket.address with the peer (net.zig:1442 → Threaded.zig:12439
addressFromPosix), and a new TCP integration test asserts the captured peer is the real
loopback client (an uninitialized field would typecheck and silently corrupt rate
limiting/grouping/logging). Importing the address module forced renaming the bind/listen
address parameters to bind_address/listen_address (Zig shadowing rule; call sites are
positional, nothing else changed). The nine stale integration literals became a per-file
bareHandler fixture deriving blocking from model.Blocking{} and forward_read_timeout
from model.readTimeout(.{}) (3000 ms, .awake), so the fixtures cannot drift from config
defaults. Memory-cost comments updated for the scratch: UDP ≈74 KiB/slot (≈4.6 MiB at 64),
TCP ≈137 KiB/slot (≈8.8 MiB at 64); the UDP <8 MiB budget assertion still holds.
After review: TcpServer.serve distinguishes its two exit paths, because the composition root
cancels its task group before any listener deinit runs (the deinit defers are declared
first, so group.cancel executes first). acceptLoop returns a Stop enum: .closing when
deinit stopped it (error.SocketNotListening, a .shutting_down claim, or the state flag)
and .canceled when the task is being canceled (error.Canceled from accept or the retry
sleep). On .closing the connection group drains under .blocked protection as before —
beginShutdown already unblocked every live stream, so the wait is bounded by the shutdown.
On .canceled the group is canceled instead: nothing has shut the streams down, deinit
cannot run until serve returns, and RFC 7766 §6.2.1.1 lets a client hold a connection open
indefinitely by asking again inside the idle budget — draining would let one client stall
process shutdown. The cost is the single reply mid-write. The old await under .blocked
protection was what made cancel invisible: Syscall.start (Threaded.zig:1349) never arms
cancellation while protection is blocked. Per-connection cleanup is unaffected (serveConn's
defer finish runs on the canceled path; finish already used lockUncancelable + .blocked
around the close). Pinned by "a canceled serve does not wait for a live connection" (blocked
read, 600 s idle budget, cancel must return; reinstating drain-always hangs the suite). The
same await shape remains in UdpServer.serve deliberately: a UDP task is one bounded upstream
exchange, not a client-paced session.
Session S4: client tracker + repo additions
Owns: src/server/clients.zig (new), src/storage/repositories/clients_repo.zig (extend).
S4.1 clients_repo additions (SQL; existing functions frozen)
/// INSERT OR CONFLICT: materialize an unseen client (hand_edited=0, first_seen=last_seen=now)
/// or touch last_seen on an existing row (hand-edited rows get last_seen touched too — the
/// operator sees liveness; group/name/hand_edited never change here).
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void
/// DELETE hand_edited=0 AND last_seen < cutoff. Returns rows deleted.
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32
ON CONFLICT(ip) DO UPDATE SET last_seen=excluded.last_seen. Check the actual clients schema
(unique key on ip) in storage/config_schema.zig and use the real column names. The existing
insertClient (hand_edited=1, import path) stays untouched.
S4.2 Tracker
pub const Tracker = struct {
pub const max_pending = 512;
pub const flush_interval_s = 60;
pub const prune_every_passes = 1440; // daily at 60 s per pass
mutex: std.Io.Mutex, // guards the fixed table below
// fixed-capacity map, same house pattern as rate_limiter: slot array + index
pub fn init(retention_days: u32) Tracker
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void // full → drop, count stats.dropped_full
pub fn run(self: *Tracker, io: std.Io, database: *db.Db, monitor: ?*disk_monitor.Monitor)
std.Io.Cancelable!void
pub const Stats = struct { tracked: u64, flushed: u64, dropped_full: u64, pruned: u64, flush_failures: u64 };
};
run is the house loop shape (manager.zig:946): startup no-op, then every flush_interval_s
on the .boot clock: skip the pass when monitor says writes are gated (ruling 17); else
drain the pending table under the mutex into a local copy (release before I/O), one
upsertSeen per client with real wall-clock seconds; every prune_every_passes-th pass also
pruneStale(now - retention_days * 86_400). A failed upsert warns once per pass and counts
flush_failures; entries stay dropped (next query re-tracks). The database handle is the
loop's own dedicated config.db connection (ruling 21) — document on run like retention does.
IP text via address.NetAddress.format into a stack buffer.
Tests: track dedupes per flush (same client twice → one row, last_seen = latest), table
full drops and counts, flush inserts hand_edited=0 rows and touches existing hand-edited rows'
last_seen only, prune removes only stale hand_edited=0 rows, gated pass writes nothing
(in-memory db + a Monitor fixture — Monitor's classify/state accessors allow constructing a
gated state; if not constructible without I/O, test the gate branch through a bool seam
writes_allowed parameter on a private flushOnce and have run pass the monitor read).
Acceptance: fmt/ast clean; temp-root (with -lc -lsqlite3) green; migration untouched.
S4 As built
init(retention_days: u16) (matches model.Logging.retention_days). The pending table is a
fixed 512-entry array with a linear scan, not a hash map — the frozen init takes no
allocator, and rate_limiter's actual pattern is a pre-reserved hash map, so "same house
pattern" did not apply; a linear scan over ≤512 entries of 17-byte keys is fine at household
scale. Added: trackAt(io, addr, now_s) (timestamp seam; track = trackAt with real
wall-clock), flushOnce(io, database, writes_allowed: bool) public (the gate seam run feeds
from monitor.writesAllowed(); S7 case 10 forces passes with it), snapshotStats(io) and
pendingClients(io) — Stats stays plain u64 and every field plus passes is written under
mutex, so readers go through the accessors. last_seen is stamped at track time (it means
"last query", and it is what makes latest-wins dedupe observable); the flush writes each
entry's own stamp. A gated pass does not increment passes (a skipped pass must not advance
the prune schedule). track uses lockUncancelable — handle has no error union, so track
is not a cancelation point. upsertSeen resolves group_id via
(SELECT id FROM groups WHERE name = 'default') — the column is NOT NULL REFERENCES groups
(config_schema.zig:26), which ruling 14's sketch omitted; a database with no default group
fails the whole statement with error.Constraint and writes nothing (validate.zig:461 already
rejects such configs, so this is a warn-and-continue path in the flush loop, never live). The
upsert's DO UPDATE touches last_seen only — name, group_id, hand_edited, first_seen
survive on every existing row, hand-edited or not, one test per column. pruneStale uses
strict < (matches queries_repo.pruneOlderThan) and clamps sqlite3_changes to u32.
16 tests (9 tracker, 7 repo).
Session S6: composition root — app.zig, shutdown.zig, cli wiring
Owns: src/app.zig (new), src/server/shutdown.zig (new), src/cli.zig (runRun body,
DataDir), src/main.zig (only if a signature forces it — expected unchanged).
S6.1 shutdown.zig
var event: std.Io.Event = .unset;
var event_io: ?std.Io = null; // set once by install before handlers exist
pub fn install(io: std.Io) void // sigaction INT+TERM → handler calls event.set(event_io.?)
pub fn wait(io: std.Io) std.Io.Cancelable!void // event.wait
pub fn trigger(io: std.Io) void // tests and future admin use: event.set
Handler fn: callconv(.c), calls set only (async-signal-safe per ruling 2). flags = 0, empty
mask (matches Threaded's own handlers, Threaded.zig:1653). Do not restore handlers on exit —
the process is leaving anyway; install is once-per-process (assert).
S6.2 app.zig — pub fn run(runner: cli.Runner, paths: cli.Paths) u8
Startup order (each failure prints to runner.err and returns cli.exit_runtime):
DataDir.open(S6 changes itsopenDirflags to.{ .iterate = true }— the monitor needs it; nothing else cares).openConfigDb.migrations.migrate.bootstrap.bootstrap(...)(first run seeds; diags printed like runCheck does).export.readConfiginto an arena.logging.install(io, cfg.logging, ...)— the sink goes live (main.zig's std_options already routes).- Build state, in dependency order, all heap-allocated where pinning is required (Logger,
Manager, Pool entries and their DoH/DoT client structs must not move — same pattern as
cli.probeUpstreamsbut with owned arrays that outlive the pool): records + zones (local_repolisters →build), fetcher + manager (Manager.init), pool (upstreams_repo → clients array →Pool.init), cache (DnsCache.init), limiter (RateLimiter.initfromcfg.dns), pause, tracker, logger (Logger.initwith a heapqueue_bufofcfg.logging.query_log_buffer_maxentries), monitor (Monitor.initwith the data dir), retention (Retention.init), querylog connections: addDataDir.openQuerylogDb(self, io) !db.Dbmirroring openConfigDb (same pragmas + 0600); open TWO (writer, retention) plus ONE extra config.db connection for the tracker (ruling 21). - One synchronous
manager.reload(io); failure warns and continues (ruling 4). - Handler construction (S2 struct) +
UdpServer.bind+TcpServer.listenoncfg.dns.bind_ipv4/bind_ipv6/port— v4 and v6 both, matching PLAN §7.2 parity; a bind failure is fatal. shutdown.install(io). Then onestd.Io.Group: udp.serve, tcp.serve, logger.runWriter(io, &querylog_writer_db, &monitor), retention.run(io, &querylog_retention_db), monitor.run(io), manager.runScheduler(io), tracker.run(io, &tracker_db, &monitor), and a maintenance loop (private fn in app.zig, house shape,.boot, 60 s): cache sweep + limiter sweep under their mutexes... the mutexes live in the Handler; the maintenance loop takes*Handlerand locks the same mutexes.shutdown.wait(io)(a Canceled result also proceeds to teardown). Then ruling 22 order:logger.shutdown(io)→group.cancel(io)→ server deinits → pool/manager/state deinits → DB closes → DataDir.close → returncli.exit_ok.
Log a one-line startup summary (scope .nxdns, info): bind addresses, snapshot generation or
"unfiltered", upstream count.
S6.3 cli.zig
runRun body: return app.run(r, paths); — keep the doc comment honest. DataDir.open flag
change + openQuerylogDb as above (both used by app; runCheck untouched).
S6.4 Acceptance
- fmt/ast clean on all owned files;
zig build(exe) succeeds — app.zig is reached from main, so full semantic analysis covers it. zig build testgreen (existing cli tests untouched; app.zig gets a smoke test only if expressible without sockets — otherwise S7 owns runtime coverage; state that choice).- Manual smoke (orchestrator runs it):
nxdns runagainst a scratch data dir starts, answersdig @127.0.0.1 -p <port>, and exits 0 on SIGTERM promptly.
S6 As built
Startup order as specified, with these corrections. Binding: IPv6 binds first; an
AddressInUse from the IPv4 bind that follows a wildcard IPv6 bind is dual-stack coverage
(info line), not a failure — on Linux net.ipv6.bindv6only=0 makes the v6 wildcard own the v4
port, and BindOptions.ip6_only cannot prevent it because Threaded.zig:12274 sets IPV6_V6ONLY
to 0 when the option is true (inverted vs its doc); a foreign IPv4 holder would have failed
the v6 wildcard bind too, so the inference is sound. NetAddress.fromIp unmaps ::ffff:
clients, so v4 clients keep their real identity. A kernel without IPv6
(AddressFamilyUnsupported and friends) warns and serves IPv4; every other bind failure is
fatal. Exit codes: configuration faults (NoUsableUpstreams, BadBindAddress,
BadRateLimit — zero rate fields are rejected before RateLimiter.init asserts) exit
cli.exit_check with a "run 'nxdns check'" hint; everything else exits cli.exit_runtime.
Querylog open: openQuerylogDb wraps querylog_schema.open (creation/repair lives there;
chmod of the file plus both WAL sidecars happens after, missing sidecars ignored);
reopenQuerylogDb opens the extra connections without repeating the O(size) quick_check.
Two std.http.Clients: blocklist downloads must not queue DoH queries behind a
multi-megabyte stream. Teardown is defer-driven; the invariants held are: group.cancel joins
first, config.db closes after manager.deinit, DataDir closes last. negative_ttl_max and
forward_read_timeout are wired (the S2 trap). shutdown.zig has reset() for tests and 2
in-file tests (orchestrator wired it into tests.zig — app.zig analysis via cli does not
collect tests). app.zig itself has no unit test; S7 case 11 is the runtime proof. Smoke test
passed: boot, migration 0->2, dual-stack answers on 15353/15354, SERVFAIL from the dead
upstream, SIGTERM exit 0 in 0.007 s, DBs 0600, zero err lines. The manager disk gate was wired in a follow-up S6 edit — see ruling 17's as-built text; the
gate test drives Monitor.state_raw directly (no I/O) and pins no-monitor/ok/warn pass,
critical gates-and-counts, recovery resumes. After review: the composition root takes one
synchronous monitor.sample(io) after Monitor.init, before the Io.Group spawns anything —
Monitor initializes to .ok and run samples inside its own task, so without it the
writer/tracker/scheduler would consult a gate still saying "writes allowed" during the boot
window on a critically full disk. A failed first sample warns and leaves .ok (an unreadable
filesystem is not evidence of a full disk — the monitor's own documented policy). run's own
first sample repeats it (one extra statvfs + scan at boot; run's sample-then-sleep contract
stays untouched). The ordering is not unit-testable (serve binds sockets and opens four
databases); S7's boot case plus the statement order is the evidence.
Session S7: phase 7 integration tests
Owns: src/server/phase7_integration_test.zig (new; gated on build_options.integration like
phase6). Fixtures may reuse the S8/M6 helper patterns (in-memory dbs, fake upstream sockets).
Cases (each end-to-end through a real Handler with real UDP sockets on 127.0.0.1 ephemeral
ports; a fake upstream = a UDP socket task answering canned responses):
- Blocked domain answers 0.0.0.0 with blocking TTL; log entry captured with reason.
- Allow-rule-over-blocklist resolves via upstream.
- Local record answers authoritatively without touching the fake upstream.
- Forward zone: matching suffix goes to the zone resolver socket (not the pool fake), a blocklisted name inside the zone still resolves (bypass), response cached (second query does not hit the zone socket).
- Cache: miss→hit, TTL aged, transaction ID fresh,
cache_hitlogged on the hit. - Uncloak: fake upstream returns CNAME chain to a blocked target → zero-IP answer with
cname:reason. - Safe-search group: query for a safesearch.zig table domain returns CNAME + the fake upstream's A for the target; question is the original name.
- Rate limit: limit=2 window=60 → third query REFUSED.
- Pause: pauseFor(indefinite) lets a blocked domain resolve; unpause blocks it again.
- Tracker: after queries + one forced flush (
flushOnceseam or short interval), the clients table holds the source IP with hand_edited=0. - Full app boot:
app.runin a task against a temp data dir (bootstrap from a minimal config.zon on an ephemeral port), one real resolve through it,shutdown.trigger, task returns 0. This is the lifecycle proof.
Acceptance: zig build test -Dintegration green, zero std.log.err emitted (binding logging
policy), cases deterministic (no external network — the pool's upstream is the fake).
S7 As built
Case 11 runs against a std.testing.tmpDir rather than a fixed scratch path (a committed
test cannot hold a machine-specific absolute path; the phase6 fixture already resolves
cwd-relative paths through both std.Io.Dir and SQLite). Port 15455, IPv4+IPv6 binds, the
dead https://127.0.0.1:9/dns-query upstream, one seeded local record; app.serve installs
and deinstalls its own log sink, so installForTest is unnecessary and the case asserts the
error writer stayed empty; shutdown.reset() brackets the case. Case 5 proves TTL ageing
with an entry planted ten seconds in the past under the handler's own key — the
miss-hit-fresh-ID half is a real round trip; no test can advance the clock. Case 4's zone
resolver is a UDP socket task answering with ResponseBuilder against the received query;
the second query proves the cache by leaving the resolver's datagram count at 1. The fake
pool upstream builds each answer from the question it receives (safe search and ECS rewrite
the outgoing question, so canned bytes would not bind). A shared Loop fixture binds the
listener and client before the serve task starts, so no task holds a pointer into a value
that later moves. 11 tests, all gated on build_options.integration; the integration suite
emits zero err-level lines.
Module layout (new/changed)
| file | change |
|---|---|
| src/dns/edns.zig | +stripEcs (S1; ecsPayload added then removed in review) |
| src/server/handler.zig | pipeline rewrite (S2) |
| src/server/pause.zig | new (S2) |
| src/server/udp_server.zig, tcp_server.zig | address + scratch plumbing (S3) |
| src/server/clients.zig | new tracker (S4) |
| src/storage/repositories/clients_repo.zig | +upsertSeen, +pruneStale (S4) |
| src/app.zig | new composition root (S6) |
| src/server/shutdown.zig | new (S6) |
| src/cli.zig | runRun body, DataDir.iterate, openQuerylogDb (S6) |
| src/server/phase7_integration_test.zig | new (S7) |
File ownership
S1: dns/edns.zig. S2: server/handler.zig, server/pause.zig. S3: server/udp_server.zig, server/tcp_server.zig. S4: server/clients.zig, storage/repositories/clients_repo.zig. S6: app.zig, server/shutdown.zig, cli.zig, main.zig(if forced). S7: its test file. Orchestrator: src/tests.zig, specs. No parallel sessions share a file (S1∥S4; the rest are sequential).
Acceptance (milestone complete)
zig build testandzig build test -Dintegrationgreen;zig fmt --checkclean;zig build crossproduces both static exes.- All S7 cases pass; the app boots, serves, and shuts down cleanly (case 11).
- No std.log.err in any new code path exercised by tests (binding logging policy: err is reserved for swallowed failures).
- Spec As-built sections synced per session.
Anti-requirements
- No HTTP/SSE/metrics/auth (Phase 8). No DoH/DoT server (Phase 9). No packaging (Phase 10).
- No regex rules, DHCP, DNSSEC validation, DoQ, clustering (PLAN §2.2).
- No per-group cache, no cache persistence, no query re-resolution in uncloaking.
- No new config fields, no schema migration, no pause persistence.
- No benchmark harness this milestone.
- dns/ stays pure (S1's additions are pure); filter/local/cache modules are not edited.