milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
+18
-18
@@ -99,22 +99,22 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 3: Silent failures and swallowed errors (11 findings)
|
||||
|
||||
- **[medium] src/filter/manager.zig:541 — loadSource swallows error.Canceled into a per-source `.load_failed` status.**
|
||||
- **[medium — CLOSED m16] src/filter/manager.zig:541 — loadSource swallows error.Canceled into a per-source `.load_failed` status.**
|
||||
Every other catch site in the file (roughly ten) propagates Canceled per the milestone-3 invariant "error.Canceled must never be recorded at all"; here a cancellation during a reload's file reads records a bogus "Canceled" load failure, consumes the one-shot cancellation signal, and publishes a snapshot with that source excluded. Fix: one more catch arm per readFileAlloc call (`error.Canceled => return error.Canceled`; Manager.Error already has the member).
|
||||
|
||||
- **[medium] src/platform/tls_server.zig:430 — BIO callbacks discard the concrete transport error, including cancellation.**
|
||||
- **[medium — CLOSED m16] src/platform/tls_server.zig:430 — BIO callbacks discard the concrete transport error, including cancellation.**
|
||||
net_reader.err/net_writer.err (which hold Reset/Timeout/Canceled) are never read; everything collapses to MBEDTLS_ERR_NET_RECV/SEND_FAILED, so a routine idle-budget cancel surfaces as a warn-level "mbedtls_ssl_read failed" and peer resets are indistinguishable from timeouts. The client side (dot_client's concreteRead/concreteWrite) solved exactly this. Fix: capture the stashed causes in the BIO callbacks and fold them into the error surface — note the ReadError set must be widened, not just the field captured.
|
||||
|
||||
- **[medium] src/web/server.zig:616 — a Cookie header over 1024 bytes silently degrades to unauthenticated.**
|
||||
- **[medium — CLOSED m16] src/web/server.zig:616 — a Cookie header over 1024 bytes silently degrades to unauthenticated.**
|
||||
copyHeader returns "" with no log; under the documented reverse-proxy-on-shared-domain deployment (foreign cookies riding along), every request 401s and the operator sees an unexplained login loop with zero diagnostics anywhere on the request path. Fix: extract only the nxdns_session pair from an oversized header (cookieValue already parses), or at minimum log the size-drop (no secret-logging conflict).
|
||||
|
||||
- **[medium] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.**
|
||||
- **[medium — CLOSED m16] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.**
|
||||
The refocus-after-30s refetch shifts the newest-100 boundary up while `extra` starts strictly below the old cursor; the missing rows are in neither, and the stale cursorOverride means load-more never heals it. On a live DNS server the trigger is routine. Fix: useInfiniteQuery (or disable background refetch while extra is non-empty), or detect the discontinuity and reset the accumulation.
|
||||
|
||||
- **[low] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.**
|
||||
Verification narrowed the blast radius: the pool's select harness means shutdown cancellation is handled correctly despite this, so the reachable impact is rare mid-exchange local-resource errors (e.g. ENOBUFS) recorded as peer faults against a healthy upstream — still a stated spec-invariant violation, fixed once for DoT and left in DoH. Fix: the same unwrap the other two transports carry.
|
||||
|
||||
- **[low] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
|
||||
- **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
|
||||
Reachable today via a race between startupPass and a web-inserted source; the milestone-5 policy says every non-ok state "is recorded and surfaced, not swallowed". Fix: warn on the fall-through or have refreshSourceLocked insert the missing entry.
|
||||
|
||||
- **[low] src/web/http_util.zig:335 — static header-array overflow reported as OutOfMemory.**
|
||||
@@ -166,13 +166,13 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 5: Protocol robustness (5 findings)
|
||||
|
||||
- **[medium] src/cache/dns_cache.zig:110 — truncated (TC=1) responses are cacheable and cached for full TTL.**
|
||||
- **[medium — CLOSED m16] src/cache/dns_cache.zig:110 — truncated (TC=1) responses are cacheable and cached for full TTL.**
|
||||
classify checks rcode and ancount but never `flags.tc`, and validateResponse doesn't either; a misbehaving upstream returning TC=1 over DoH/DoT/forward-TCP gets its partial answer cached and served (TC bit intact, even over TCP — a protocol violation that can loop retrying clients) for up to 86400s. RFC 2181 §9 forbids this; no spec declares it out of scope. Fix: reject tc in classify, arguably also in validateResponse.
|
||||
|
||||
- **[medium] src/filter/parsers.zig:66 — the format sniffer misclassifies hosts files with `##` banner comments as ABP.**
|
||||
- **[medium — CLOSED m16] src/filter/parsers.zig:66 — the format sniffer misclassifies hosts files with `##` banner comments as ABP.**
|
||||
hasAbpMarker runs before isComment and matches `##` unanchored with no comment guard (unlike the deliberately guarded `$` branch two lines later). Reproduced with the literal URLhaus banner style: the whole file parses as ABP, `0.0.0.0` enters the compiled domain set, and — worse than the original finding — hosts lines with inline reference comments containing URLs are silently dropped (real blocking loss). Fix caveat from verification: guarding the element-hiding branch with !isComment is a circular no-op; instead anchor the `##` match per the As-built note, or treat `##` at line start on a `#`-initial line as a banner.
|
||||
|
||||
- **[medium] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.**
|
||||
- **[medium — CLOSED m16] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.**
|
||||
Per the WHATWG spec a non-200 response fails the connection permanently after one error event, so the 3-consecutive-errors threshold is unreachable on exactly the 429/401 paths it was built for: the UI shows "Reconnecting…" forever, the capped state and session probe are dead, and the spec's required "too many live viewers + retry" state is violated. FakeEventSource has no readyState/reconnect semantics, so tests pass — the mocked-network class again. Fix: expose readyState on EventSourceLike, treat CLOSED in the error handler as permanent failure, and run the session probe there.
|
||||
|
||||
- **[low] src/filter/fetcher.zig:169 — TLS error classification by @errorName prefix string-matching.**
|
||||
@@ -183,39 +183,39 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 6: Concurrency, locking and lifecycle coupling (6 findings)
|
||||
|
||||
- **[medium] src/filter/manager.zig:609 — writer_lock held across every download of a refresh pass, stalling all web mutations' reloads.** *(merged: web/handlers/blocklists.zig:171, the same debt from the manual-refresh-endpoint angle)*
|
||||
- **[medium — CLOSED m16] src/filter/manager.zig:609 — writer_lock held across every download of a refresh pass, stalling all web mutations' reloads.** *(merged: web/handlers/blocklists.zig:171, the same debt from the manual-refresh-endpoint angle)*
|
||||
refreshAll holds writer_lock uncancelably across N × 300s download budgets plus compiles; every mutation handler ends in Manager.reload on the same lock, so an unrelated rule save parks uncancelably (on a request path with deliberately no timeout, occupying one of 64 slots) until the pass ends. The spec's serialization rationale covers only readers-never-wait; writer-vs-writer latency is unaddressed emergent behavior. DNS serving is unaffected and the stalled edit is not lost. Fix: download+compile to tmp files outside the lock, take it only for publish+DB write+reload — or a 202-with-poll job model for the manual endpoint.
|
||||
|
||||
- **[medium] src/storage/retention.zig:95 — VACUUM runs even when the disk monitor says critical.**
|
||||
- **[medium — CLOSED m16] src/storage/retention.zig:95 — VACUUM runs even when the disk monitor says critical.**
|
||||
The logger and blocklist refresher gate on `writesAllowed()`; the seventh-pass VACUUM — the most expensive write the program makes, needing roughly DB-size free space — fires unconditionally, transiently draining the exact filesystem the monitor watches, then failing SQLITE_FULL with no retry for seven daily passes. Prune and checkpoint should keep running (they free space). Fix: pass the optional monitor into runOnce the way the two sibling tasks already do, gating only the vacuum step.
|
||||
|
||||
- **[medium] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.**
|
||||
- **[medium — CLOSED m16] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.**
|
||||
Socket shutdown cannot wake a task parked on the per-slot Event; only the heartbeat write fails, up to 15s later — measured: the SSE integration teardown sits exactly 15s in group.await. Production is masked by the cancel path, but the documented "shutdown unblocks every connection" contract is violated. Fix: Hub.close(io) setting a shutdown flag and signaling every active slot's event, called from beginShutdown.
|
||||
|
||||
- **[low] src/web/handlers/groups.zig:70 — reload runs outside config_lock in five handlers but inside it in local.zig; the unlocked variant is safe only via an undocumented Manager invariant.**
|
||||
Safety depends on Manager.reload re-reading the database under its own writer_lock — a cross-module fact stated nowhere at the call sites; changing reload to accept pre-read rows (the shape swapLocalTables already has) would silently regress five handlers with the exact ordering bug the milestone-8 review already fixed once in local.zig. Fix: document the re-read requirement at mutations.reload, or hold config_lock uniformly and document the cost.
|
||||
|
||||
- **[low] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.**
|
||||
- **[low — CLOSED m16] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.**
|
||||
Stalls every mutation handler and the settings GET for the hash duration on the Pi 5, contradicting the lock discipline auth.zig documents and the login path follows. Hashing depends only on the parsed patch; the LiveHash generation check already handles the concurrent-install race. Fix: hash before acquiring the lock.
|
||||
|
||||
- **[low] src/server/doh_server.zig:66 — idle_timeout only bounds the handshake; idle keep-alive clients pin DoH slots until restart.**
|
||||
- **[low — CLOSED m16] src/server/doh_server.zig:66 — idle_timeout only bounds the handshake; idle keep-alive clients pin DoH slots until restart.**
|
||||
The web-listener precedent the spec cites assumes clients eventually close; DoH stubs hold keep-alives by design, and with no TCP keepalive a vanished peer pins a slot permanently — while DoT on the same LAN reclaims after 10s. Fix: extend the race to receiveHead the way DoT races readPrefix, or at least rename the misleading option.
|
||||
|
||||
## Theme 7: Observability debt (6 findings)
|
||||
|
||||
- **[medium] src/server/udp_server.zig:38 — UDP/53 and TCP/53 listener stats are written and never read anywhere.**
|
||||
- **[medium — CLOSED m16] src/server/udp_server.zig:38 — UDP/53 and TCP/53 listener stats are written and never read anywhere.**
|
||||
dropped_no_slot/dropped_oversize increment with no metric, API field, or log line (errors log only at debug below the default level), while the DoT/DoH siblings export equivalent counters to /metrics. The module doc sells "dropped and counted" as the failure surface; the surface is unobservable. Fix: give both the same snapshotStats() + metric families the TLS listeners have.
|
||||
|
||||
- **[medium] src/local/forward_client.zig:45 — ForwardClient.Stats are instrumented but discarded in production.**
|
||||
- **[medium — CLOSED m16] src/local/forward_client.zig:45 — ForwardClient.Stats are instrumented but discarded in production.**
|
||||
The doc claims the foreign-datagram counter prevents "an unrecorded failure mode"; the only production caller builds a stack-local client per query and drops it, so the spoofing signal does not exist and truncation/failure counters never reach /metrics. Fix: aggregate into handler-owned atomics like the rest of the pipeline, or delete the Stats struct.
|
||||
|
||||
- **[medium] src/server/dot_server.zig:313 — a timed-out handshake counts as tls_handshake_failures on DoT but idle_timeouts on DoH.**
|
||||
- **[medium — CLOSED m16] src/server/dot_server.zig:313 — a timed-out handshake counts as tls_handshake_failures on DoT but idle_timeouts on DoH.**
|
||||
The DoT classification is the recorded spec ruling; DoH drifted in the copy, and since DoH requests have no timeout, its idle_timeouts metric can only ever mean handshake stalls — the same exported name carries disjoint semantics per listener, corrupting cross-listener dashboards. Fix: align DoH's .timed_out to tls_handshake_failures per the ruling.
|
||||
|
||||
- **[medium] src/platform/tls_server.zig:311 — warn-level logging for routine peer misbehavior contradicts the module's own debug ruling.**
|
||||
- **[medium — CLOSED m16] src/platform/tls_server.zig:311 — warn-level logging for routine peer misbehavior contradicts the module's own debug ruling.**
|
||||
A peer TCP drop is deliberately debug on the read path, then the close_notify against the dead socket warns; probes that send bytes or reject the cert warn per event, and .tls_server is not in the dedup scope set, so peer-driven warns can evict genuine warnings from the rotating log. Fix: apply the read path's peer-vs-local triage to close/handshake reporting, or add the scope to the dedup set.
|
||||
|
||||
- **[medium] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.**
|
||||
- **[medium — CLOSED m16] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.**
|
||||
Designed for background reclamation (preallocated stale_keys so sweep never allocates), but only the DNS-side limiter got scheduled in runMaintenance. Once 4096 distinct addresses are seen, the table stays full forever and every unknown-address request pays an O(4096) scan under the limiter mutex. Fix: add it to the maintenance loop, or delete sweep and own inline-eviction-only.
|
||||
|
||||
- **[low] src/server/handler.zig:255 — two tracker-mutex acquisitions per query (scan + full stats copy) purely to mirror one counter.**
|
||||
|
||||
Reference in New Issue
Block a user