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.Mutexon Manager, serializing refresh passes against each other only. - Split
refreshOne(:626-729): the fetch/detect/compile stages (:650, :659, :668) run underrefresh_lockalone; the publish stage (:711), the status commit and the finalreloadLockedrun underwriter_lock. refreshAlltakesrefresh_lockfor the pass, andwriter_lockonly 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_lockis never acquired while holdingwriter_lock. refresh_lockserializes blocklist-directory maintenance too, not just refresh passes:pruneOrphans(:1040, invariant comment at :1130-1136) today assumes every temp-file writer holdswriter_lock; once downloads write.raw.tmp/.list.tmp/.wild.tmpunderrefresh_lockalone, a concurrent source deletion (blocklists.zig delete → reload → pruneFiles) could sweep an in-flight refresh's temp files.pruneOrphanstherefore takesrefresh_lockfirst (thenwriter_lockif 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 == 0and the character after the separator is not whitespace,#, or end of line (a generic rule##.adcounts; a banner## Title,####, or bare##does not), orp > 0andline[p - 1]is not whitespace and not#(a ruleexample.com##.adcounts; prosesee ## belowdoes 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.
7. An oversized Cookie header degrades loudly, and the session survives
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) gainsreadyState: number; exportconst EVENT_SOURCE_CLOSED = 2. The browserEventSourcesatisfies it structurally.- In the error handler:
readyState === EVENT_SOURCE_CLOSEDis a permanent failure — close, setcapped, run the session probe immediately, bypassing the counter. Transient errors (browser auto-retry pending) keep the existing counter path. fakeEventSource.tsgainsreadyStatewith the real lifecycle (CONNECTING → OPEN onemit("open"), CLOSED onclose()and onfailFatal(), a new test helper).- New tests: a fatal rejection (single error event, readyState CLOSED)
reaches
cappedand callsprobeSession; 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 gainsclosing: boolandpub fn close(self: *Hub, io: std.Io) void: under the mutex, set the flag andevent.setevery active slot.waitreturns.closedimmediately when the flag is set.live.zig:114:.closed => return.web/server.zigdeinit(:349): call the hub's close (via the state's hub pointer) immediately beforebeginShutdown(:360).- Test: a subscriber parked in
waitreturns.closedpromptly afterclose; the W10 integration teardown no longer stalls (tightensse_budgetonly 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 snapshotStatsin the exact DoT shape (dot_server.zig:61, :232). Field names stay as-is (theaccepted/connectionsunification is milestone-18 work). WebState(src/web/server.zig) gainsudp_listeners: []const *udp_server.UdpServer = &.{}andtcp_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 onenxdns_udp_server_*_total/nxdns_tcp_server_*_totalgroup via the existingcounterGroupderivation; extend the name-assertion test (:722-743).app.zigwires 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).
ServerStreamgainsrecv_cause: ?anyerror = null,send_cause: ?anyerror = null;bioRecv/bioSendstashself.net_reader.err/self.net_writer.erron failure before returning the mbedtls code.ReadError(:216-223) widens withCanceled; the read path (readIntoBuffer, :334) maps a stashederror.Canceledto it instead ofTlsFailed. Callers' race harnesses already treat cancellation separately.- Unit tests in the file's existing stub style: a reader whose
erris Canceled yieldserror.Canceled; a Reset yieldsTlsFailedwith 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 whenpeer_closedis set or the stashed cause (ruling 15) is a peer-class error; the handshake site likewise. Local/config failures keep warn.- Add
.tls_servertoisDedupScope(logging.zig:103-108) and to its membership test (:637-644).
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
loadSourcecatch sites propagate Canceled; the new unit test passes; no status row ever reads "Canceled". commitStatuswarns 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);
validateResponseis 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
cappedand probes the session (frontend test); a transient error still takes three. - DoH
.timed_outhandshake countstls_handshake_failures; the metrics name test still passes. - The new DoH idle test passes: quiet keep-alive closed,
idle_timeouts == 1. Hub.closewakes a parked subscriber promptly (test); webdeinitcalls it beforebeginShutdown.- The API limiter sweep runs in maintenance (test).
/metricscarriesnxdns_udp_server_*andnxdns_tcp_server_*families summed over four listeners (name test extended)./metricscarries the three newnxdns_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_serveris 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 (
acceptedvsconnections) — milestone 18. - No config key renames and no trusted-proxy setting — milestone 17.
- No
useInfiniteQuerymigration anywhere but the query log. - No new metrics beyond the families this spec names.
- No timeout on DoH request bodies or handlers — only
receiveHeadraces.