18 KiB
Memory budget: pay for what the household uses
Measured on the deployed Pi (0.0.21, aarch64, 2.85M blocklist entries, 18 days up): VmRSS 238 MiB, VmHWM 358 MiB, 207 threads, almost all private anonymous memory. The docs promised "memory with ~1M blocked domains < 100 MiB" (docs/explanation/performance-and-testing.md, removed in the user-docs rewrite); the Pi never met it. Nothing leaks. Every MiB below has a measured owner, and each owner pays for a worst case the household never reaches, or pays twice.
| Owner | Resident | How it was measured |
|---|---|---|
| Blocklist snapshot | 71 MiB, of which ~32 MiB is ABP domains stored twice | Pi smaps; .list and .wild bodies of the ABP sources are byte-identical (md5) |
| Fixed buffers | ~52 MiB: query-log writer queue 17.5, two TCP listeners 2 × 8.7, two UDP listeners 2 × 4.8, SSE hub 3.6, cache slots 3.3 | Sizes from source; resident at once because safe builds fill new allocations with 0xaa |
| Idle worker threads | ~41 MiB for ~200 parked workers | std.Io.Threaded never retires a worker; one burst of 64 in-flight UDP queries × 3 tasks each grows the pool for good (replica: 14 → 206 threads) |
| Heap slabs kept after peaks | ~61 MiB free + ~8 MiB live | Decoded SmpAllocator free lists from two /proc/<pid>/mem dumps; owners from each slot's size header and a local tracing build |
| Peak during refresh | +120 MiB over steady state | Old snapshot + new snapshot + every compiled body read at once; replica reproduced 350 MiB |
Heap owners, from the dumps and the trace:
- 8 KiB class, 25 MiB free: SQLite page-cache blocks (4,376 B requests in 8 KiB slots). Seven connections, no
cache_sizepragma, so each keeps the 2 MiB default, which costs ~3.7 MiB of slabs once filled. - 128 B + 256 B classes, ~21 MiB free:
std.Io.ThreadedGroup.Taskblocks — one per UDP query (UdpServer.receiveLoop), two per upstream exchange (transport.raceUntilTagged), two per query-log wait (Logger.getWithin). - 512 B class, 7.7 MiB free:
DnsCache.putcopies of cached responses (src/cache/dns_cache.zig:356).
Why freed small blocks stay resident: with libc linked, malloc is zig's lib/c/malloc.zig on std.heap.SmpAllocator, which never unmaps a 64 KiB slab. free pushes onto the freeing thread's slot and alloc searches one other slot before it maps a new slab (max_alloc_search = 1), so producer/consumer pairs map more slabs than their live peak needs. Upstream: ziglang/zig#23592. Fewer tasks and smaller peaks shrink this retained memory; nothing in this spec frees it after the fact.
Decisions
- Fix each owner at its root. Do not bypass the safe-build 0xaa fill: it only makes the oversizing visible.
- Do not change the allocator in this work. Replacing
init.gpadoes not move SQLite, which calls Cmalloc. Revisit only if the measurement gate still names small-class slabs as a top owner. - No compiled-format change, no config-key migration, no socket-shutdown deadline. Each of those was considered and rejected in review: the format change makes the upgrade depend on the network, packed query-log rings cost more complexity than sizing the queue, and a socket-shutdown deadline cannot bound
connect(std.Io.Threadedhas no connect timeout) or a wait for a permit. Permit waits take their deadline on a futex instead (item 3). - Not
SQLITE_CONFIG_PAGECACHE, not a database page-size migration.
Contract
0. Make the budget measurable first
The admission budget, the listener caps and the queue size are chosen from the Pi's real peaks, so those peaks become measurements before anything is resized:
- high-water of in-flight DNS queries:
nxdns_dns_in_flight_peak, counted aroundHandler.handle, every listener together; - high-water of active stream connections, per listener (today
connectionscounts accepts over time, not concurrency):nxdns_tcp_server_connections_peak(the larger of the two address families),nxdns_dot_server_connections_peak,nxdns_doh_server_connections_peak; - high-water of the
std.Io.Threadedworker pool: the process thread count.Threadedspawns a worker only when every worker is busy and never retires one (Threaded.zig:2110), so the count is that high-water already and needs no code. It counts workers, not tasks:asyncwork can run inline or wait in the run queue; - high-water of the query-log writer queue's occupancy (not arrivals per interval: a full batch flushes early, and a disk gate or a slow write lets backlog build past one interval):
nxdns_querylog_queue_peak.std.Io.Queueexposes no length, so a count beside it is raised before each insert and lowered after each removal; it can overstate the peak by the entries in transit, never understate it; - worker call-stack use, read from
/proc/<pid>/smaps: each worker stack is an anonymous mapping directly above its own guard page, all of one size, and itsRssis what the thread has touched and still holds resident. It is a record, not an input: a worker stack costs only the pages it touches, sostack_sizeis address space and lowering it saves no resident memory.stack_sizekeeps its default. The signal stack (std_options.signal_stack_size) is a separate allocation for signal handling and stack traces and keeps its stdlib default unless its own use is measured.
The five gauges are in /metrics beside the existing per-reason drop counters. One release carrying only these measurements runs on the Pi before the budget values are fixed.
First reading, static x86_64 musl build, one burst of 300 UDP queries: the in-flight peak reached 64 (the UDP slot pool), threads went from 14 to 205, and of 201 worker stacks 175 touched 32 kB or less while 26 touched 2 MiB each — the same 2 MiB with only forward-zone traffic and no DoT exchange, so the TLS client is not the owner. That 2 MiB was transparent huge pages, not a code path: the build host runs THP in always mode and backs a touched 2 MiB-aligned stack range with one huge page. The Pi runs madvise. Pi reading on 0.0.22 after 31 h (16 KiB pages): of 91 worker stacks, 6 hold 32 kB, 2 hold 80–96 kB, 73 hold 192 kB and 10 hold 208–256 kB, none with a huge page. The resident cost of the pool is therefore about 192 kB of stack per worker, which item 3 reduces by bounding the worker count.
1. Store each blocklist name once in memory
Within one source, as compiled entries, an exact x in .list together with x in .wild means exactly what ||x^ compiles to. The compiled files, their checksum and their counts stay as they are.
- The snapshot builder merges one source's sorted
.listand.wildbodies into one name set with two flags per name:exactandsubdomains. A name in both bodies is stored once. Sources are never merged with each other, so group eligibility and the reported source do not change. .allowstays a separate set, and level 7 (exceptions) still runs before both flag checks.- Level 8 tests the full name for
exact; level 9 tests proper parents forsubdomains. Sources still walk in ascending index order. The reported result stays what it is today: when thexentry wins, the apex reportsblocklist_domainwith matchedxand a proper subdomain reportsblocklist_wildcardwith matched parentx. An exception, an exact entry for the queried name or a nearer matching parent still wins first.
2. Lower the refresh and boot peak
- Boot keeps one synchronous initial load before the listeners start (
src/app.zig:692). The startup pass uses that result for its refresh decisions and reloads only after a source actually refreshed, instead of rebuilding unconditionally (src/filter/manager.zig:1823). Snapshotgets a pureBuilder: add one source's bodies, then free them before reading the next. The old snapshot is still freed only after the swap, so a reload holds at most old snapshot + new snapshot + one source's bodies. That is the reload bound, not the process peak: refresh preparation (download, compile) can run beside a reload and holds its own allocations, and the Pi peak measurement includes it.
3. Bound concurrent DNS work
- Two kinds of bound: one budget of query permits, shared by every listener (UDP and TCP, both families, DoT/DoH when enabled), and a slot cap per stream listener on accepted connections. A query takes a permit only while it does work; a keep-alive connection releases its permit between requests.
- The work is bounded where it enters, not in the thread pool.
mainkeepsstd.process.Initand itsstd.Io.Threadedsets noconcurrent_limit: a limit below the tasks the admitted work can nest turns a burst intoConcurrencyUnavailablefailures inside admitted queries, and a limit sized to cover them bounds nothing the budget does not already bound.ConcurrencyUnavailabletherefore means the system refused a thread or a task could not be allocated, and it is counted at every boundary where it can surface asnxdns_concurrency_unavailable_total{site}(platform/concurrency.zig). - A query that finds every permit taken waits for one; it is neither dropped nor answered SERVFAIL. glibc's stub retries a SERVFAIL once at once and then fails the lookup, and a dropped UDP query costs the client its whole retry timeout, while a wait behind a permit that frees within the upstream deadline is invisible to it. Every held permit is bounded (the upstream total deadline, the stream write budget), so a wait always ends.
- UDP: the receive loop receives into its own staging buffer, then waits for a permit holding that datagram, and receives nothing meanwhile. The backlog stays in the socket's kernel buffer, bounded by
SO_RCVBUF; what overflows it is dropped by the kernel and exported from/proc/net/udp{,6}by socket inode asnxdns_udp_server_kernel_drops_total, with the queued bytes asnxdns_udp_server_receive_queue_bytesand a failed read asnxdns_udp_server_kernel_read_failures_total. - Streams: a whole query read off the connection waits at most the connection's idle budget. A wait that outlasts it ends the connection unanswered, because DNS-over-TCP has no framing for "no answer"; DoH closes too.
- Every wait is counted:
nxdns_dns_query_permit_waits_total,nxdns_dns_query_permit_wait_seconds_total,nxdns_dns_query_permit_wait_timeouts_totaland the gaugenxdns_dns_query_permits_waiting. - A listener that is closing wakes the budget's waiters, so its
deinitdoes not wait for a permit to free.
- UDP: the receive loop receives into its own staging buffer, then waits for a permit holding that datagram, and receives nothing meanwhile. The backlog stays in the socket's kernel buffer, bounded by
- A query waits on its own thread. The budget and the upstream pool's per-entry admission sleep on a futex word with the caller's deadline (
platform/wake_word.zig) instead of racing a condition wait against a timer task, which cost two extra threads per waiter. A waiter that leaves without a permit hands its wake on if a permit is free. - The upstream exchange keeps its whole-operation deadline race and its distinction between peer result, budget expiry and cancellation.
- Stream listeners keep
listener.racefor their idle reads, writes and handshakes: three threads per open connection, at most 16 per listener. Removing those two threads needs a non-blocking, socket-aware TLS and HTTP transport with its own deadline, reset and partial-I/O handling; std's timed file reads do not provide one (a blocking syscall follows the poll, andECONNRESETon a timed write is anerrnoBug). That transport costs more than the threads it saves at household scale. - The process opts out of transparent huge pages at startup (
PR_SET_THP_DISABLE,platform/huge_pages.zig), with one warning if the kernel refuses. With THP set toalways, a worker stack that touches a few KiB holds a 2 MiB page and keeps it, because workers are never retired: a 64-query burst on such a host left about 40 of 200 workers at 2 MiB each. - What this costs, stated plainly: a burst beyond the budget is answered later, not faster; a UDP burst that also outruns the socket buffer loses datagrams to the kernel, counted; a stream query can wait out its idle budget and get no answer; an upstream in backoff is still probed, so a dead upstream costs each query its deadline and ends in SERVFAIL from the handler, as before.
- As built: 64 query permits for the whole process (
query_budget.permits), each holding the datagram, the reply buffer and the handler scratch, about 4.6 MiB in all. The Pi'snxdns_dns_in_flight_peakwas 27 over a day of household traffic; 64 simultaneous uncached queries on two DoT upstreams (x86_64 musl ReleaseSafe) were all answered, the slowest in 0.2 s, and grew the pool from 14 to 205 threads, which is the all-active envelope of 64 × 3 plus the fixed services, about 40 MiB of stack at the measured 200 KiB high-water per worker. 16 connections per DNS stream listener (listener.dns_max_connections), a policy rather than a measurement: the Pi's TCP peak was 0 and it runs neither DoT nor DoH.
4. Size the query-log buffers by use
- The writer queue default (
logging.query_log_buffer_max) comes from the item-0 occupancy high-water, with headroom stated in the reference. Drop-oldest backpressure and its counter stay. - The SSE hub allocates a subscriber's ring on subscribe and frees it on unsubscribe;
max_subscribersstays a cap, not a reservation.subscribebecomes fallible: an allocation failure is distinct from "all slots occupied", is counted, answers with its own error rather than "live stream is full" (src/web/handlers/live.zig:75), and leaves the slot reusable. Overflow keeps today's contract: the hub ends that subscriber's stream soEventSourcereconnects (src/web/sse.zig:8).
5. SQLite caches chosen per connection
- An explicit runtime-connection initialization step sets
cache_sizefrom a constant per role (query-log writer, retention, readers, config).Db.openstays free of pragmas, because migration callers control when pragmas apply (src/storage/db.zig:302). Every runtime handle reads its value back.
6. DNS cache bounded in bytes
DnsCachekeeps its entry count and adds a budget on live response bytes, tracked separately frommemoryBytes(which also counts slots and index).putevicts by CLOCK until the copy fits. A response larger than the whole budget is not cached and is counted. A replacement keeps the old entry if allocating the new copy fails. A failed copy allocation is counted; todayhandler.zig:918discards it withcatch {}andDnsCache.Statshas no field for it. The answer already obtained is still sent. The budget, the live bytes and both counters appear in cache diagnostics.- The budget is not a config key (owner, 2026-10-21: an operator has no basis for choosing it). It follows
cache.size: 256 bytes per entry (about 2.4 MiB at the default 10000), about 2.5 times the Pi's measured average of 100 bytes per cached response, and never less than one 65535-byte message, so a small cache still caches any answer a large one would. The live bytes and the budget are reported asnxdns_cache_response_bytesandnxdns_cache_max_bytes.
Propagation
- Config: the changed default of
logging.query_log_buffer_max. No new key (item 6). - Diagnostics: the item-0 counters,
ConcurrencyUnavailable, oversized cache responses, cache live bytes and budget. - Docs: the user docs make no memory claim. The measured RSS, refresh peak and thread count at the Pi's blocklist size go into the measurement record below and the release's CHANGELOG entry.
Acceptance
- Matcher equivalence: a deterministic corpus (hosts, domains, ABP with
||x^,*.xalone,xalone,xwith*.x,@@, an exact entry for a subdomain of a wildcard, nested wildcards*.xand*.y.x, overlapping sources) gives the sameDecisionfor every probe name before and after item 1. - Builder: a reload holds at most one source's bodies (tested through an allocator that records peak bytes).
- Boot: one snapshot build when no source refreshes; listeners never start without the initial load having run.
- Measurements: each of the five item-0 gauges rises under a scripted load and is visible in
/metrics; the thread count and the worker stack residency are recorded from/procbeside them. - Bounds: with the query budget full, a further UDP query waits and is answered once a permit frees, and the wait is counted; a stream query waits at most its idle budget, and a timeout ends the connection and is counted; closing a listener ends its waits without a permit freeing; a waiter canceled or timed out hands its wake on; a stream listener at its slot cap refuses and counts; a keep-alive connection between requests holds no query permit; the UDP sockets' kernel queue and drops are exported;
ConcurrencyUnavailableis counted at each enumerated boundary, including the logger wait and the listener races. - SSE: no ring is allocated without a subscriber; an allocation failure is counted, reported as its own error and leaves the slot reusable; overflow still ends the stream.
- SQLite: each runtime handle reports its configured
cache_size; migrations open without it. - DNS cache: puts beyond the byte budget evict; live bytes never exceed it; an oversized response is skipped and counted; a failed replacement keeps the old entry; a failed copy allocation is counted and the answer is still sent.
- Suites:
zig build testand-Dintegration0 failed; admin typecheck/vitest/prettier clean; contract samples regenerated. - Measurement record, on the Pi with the same blocklists: steady VmRSS after 24 h, VmHWM across one scheduled refresh (including its concurrent download and compile), thread count, and a decoded heap dump per size class, each beside the baseline in the table above. The owner chose a record, not a pass threshold: each owner must be reduced as specified, and the numbers go into this spec beside the baseline and into the release's CHANGELOG entry. If small-class slabs remain a top owner, the allocator question reopens with that data.