Files
nxdns/specs/milestone-16.md
T
mokhtar 25455e5ae2
CI / test (push) Failing after 11s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
milestone 16: behavioral fixes for silent failures, locks, counters and the query log
2026-08-07 01:54:40 +02:00

23 KiB

Milestone 16: contained behavioral fixes

Goal: fix the verified bugs and silent failures from TECH_DEBT.md themes 3, 5, 6 and 7 — each one localized, each with a test that fails before and passes after. No refactors: the duplication work is milestone 18, and a refactor that must simultaneously fix behavior is how mirrored copies diverge further.

PROVISIONAL. Written before milestone 15 was built. m15 touches build.zig, workflows, fuzz files, filter_integration_test.zig, cli.zig, docs_drift_test.zig and logging.zig — re-verify line references in those files before starting.

Rulings (binding)

1. loadSource propagates cancellation

src/filter/manager.zig:541-544 and :547-550: two identical catch sites fold error.Canceled into loadFailure(...), recording a bogus "Canceled" load-failed status, consuming the one-shot cancellation, and publishing a snapshot with the source excluded. Ten other catch sites in the file propagate it correctly. Add if (err == error.Canceled) return error.Canceled; beside the existing OutOfMemory arm at both sites — Manager.Error already has the member. Unit test: a reader that fails with Canceled during loadSource makes the whole call return Canceled and writes no status.

2. commitStatus never drops an outcome silently

src/filter/manager.zig:1237-1250: the id-match loop falls through with no log when a source has no status entry (reachable via a startupPass / web-insert race; milestone-5 policy says every non-ok state is recorded and surfaced). Add a log.warn after the loop naming the source id. Unit test: committing a status for an unknown id emits the warning and does not crash.

3. Downloads and compiles leave the writer lock

src/filter/manager.zig:609-624: refreshAll holds writer_lock across every download (300 s budget each, app.zig:85) and every compile; every web mutation ends in Manager.reload on the same lock, so an unrelated rule save parks uncancelably — on a request path with no timeout, occupying one of 64 web slots — until the pass ends. The download-to-tmp flow already exists (download writes <id>.raw.tmp via compiledName, with deleteQuietly defers at :646-648); the lock is just taken too early.

Restructure:

  • New refresh_lock: std.Io.Mutex on Manager, serializing refresh passes against each other only.
  • Split refreshOne (:626-729): the fetch/detect/compile stages (:650, :659, :668) run under refresh_lock alone; the publish stage (:711), the status commit and the final reloadLocked run under writer_lock.
  • refreshAll takes refresh_lock for the pass, and writer_lock only per-source around publish plus once for the final reload. refreshSource (:582-586) follows the same split.
  • The lock-ordering rule, documented on both mutex fields: refresh_lock is never acquired while holding writer_lock.
  • refresh_lock serializes blocklist-directory maintenance too, not just refresh passes: pruneOrphans (:1040, invariant comment at :1130-1136) today assumes every temp-file writer holds writer_lock; once downloads write .raw.tmp/.list.tmp/.wild.tmp under refresh_lock alone, a concurrent source deletion (blocklists.zig delete → reload → pruneFiles) could sweep an in-flight refresh's temp files. pruneOrphans therefore takes refresh_lock first (then writer_lock if it needs it — same order as everywhere), and its invariant comment is rewritten. Regression test: a source deletion during a stalled refresh must not delete the stalled refresh's temp files.

Integration test (filter_integration_test.zig): with a refresh pass parked on a deliberately stalled fixture route, a concurrent rule mutation completes without waiting for the pass. DNS serving was never affected and stays covered by the existing tests.

4. Truncated responses are never cached

src/cache/dns_cache.zig:106-131: classify reads rcode and ancount and never flags.tc, so a TC=1 answer from a misbehaving upstream is cached for up to max_ttl_seconds (86 400 s) and re-served — TC bit intact, even over TCP, which can loop retrying clients (RFC 2181 §9). Add if (p.header.flags.tc) return null; (flags.tc is src/dns/header.zig:20).

transport.validateResponse is deliberately untouched: rejecting TC there would change failover semantics for every exchange, and the forward client legitimately reads TC for its UDP-to-TCP retry (forward_client.zig:178). Record: considered, rejected. Unit test beside the existing classify tests: a TC=1 NOERROR response classifies as null.

5. The format sniffer stops eating hosts files with ## banners

src/filter/parsers.zig:54-73: hasAbpMarker runs before isComment, and isElementHiding (:86-93) matches ## (and #@#, #?#, #$#, #%#) unanchored with no comment guard — unlike the $ branch two lines later, which is guarded. A hosts file with a ## banner parses whole as ABP: 0.0.0.0 enters the domain set and hosts lines with inline URL comments are silently dropped (real blocking loss, reproduced with the URLhaus banner style). The milestone-5 as-built note (specs/milestone-5.md:1677-1678) already claims the separators are matched anchored — the code never was. Guarding the branch with !isComment is a circular no-op (isComment consults isElementHiding); do not attempt it.

Fix — a positional predicate in isElementHiding: a separator at offset p counts as element hiding only when

  • p == 0 and the character after the separator is not whitespace, #, or end of line (a generic rule ##.ad counts; a banner ## Title, ####, or bare ## does not), or
  • p > 0 and line[p - 1] is not whitespace and not # (a rule example.com##.ad counts; prose see ## below does not).

Required test cases, in parsers.zig: the URLhaus banner style sniffs as hosts; ##.ad-banner sniffs as ABP; example.com##.ad sniffs as ABP; #@#exception after a domain sniffs as ABP; a #-initial line containing ## sniffs as a comment. The fuzz target (blocklist_fuzz.zig) already covers detectFormat for crashes.

6. VACUUM respects the disk monitor

src/storage/retention.zig: runOnce (:78) does prune → checkpoint → (every 7th pass, :94-95) VACUUM, and takes no monitor — the word does not appear in the file — while its two sibling tasks gate on disk_monitor.writesAllowed() (logger.zig:386 as a parameter, manager.zig:1058 as a field). VACUUM is the most expensive write the program makes and fires unconditionally on the exact filesystem the monitor watches, then fails SQLITE_FULL with no retry for seven daily passes.

runOnce and run gain monitor: ?*disk_monitor.Monitor (the logger's parameter shape). Only the VACUUM step gates: prune and checkpoint keep running — they free space. A skipped vacuum increments a new Stats.vacuums_gated and retries on the next pass (drop the modulo-only trigger for a passes_since_vacuum >= vacuum_every_passes counter that resets on success). app.zig:561 passes the monitor like :560 does for the logger. Tests: extend the cadence tests (:223) with a gated pass — the vacuum is skipped, counted, and runs on the next allowed pass.

src/web/server.zig:612-620 (copyHeader): a header value over the buffer (cookie buffer = http_util.max_cookie_len = 1024, http_util.zig:35) returns "" with no log — under the documented reverse-proxy-on-shared- domain deployment (foreign cookies riding along), every request silently 401s and the operator sees an unexplained login loop.

copyHeader stays generic. The cookie call site (:510) changes: when the raw header value exceeds the buffer, extract only the session pair by running http_util.cookieValue (:200 — it already parses pairs and returns a slice of the original header) with the existing session-cookie name constant against the full value, copy that pair into the buffer as <name>=<value>, and emit one log.debug with the dropped size (the .web_server scope at :55; no secret is logged). If even the pair does not fit, keep the empty result but still log. Integration test (web_integration_test.zig): a request with a 2 KiB cookie header whose session pair is valid is authenticated; the same header without the pair 401s.

8. Live view detects fatal SSE rejections

web/src/features/live/useLiveQueries.ts: per the WHATWG spec a non-200 response fails an EventSource permanently after one error event, so the 3-consecutive-errors threshold (:29, :125-133) is unreachable on exactly the 429/401 paths it was built for — the UI shows "Reconnecting…" forever and the capped state and session probe are dead. FakeEventSource has no readyState, so tests pass — the mocked-network class again.

  • EventSourceLike (:9-13) gains readyState: number; export const EVENT_SOURCE_CLOSED = 2. The browser EventSource satisfies it structurally.
  • In the error handler: readyState === EVENT_SOURCE_CLOSED is a permanent failure — close, set capped, run the session probe immediately, bypassing the counter. Transient errors (browser auto-retry pending) keep the existing counter path.
  • fakeEventSource.ts gains readyState with the real lifecycle (CONNECTING → OPEN on emit("open"), CLOSED on close() and on failFatal(), a new test helper).
  • New tests: a fatal rejection (single error event, readyState CLOSED) reaches capped and calls probeSession; a transient error still takes three to trip.

9. A timed-out DoH handshake is a handshake failure

src/server/doh_server.zig:311-315 counts a handshake-race timeout as idle_timeouts; DoT (:313-317) counts it as tls_handshake_failures, which is the recorded spec ruling. Since DoH requests have no other timer, its idle_timeouts metric can only ever mean handshake stalls — the same exported name with disjoint semantics per listener. Align DoH's .timed_out arm to tls_handshake_failures.

10. Idle DoH keep-alive connections are reclaimed

src/server/doh_server.zig:70: idle_timeout bounds only the handshake; its own doc comment admits requests have none. DoH stubs hold keep-alives by design, and with no TCP keepalive a vanished peer pins one of 64 slots until restart — while DoT on the same LAN reclaims after 10 s.

Extend the existing race to receiveHead (:333), the wait for the next request on a keep-alive connection, using the DoT out-param precedent (readPrefix's out_len): a small wrapper writes the received Request (or its error) through a pointer so the raced function stays anyerror!void. On .timed_out: bump idle_timeouts (now truthfully named again after ruling 9) and close the connection. Preserve the existing error triage at :334-346 (HttpConnectionClosing and ReadFailed return uncounted; the three structural errors count bad_requests/connection_errors as today). The body read and handleRequest stay untimed, like the web listener.

New integration-gated test mirroring the DoT idle test (dot_server.zig:1098): a client that completes the handshake and one request then goes quiet is closed after a short idle budget; idle_timeouts == 1, tls_handshake_failures == 0. DoH currently has no idle test at all.

11. SSE shutdown does not wait for a heartbeat

src/web/sse.zig: the Hub has no shutdown signal, so graceful drain parks up to 15 s (heartbeat_interval, web/handlers/live.zig:32) per idle subscriber — the web beginShutdown (web/server.zig:593-605) shuts down sockets but nothing wakes a task inside Hub.wait. Measured: the SSE integration test budgets 40 s for exactly this (web_integration_test.zig:74-75).

  • Wake (:33) gains .closed. Hub gains closing: bool and pub fn close(self: *Hub, io: std.Io) void: under the mutex, set the flag and event.set every active slot. wait returns .closed immediately when the flag is set.
  • live.zig:114: .closed => return.
  • web/server.zig deinit (:349): call the hub's close (via the state's hub pointer) immediately before beginShutdown (:360).
  • Test: a subscriber parked in wait returns .closed promptly after close; the W10 integration teardown no longer stalls (tighten sse_budget only if the heartbeat assertion itself allows it).

12. The API limiter's sweep runs

src/web/api_limiter.zig:210: sweep exists, preallocates stale_keys so it never allocates, is tested — and has no production caller; only the DNS limiter got scheduled. Once 4096 distinct addresses have been seen, the table stays full forever and every unknown-address request pays an O(4096) eviction scan under the limiter mutex.

runMaintenance (app.zig:709) gains an api_limiter: ?*ApiLimiter parameter, wired at :565 from the instance built at :380-388. In the loop, beside the two existing tasks: _ = api.sweep(io, Clock.awake.now(io)); — note it locks itself, unlike the DNS limiter. Test: the maintenance-loop integration coverage asserts a stale bucket disappears.

13. UDP/53 and TCP/53 stats reach /metrics

src/server/udp_server.zig:38-49 and tcp_server.zig:54-60: both stats structs are written and read by nothing outside their own integration tests — dropped_no_slot/dropped_oversize have no metric, no API field, and errors log below the default level, while the DoT/DoH siblings export equivalent counters. The module doc sells "dropped and counted"; the count is unobservable.

  • Both gain pub const Snapshot + pub fn snapshotStats in the exact DoT shape (dot_server.zig:61, :232). Field names stay as-is (the accepted/connections unification is milestone-18 work).
  • WebState (src/web/server.zig) gains udp_listeners: []const *udp_server.UdpServer = &.{} and tcp_listeners: []const *tcp_server.TcpServer = &.{} — S4 adds the fields with exactly these names and types; S2 consumes them (the app builds four listeners: udp6/udp4/tcp6/tcp4, app.zig:506-528).
  • metrics.zig: sum each family across its listeners into one nxdns_udp_server_*_total / nxdns_tcp_server_*_total group via the existing counterGroup derivation; extend the name-assertion test (:722-743).
  • app.zig wires the slices.

14. Forward-client counters survive the query

src/local/forward_client.zig:44-57: Stats (queries, udp_truncated, foreign_datagrams, failures) is instrumented, documented as preventing "an unrecorded failure mode" — and the only production caller builds a stack-local client per query (handler.zig:394) and drops it, so the spoofing signal does not exist.

Handler.Stats (handler.zig:130-154) gains forward_udp_truncated, forward_foreign_datagrams, forward_failures (atomic, like the other 17; queries are already counted by forward_zone_answers). After the exchange in viaForwardZone, fetchAdd the client's counters into them. The metrics reflection (dns_stat_fields, metrics.zig:48) picks the new fields up without an edit — assert the three new nxdns_dns_*_total names in the metrics test. ForwardClient.Stats itself stays plain and per-instance.

15. The TLS server keeps the concrete transport cause

src/platform/tls_server.zig:418-438: both BIO callbacks discard the stashed cause — net_reader.err / net_writer.err (which hold Reset/Timeout/Canceled, std Io/net.zig:1260/:1324) are never read anywhere in the file — so a routine idle-budget cancel surfaces as a warn "mbedtls_ssl_read failed" and peer resets are indistinguishable from timeouts. The client side solved exactly this (dot_client.zig concreteRead/concreteWrite, :303-318).

  • ServerStream gains recv_cause: ?anyerror = null, send_cause: ?anyerror = null; bioRecv/bioSend stash self.net_reader.err/self.net_writer.err on failure before returning the mbedtls code.
  • ReadError (:216-223) widens with Canceled; the read path (readIntoBuffer, :334) maps a stashed error.Canceled to it instead of TlsFailed. Callers' race harnesses already treat cancellation separately.
  • Unit tests in the file's existing stub style: a reader whose err is Canceled yields error.Canceled; a Reset yields TlsFailed with the cause stashed for logging (ruling 16).

16. Peer misbehavior logs at debug, like the read path already rules

src/platform/tls_server.zig: the read path deliberately logs a peer TCP drop at debug (:362-364, "a louder level would be a log-spam vector"), then close warns on close_notify against the same dead socket (:311) and handshake warns per probe (:287). .tls_server is not in the dedup scope set (logging.zig:103-108), so peer-driven warns can evict genuine warnings from the rotating log.

  • report (:474-479) gains a level parameter. The close_notify site passes debug when peer_closed is set or the stashed cause (ruling 15) is a peer-class error; the handshake site likewise. Local/config failures keep warn.
  • Add .tls_server to isDedupScope (logging.zig:103-108) and to its membership test (:637-644).

Scope widened during implementation (orchestrator ruling): the level choice also applies to the ssl_read and ssl_write report sites, not only the two named ones. Ruling 15's "cause stashed for logging" wording requires the read site to consume the level, and an unconditional warn on the write half would keep the same spam vector open through closeflush against a dead peer. Peer-class causes are decided by pure helpers (isPeerCause, causeLevel); check still passes warn.

17. The query log heals its own gaps

web/src/features/queries/QueryLogPage.tsx: the hand-rolled accumulation (extra, cursorOverride, generation, :79-98) develops a silent mid-table row gap when the base page refetches after 30 s staleness — the newest-100 boundary moves up while extra starts below the old cursor, and the stale override means load-more never heals it. On a live DNS server the trigger is routine.

Migrate to useInfiniteQuery: the endpoint already maps onto it (getNextPageParam: (last) => last.next_before ?? undefined; keyset pagination confirmed server-side, handlers/queries.zig:103-114). A background refetch then refetches all pages in order — consistent, no gap. The three behaviors the old code carried by hand: staleness discard is handled by the query itself; keep the isPlaceholderData disable on the button; the 401 branch is already covered by the global cache-level handleUnauthorized (queryClient.ts:29-30). Delete extra, cursorOverride, generation, loadingMore, moreError. Rewrite the five load-more tests; add one for the gap scenario: base refetch with new rows between page renders leaves no discontinuity.

18. Password hashing leaves the config lock

src/web/handlers/settings.zig: applyPut holds config_lock from :286 to :341, and the argon2id call (t=2, m=19 MiB, :373-391) sits inside it at :312 — every settings GET (:407-409 takes the same lock) and every mutation stalls for the hash duration on the Pi 5. The login path already does it right: copy under lock, hash unlocked, re-check generation under lock (auth.zig:47-71), and the LiveHash generation check (auth.zig:73-79) already closes the concurrent-install race.

Move the password-length check and the hashPassword call before the lock acquisition; everything from loadConfig on stays inside. The hash input is the parsed patch only, so nothing under the lock is needed. Tests: the existing applyPut suite passes unchanged; add one that actually distinguishes the new behavior — "both complete" already held before the fix, the GET merely waited out the hash. A stall seam under builtin.is_test (the milestone-15 rotation-seam shape) parks hashPassword on an event; the test starts a password PUT, waits until the hash is parked, completes a settings GET while the hash is still parked, then releases the seam and joins the PUT.

Sessions

S1-S5 run in parallel. One cross-session interface, fixed here: S4 adds the WebState.udp_listeners/tcp_listeners fields exactly as ruling 13 names them; S2 wires and consumes them without touching web/server.zig.

Session S1: filter

Owns src/filter/manager.zig, src/filter/parsers.zig, src/filter/filter_integration_test.zig. Rulings 1, 2, 3, 5.

Session S2: cache, retention, counters, wiring

Owns src/cache/dns_cache.zig, src/storage/retention.zig, src/app.zig, src/server/udp_server.zig, src/server/tcp_server.zig, src/local/forward_client.zig, src/server/handler.zig, src/web/metrics.zig, and the storage/server integration tests those touch. Rulings 4, 6, 12, 13 (except the WebState fields), 14.

Session S3: TLS listeners

Owns src/platform/tls_server.zig, src/platform/logging.zig, src/server/doh_server.zig. Rulings 9, 10, 15, 16.

Session S4: web server

Owns src/web/server.zig, src/web/sse.zig, src/web/handlers/live.zig, src/web/handlers/settings.zig, src/web/web_integration_test.zig, plus the ruling-13 field additions. Rulings 7, 11, 18.

Session S5: frontend

Owns web/src/features/live/*, web/src/features/queries/*. Rulings 8, 17.

Orchestrator

Strikes the closed findings in TECH_DEBT.md; updates the stale milestone-5 as-built line if ruling 5 changed its truth value.

Acceptance (milestone complete)

  • Both loadSource catch sites propagate Canceled; the new unit test passes; no status row ever reads "Canceled".
  • commitStatus warns on an unknown id (test).
  • With a stalled download in flight, a concurrent rule mutation completes (integration test); the lock-ordering comment exists on both mutexes; a source deletion during a stalled refresh leaves the refresh's temp files alone (regression test).
  • A TC=1 response classifies as null (test); validateResponse is untouched.
  • The five sniffer cases pass; the URLhaus banner file parses as hosts.
  • A gated pass skips only the vacuum, counts vacuums_gated, and vacuums on the next allowed pass (test).
  • The 2 KiB-cookie tests pass: session extracted, non-session 401s, debug line emitted.
  • A fatal SSE rejection reaches capped and probes the session (frontend test); a transient error still takes three.
  • DoH .timed_out handshake counts tls_handshake_failures; the metrics name test still passes.
  • The new DoH idle test passes: quiet keep-alive closed, idle_timeouts == 1.
  • Hub.close wakes a parked subscriber promptly (test); web deinit calls it before beginShutdown.
  • The API limiter sweep runs in maintenance (test).
  • /metrics carries nxdns_udp_server_* and nxdns_tcp_server_* families summed over four listeners (name test extended).
  • /metrics carries the three new nxdns_dns_forward_* counters.
  • The TLS-server cause tests pass: Canceled surfaces as Canceled, never as a warn line; close_notify after a peer drop logs debug; .tls_server is in the dedup set.
  • The query log uses useInfiniteQuery; the gap-scenario test passes; the accumulation state is gone.
  • Hashing runs before config_lock; the settings suite passes.
  • Full suite green: zig build test -Dintegration, npm run test, npm run typecheck.

Anti-requirements

  • No listener-core extraction, no shared repo helpers, no shared transport helpers — milestone 18.
  • No stats field renames (accepted vs connections) — milestone 18.
  • No config key renames and no trusted-proxy setting — milestone 17.
  • No useInfiniteQuery migration anywhere but the query log.
  • No new metrics beyond the families this spec names.
  • No timeout on DoH request bodies or handlers — only receiveHead races.