server: a query that finds every permit taken waits for one instead of failing, the budget is 64, permit and upstream turns wait on a futex with one thread each, the thread pool has no task limit, and transparent huge pages are off for the process

This commit is contained in:
2026-10-22 20:26:25 +02:00
parent 63cc5f8dc3
commit 91ed5ae774
26 changed files with 1114 additions and 305 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ Sections are written by hand. Nothing here is generated from commit messages: th
- **Blocklists take less memory, and a refresh peaks lower.** A list that names a domain both on its own and with its subdomains, as Adblock-style lists do for every entry, now stores that name once instead of twice. A reload reads one list's compiled files at a time instead of all of them at once, and the startup pass no longer rebuilds the blocklists when no list was due for a refresh. - **Blocklists take less memory, and a refresh peaks lower.** A list that names a domain both on its own and with its subdomains, as Adblock-style lists do for every entry, now stores that name once instead of twice. A reload reads one list's compiled files at a time instead of all of them at once, and the startup pass no longer rebuilds the blocklists when no list was due for a refresh.
- **The query-history buffer defaults to 1024 queries** instead of 10000, under 2 MiB instead of about 17. It only holds queries that arrive while a write is in progress. - **The query-history buffer defaults to 1024 queries** instead of 10000, under 2 MiB instead of about 17. It only holds queries that arrive while a write is in progress.
- **A live query stream takes memory only while someone watches it.** Each of the 32 possible streams reserved its buffer at startup, about 3.6 MiB in all. A stream that cannot get its buffer is refused with its own message and counted in `nxdns_live_stream_allocation_failures_total`. - **A live query stream takes memory only while someone watches it.** Each of the 32 possible streams reserved its buffer at startup, about 3.6 MiB in all. A stream that cannot get its buffer is refused with its own message and counted in `nxdns_live_stream_allocation_failures_total`.
- **All DNS listeners share one budget of 32 queries at a time**, about 2.4 MiB, where each UDP and TCP listener used to reserve room for 64 of its own, about 27 MiB across the four. The Pi's busiest moment over 37 hours was 27 queries at once. A query beyond the budget is dropped and counted rather than queued, so a device retries it: watch `nxdns_udp_server_dropped_no_slot_total` and the stream listeners' new `dropped_no_permit_total`. TCP, DoT and DoH each accept 16 connections at a time instead of 64, and the DoT and DoH listeners now report `rejected_at_capacity_total` too. - **All DNS listeners share one budget of 64 queries at a time**, about 4.6 MiB, where each UDP and TCP listener used to reserve room for 64 of its own, about 27 MiB across the four. The Pi's busiest moment over a day was 27 queries at once. A query beyond the budget waits for a free one instead of failing: over UDP it waits in the system's socket buffer, over TCP, DoT and DoH for at most the connection's idle time. `/metrics` counts the waits as `nxdns_dns_query_permit_waits_total`, their time as `nxdns_dns_query_permit_wait_seconds_total` and stream waits that ran out as `nxdns_dns_query_permit_wait_timeouts_total`, shows `nxdns_dns_query_permits_waiting`, and reports what the system holds and drops for the UDP/53 sockets as `nxdns_udp_server_receive_queue_bytes` and `nxdns_udp_server_kernel_drops_total`. TCP, DoT and DoH each accept 16 connections at a time instead of 64, and the DoT and DoH listeners now report `rejected_at_capacity_total` too.
- **The server runs a bounded number of threads**, sized from those caps, instead of starting a new one whenever all were busy. Work that could not get a thread used to fail as a generic error or not be counted at all; `/metrics` now counts it as `nxdns_concurrency_unavailable_total`, by where it happened. - **A query waiting for its turn at an upstream holds one thread instead of three**, so a burst grows the server's thread pool less, and transparent huge pages are switched off for the process, so a thread's stack no longer holds 2 MiB on systems that enable them for everything. Work that could not get a thread used to fail as a generic error or not be counted at all; `/metrics` now counts it as `nxdns_concurrency_unavailable_total`, by where it happened.
- **Each database connection keeps a page cache sized for its job** instead of SQLite's 2 MiB default on all seven: 256 KiB for the configuration database and for retention, 1 MiB for the query-history writer and reader. - **Each database connection keeps a page cache sized for its job** instead of SQLite's 2 MiB default on all seven: 256 KiB for the configuration database and for retention, 1 MiB for the query-history writer and reader.
- **The documentation is written for people running nxdns.** It starts from Get started, which downloads a release instead of building from source, and drops the API reference, the troubleshooting page and the performance pages. It is published at <https://nxdns.sh/docs/>, and `INSTALL.md` in the tarball links there. - **The documentation is written for people running nxdns.** It starts from Get started, which downloads a release instead of building from source, and drops the API reference, the troubleshooting page and the performance pages. It is published at <https://nxdns.sh/docs/>, and `INSTALL.md` in the tarball links there.
+2 -1
View File
@@ -56,7 +56,8 @@ Serves a household LAN (≈2–20 devices). Portfolio-grade public repo with ext
Zig 0.16 stdlib is built on the `std.Io` interface: `std.Io.net` owns networking (`std.net` in its old form is gone) and `std.http.Client` requires an `io: Io`. Therefore: Zig 0.16 stdlib is built on the `std.Io` interface: `std.Io.net` owns networking (`std.net` in its old form is gone) and `std.http.Client` requires an `io: Io`. Therefore:
- **`Io` is the injected platform abstraction.** Every component that does I/O takes `io: Io`. No project-owned wrapper interfaces around it — a second abstraction over an abstraction with one consumer is waste. - **`Io` is the injected platform abstraction.** Every component that does I/O takes `io: Io`. No project-owned wrapper interfaces around it — a second abstraction over an abstraction with one consumer is waste.
- **Backend: `std.Io.Threaded`** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. The originally planned io_uring config flag was dropped in milestone 11: `std.Io.Evented` at Zig 0.16.0 stubs the networking a server needs — listen, accept, connect, lookup, and stream reads/writes return `error.NetworkDown` (`Uring.zig` netListenIp/netAccept/netConnectIp/netRead/netWrite) — so a selectable backend would boot a dead server. The code stays backend-agnostic by construction; revisit when std ships working evented networking. - **Backend: `std.Io.Threaded`** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. The originally planned io_uring config flag was dropped in milestone 11: `std.Io.Evented` at Zig 0.16.0 stubs the networking a server needs — listen, accept, connect, lookup, and stream reads/writes return `error.NetworkDown` (`Uring.zig` netListenIp/netAccept/netConnectIp/netRead/netWrite), and a receive inside a batch panics with `TODO` — so a selectable backend would boot a dead server. A replacement or hybrid backend of our own would bring runtime-level scheduling, cancellation, timer and transport adapters whose complexity is out of proportion to a household workload, so nxdns stays on `std.Io.Threaded`.
- **Work is bounded where it enters, not in the thread pool** (specs/memory-budget.md item 3). Every DNS listener draws from one budget of 64 query permits, and each stream listener accepts at most 16 connections. A query that finds no permit waits for one rather than being dropped or answered SERVFAIL; UDP's backlog stays in the kernel socket buffer, whose drops are exported. The `Threaded` pool has no task limit and grows to the peak the bounds allow; its workers are never retired. Waits for a permit sleep on a futex with a deadline, one thread each.
- No hand-written thread pool. `Io` async/concurrent/Group covers task management. - No hand-written thread pool. `Io` async/concurrent/Group covers task management.
- Core domain modules (`dns`, `filter`, `cache`) stay pure: no `Io`, no sockets — bytes in, bytes out. Only servers, upstream clients, and storage touch `Io`. - Core domain modules (`dns`, `filter`, `cache`) stay pure: no `Io`, no sockets — bytes in, bytes out. Only servers, upstream clients, and storage touch `Io`.
+15 -9
View File
@@ -22,7 +22,7 @@ Why freed small blocks stay resident: with libc linked, `malloc` is zig's `lib/c
- Fix each owner at its root. Do not bypass the safe-build 0xaa fill: it only makes the oversizing visible. - 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.gpa` does not move SQLite, which calls C `malloc`. Revisit only if the measurement gate still names small-class slabs as a top owner. - Do not change the allocator in this work. Replacing `init.gpa` does not move SQLite, which calls C `malloc`. Revisit only if the measurement gate still names small-class slabs as a top owner.
- No compiled-format change, no config-key migration, no new deadline mechanism. 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.Threaded` has no connect timeout) or the pool admission wait. - 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.Threaded` has 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. - Not `SQLITE_CONFIG_PAGECACHE`, not a database page-size migration.
## Contract ## Contract
@@ -56,13 +56,19 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
### 3. Bound concurrent DNS work ### 3. Bound concurrent DNS work
- `main` takes `std.process.Init.Minimal` and owns its `std.Io.Threaded`, with a `concurrent_limit` derived from the chosen query and connection caps, the tasks each can nest, and the fixed services. The measured task high-water already counts every caller, so it is the cross-check, not a term in the sum. - 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.
- `ConcurrencyUnavailable` is counted at every boundary where it can surface. The spec work enumerates each `io.concurrent` and `Group.concurrent` call; today the logger turns the error into an early flush without counting it (`src/storage/logger.zig:748`) and the listener races fold it into a generic failure (`src/server/listener.zig:102`). Each gets its own counter or a reason on an existing one. - The work is bounded where it enters, not in the thread pool. `main` keeps `std.process.Init` and its `std.Io.Threaded` sets no `concurrent_limit`: a limit below the tasks the admitted work can nest turns a burst into `ConcurrencyUnavailable` failures inside admitted queries, and a limit sized to cover them bounds nothing the budget does not already bound. `ConcurrencyUnavailable` therefore means the system refused a thread or a task could not be allocated, and it is counted at every boundary where it can surface as `nxdns_concurrency_unavailable_total{site}` (`platform/concurrency.zig`).
- Two kinds of bound: one cap on active DNS queries, shared by every listener (UDP and TCP, both families, DoT/DoH when enabled), and a slot cap per stream listener on accepted connections, each from the item-0 high-waters. A query takes a permit only while it does work; a keep-alive connection releases its permit between requests. - 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 keeps today's counted drop when no permit is free (`dropped_no_slot`). The kernel buffer does not absorb overload invisibly. - 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 as `nxdns_udp_server_kernel_drops_total`, with the queued bytes as `nxdns_udp_server_receive_queue_bytes` and a failed read as `nxdns_udp_server_kernel_read_failures_total`.
- The upstream exchange keeps its whole-operation `Select` deadline and its distinction between peer result, budget expiry and cancellation. The thread high-water falls because the query budget is smaller, not because the race changes. - 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.
- UDP slots and TCP connection buffers are sized from these bounds, not 64 per listener. - Every wait is counted: `nxdns_dns_query_permit_waits_total`, `nxdns_dns_query_permit_wait_seconds_total`, `nxdns_dns_query_permit_wait_timeouts_total` and the gauge `nxdns_dns_query_permits_waiting`.
- As built: 32 query permits for the whole process (`query_budget.permits`; the Pi's `nxdns_dns_in_flight_peak` was 27 over 37 h), each holding the datagram, the reply buffer and the handler scratch, about 2.4 MiB in all. 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. A stream query that finds no permit closes its connection and is counted as `dropped_no_permit`, on DoH too, where a 503 would need a reply buffer the query does not have. `async_limit` is `.nothing`, so every pool task is a counted `concurrent` one, and `app.concurrent_limit` enumerates them. `ConcurrencyUnavailable` is `nxdns_concurrency_unavailable_total{site}`, one label per boundary (`platform/concurrency.zig`). - A listener that is closing wakes the budget's waiters, so its `deinit` does not wait for a permit to free.
- 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.race` for 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, and `ECONNRESET` on a timed write is an `errnoBug`). 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 to `always`, 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's `nxdns_dns_in_flight_peak` was 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 ### 4. Size the query-log buffers by use
@@ -90,7 +96,7 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
- [ ] Builder: a reload holds at most one source's bodies (tested through an allocator that records peak bytes). - [ ] 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. - [ ] 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 `/proc` beside them. - [ ] 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 `/proc` beside them.
- [ ] Bounds: with the query budget full, a further UDP query is dropped and counted; a stream listener at its slot cap refuses and counts; a keep-alive connection between requests holds no query permit; `ConcurrencyUnavailable` is counted at each enumerated boundary, including the logger wait and the listener races. - [ ] 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; `ConcurrencyUnavailable` is 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. - [ ] 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. - [ ] 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. - [ ] 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.
+7 -35
View File
@@ -48,7 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig"); const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig"); const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig"); const http_util = @import("web/http_util.zig");
const listener = @import("server/listener.zig"); const huge_pages = @import("platform/huge_pages.zig");
const loader = @import("config/loader.zig"); const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig"); const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig"); const local_tables = @import("server/local_tables.zig");
@@ -89,40 +89,6 @@ const maintenance_interval_s = 60;
/// takes longer than this is not going to finish at all. /// takes longer than this is not going to finish at all.
const download_budget_s = 300; const download_budget_s = 300;
/// The most tasks the process's `std.Io.Threaded` runs at once, which `main`
/// sets as its `concurrent_limit` (specs/memory-budget.md item 3). Each
/// `Threaded` worker holds a task and is never retired, so this also bounds
/// the worker threads. `main` runs `async` work inline, so every task is one of
/// the `concurrent` ones counted here; a term that is wrong shows up as
/// `nxdns_concurrency_unavailable_total`, not as a hang.
///
/// An upstream or forward-zone exchange, an upstream admission wait, a stream
/// read or write and a TLS handshake each race one task against a timer task,
/// and a task runs at most one such race at a time.
pub const concurrent_limit =
// A UDP query: its `respondOne` task and the race of its exchange. A stream
// query runs on its connection's task and is counted below.
query_budget.permits * 3 +
// tcp6, tcp4, dot and doh: each connection's task and its race.
4 * @as(usize, listener.dns_max_connections) * 3 +
// A web connection's task. It runs no race of its own; the two below are
// shared with the services that start them.
web_server.default_max_connections +
// A blocklist download and its timer, on the scheduler's task or a web
// connection's, one at a time under the manager's refresh lock.
2 +
// A client-naming PTR lookup and its timer, on the tracker's task, and
// inside it the forward exchange's own race.
2 + 2 +
// The services `serve` starts: six DNS listeners, two certificate
// watchers, retention, the disk monitor, the blocklist scheduler, the
// client tracker, maintenance and the web listener.
14 +
// The query-log writers: the live one and, across a resize, the one before
// or after it, each waiting for its next entry against a timer; and the
// reaper.
2 * 3 + 1;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: { const code = serve(runner, args) catch |err| code: {
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {}; runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
@@ -427,6 +393,11 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const gpa = r.gpa; const gpa = r.gpa;
const paths = args.paths; const paths = args.paths;
// First, so no mapping the server makes is backed by a huge page.
huge_pages.disable() catch |err| {
log.warn("transparent huge pages stay enabled ({t}); worker stacks may each hold 2 MiB", .{err});
};
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// storage and configuration // storage and configuration
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -979,6 +950,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
udp_count += 1; udp_count += 1;
} }
web_state.udp_listeners = udp_listeners[0..udp_count]; web_state.udp_listeners = udp_listeners[0..udp_count];
web_state.query_budget = &budget;
var tcp_listeners: [2]*tcp_server.TcpServer = undefined; var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
var tcp_count: usize = 0; var tcp_count: usize = 0;
+1 -1
View File
@@ -1038,7 +1038,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
.priority = server.priority, .priority = server.priority,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = slots.len }, .admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &recoveries, .reuse_recoveries = &recoveries,
}}; }};
var single: pool.Pool = .init(&entries, .{}, timeouts, seed); var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
+8 -33
View File
@@ -3,8 +3,6 @@
//! parameters and is therefore testable without a process. //! parameters and is therefore testable without a process.
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const app = @import("app.zig");
const cli = @import("cli.zig"); const cli = @import("cli.zig");
const logging = @import("platform/logging.zig"); const logging = @import("platform/logging.zig");
@@ -42,51 +40,28 @@ fn runnerWriter(file: std.Io.File, io: std.Io, buffer: []u8) std.Io.File.Writer
return file.writerStreaming(io, buffer); return file.writerStreaming(io, buffer);
} }
/// The allocator `start.zig` would choose: leak-checking in Debug, and libc's pub fn main(init: std.process.Init) u8 {
/// otherwise, because SQLite allocates through `malloc` either way.
const use_debug_allocator = builtin.mode == .Debug;
/// `main` builds the `Threaded` itself, which the full `std.process.Init` would
/// build with neither limit set (specs/memory-budget.md item 3).
pub fn main(init: std.process.Init.Minimal) u8 {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer if (use_debug_allocator) {
_ = debug_allocator.deinit();
};
const gpa = if (use_debug_allocator) debug_allocator.allocator() else std.heap.c_allocator;
var threaded: std.Io.Threaded = .init(gpa, .{
.argv0 = .init(init.args),
.environ = init.environ,
// Inline, so no `async` task holds a worker beside the counted
// `concurrent` ones.
.async_limit = .nothing,
.concurrent_limit = .limited(app.concurrent_limit),
});
defer threaded.deinit();
const io = threaded.io();
// Both `File.Writer` values are self-referential and must not move, so they // Both `File.Writer` values are self-referential and must not move, so they
// stay in these `var` slots for the whole of `main`. // stay in these `var` slots for the whole of `main`.
var out_buffer: [4096]u8 = undefined; var out_buffer: [4096]u8 = undefined;
var err_buffer: [4096]u8 = undefined; var err_buffer: [4096]u8 = undefined;
var out = runnerWriter(std.Io.File.stdout(), io, &out_buffer); var out = runnerWriter(std.Io.File.stdout(), init.io, &out_buffer);
var err = runnerWriter(std.Io.File.stderr(), io, &err_buffer); var err = runnerWriter(std.Io.File.stderr(), init.io, &err_buffer);
const runner: cli.Runner = .{ const runner: cli.Runner = .{
.io = io, .io = init.io,
.gpa = gpa, .gpa = init.gpa,
.out = &out.interface, .out = &out.interface,
.err = &err.interface, .err = &err.interface,
}; };
var argv: std.ArrayList([]const u8) = .empty; var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(gpa); defer argv.deinit(init.gpa);
var args = init.args.iterate(); var args = init.minimal.args.iterate();
_ = args.skip(); _ = args.skip();
while (args.next()) |arg| { while (args.next()) |arg| {
argv.append(gpa, arg) catch return cli.exit_runtime; argv.append(init.gpa, arg) catch return cli.exit_runtime;
} }
const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e); const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e);
+13 -12
View File
@@ -1,15 +1,16 @@
//! Where `error.ConcurrencyUnavailable` surfaced (specs/memory-budget.md item 3). //! Where `error.ConcurrencyUnavailable` surfaced (specs/memory-budget.md item 3).
//! //!
//! `main` caps the process's one `std.Io.Threaded` at a task count the //! The process's `std.Io.Threaded` sets no task limit: the work is bounded
//! accounting in `app.zig` says is never reached, so this error means the //! where it enters, by the shared query budget and each listener's connection
//! accounting is wrong or the system refused a thread. Either way the work it //! cap, and the pool grows to that peak. So this error means the system
//! cost is lost, and each boundary that can lose work to it counts here under //! refused a thread or the pool could not allocate a task. Either way the work
//! its own site rather than folding it into a generic failure. //! it cost is lost, and each boundary that can lose work to it counts here
//! under its own site rather than folding it into a generic failure.
//! //!
//! Process-wide, like the log sink's counters: the cap is a property of the //! Process-wide, like the log sink's counters: the failure belongs to the one
//! one `Threaded` every subsystem shares, not of any one of them, and a //! `Threaded` every subsystem shares, not to any one of them, and a counter
//! counter beside each call site keeps transport helpers free of a stats //! beside each call site keeps transport helpers free of a stats pointer they
//! pointer they would otherwise thread through for this alone. Never reset, //! would otherwise thread through for this alone. Never reset,
//! so a test reads a delta, not a value. //! so a test reads a delta, not a value.
const std = @import("std"); const std = @import("std");
@@ -25,9 +26,9 @@ pub const Site = enum {
/// A stream listener's read, write or TLS handshake that could not start /// A stream listener's read, write or TLS handshake that could not start
/// its race against the idle budget. The connection ends. /// its race against the idle budget. The connection ends.
listener_race, listener_race,
/// An upstream or forward-zone exchange, or an upstream admission wait, /// An upstream or forward-zone exchange that could not start its race
/// that could not start its race against the query's deadline. The /// against the query's deadline. The exchange fails as a local resource
/// exchange fails as a local resource fault. /// fault.
exchange_race, exchange_race,
/// A forward-zone receive whose timed wait could not start. /// A forward-zone receive whose timed wait could not start.
forward_receive, forward_receive,
+36
View File
@@ -0,0 +1,36 @@
//! Opting the process out of transparent huge pages.
//!
//! With THP set to `always`, the kernel backs any 2 MiB-aligned anonymous
//! region with a huge page on first touch. Each worker thread's stack mapping
//! qualifies, so a thread that touches a few KiB of stack can hold 2 MiB
//! resident, and `std.Io.Threaded` never retires a worker: one burst on such a
//! host left about 40 of 200 workers at 2 MiB each. nxdns's memory is sized
//! for small pages, so the whole process opts out instead of chasing each
//! mapping.
const std = @import("std");
const linux = std.os.linux;
pub const DisableError = error{
/// A kernel older than 3.15, which has no such switch.
Unsupported,
Unexpected,
};
/// Sets `PR_SET_THP_DISABLE`, which covers every mapping of the process,
/// existing and future, and is inherited across `fork`.
pub fn disable() DisableError!void {
const rc = linux.prctl(@intFromEnum(linux.PR.SET_THP_DISABLE), 1, 0, 0, 0);
switch (linux.errno(rc)) {
.SUCCESS => {},
.INVAL => return error.Unsupported,
else => return error.Unexpected,
}
}
test "disable takes effect for the process" {
try disable();
const rc = linux.prctl(@intFromEnum(linux.PR.GET_THP_DISABLE), 0, 0, 0, 0);
try std.testing.expectEqual(linux.E.SUCCESS, linux.errno(rc));
try std.testing.expectEqual(@as(usize, 1), rc);
}
+201
View File
@@ -0,0 +1,201 @@
//! The kernel's own counters for this process's UDP sockets, read from
//! `/proc/net/udp` and `/proc/net/udp6`.
//!
//! A UDP listener that is waiting for a query permit leaves datagrams in its
//! socket's receive buffer, and what overflows that buffer is dropped by the
//! kernel before nxdns ever sees it. Only the kernel can count those, so each
//! listener's socket is found in the tables by its inode and the row's
//! `rx_queue` (bytes waiting) and `drops` are reported as they are.
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.udp_socket_stats);
pub const Reading = struct {
receive_queue_bytes: u64 = 0,
drops: u64 = 0,
};
/// The tables nxdns reads, IPv4 then IPv6. A dual-stack socket bound to `::`
/// appears only in the second.
pub const tables = [_][]const u8{ "/proc/net/udp", "/proc/net/udp6" };
/// A table larger than this is not a household host's, and a scrape stops
/// rather than allocate without bound.
const max_table_bytes = 4 * 1024 * 1024;
var read_failures: std.atomic.Value(u64) = .init(0);
/// Scrapes whose reading failed, process-wide like the log sink's counters: a
/// failed read leaves the families out of that scrape, and this is what says
/// why they are missing. Never reset.
pub fn readFailures() u64 {
return read_failures.load(.monotonic);
}
pub const ReadError = error{
/// A socket in `inodes` was in neither table.
SocketNotFound,
/// A row did not have the shape the kernel has printed since 2.6.
Malformed,
TableTooLarge,
OutOfMemory,
Unreadable,
};
/// The sum over `sockets`, or null with the failure counted. Every socket must
/// be found, so a partial sum is never reported as the whole.
pub fn readSockets(io: std.Io, arena: Allocator, sockets: []const std.Io.net.Socket) ?Reading {
return readOrError(io, arena, sockets) catch |err| {
_ = read_failures.fetchAdd(1, .monotonic);
log.debug("udp socket counters unavailable: {t}", .{err});
return null;
};
}
fn readOrError(io: std.Io, arena: Allocator, sockets: []const std.Io.net.Socket) (ReadError || std.Io.File.StatError)!Reading {
const inodes = try arena.alloc(u64, sockets.len);
for (sockets, inodes) |socket, *inode| inode.* = try socketInode(io, socket);
return read(io, arena, inodes);
}
/// The sum over the sockets in `inodes`. Every one must be found, so a partial
/// sum is never reported as the whole.
pub fn read(io: std.Io, arena: Allocator, inodes: []const u64) ReadError!Reading {
var reading: Reading = .{};
var found: usize = 0;
for (tables) |path| {
const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
// A kernel without IPv6 has no `udp6` table, and then no socket
// can be in it.
error.FileNotFound => continue,
else => return error.Unreadable,
};
defer file.close(io);
// Streaming: procfs reports a size of zero, which a positional read
// takes for an empty file.
var reader = file.readerStreaming(io, &.{});
const table = reader.interface.allocRemaining(arena, .limited(max_table_bytes)) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.StreamTooLong => return error.TableTooLarge,
error.ReadFailed => return error.Unreadable,
};
found += try accumulate(table, inodes, &reading);
}
if (found != inodes.len) return error.SocketNotFound;
return reading;
}
/// Adds the rows of one table whose inode is in `inodes` to `into`, and
/// returns how many matched.
pub fn accumulate(table: []const u8, inodes: []const u64, into: *Reading) error{Malformed}!usize {
var lines = std.mem.tokenizeScalar(u8, table, '\n');
// The header names the columns; the rows follow it.
_ = lines.next() orelse return 0;
var found: usize = 0;
while (lines.next()) |line| {
const row = try parseRow(line);
if (std.mem.indexOfScalar(u64, inodes, row.inode) == null) continue;
into.receive_queue_bytes += row.rx_queue;
into.drops += row.drops;
found += 1;
}
return found;
}
const Row = struct { rx_queue: u64, inode: u64, drops: u64 };
/// `sl local rem st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode
/// ref pointer drops`, as `udp4_format_sock` and `__ip6_dgram_sock_seq_show`
/// print them.
fn parseRow(line: []const u8) error{Malformed}!Row {
var fields: [13][]const u8 = undefined;
var it = std.mem.tokenizeScalar(u8, line, ' ');
for (&fields) |*field| field.* = it.next() orelse return error.Malformed;
const queues = fields[4];
const colon = std.mem.indexOfScalar(u8, queues, ':') orelse return error.Malformed;
return .{
.rx_queue = std.fmt.parseInt(u64, queues[colon + 1 ..], 16) catch return error.Malformed,
.inode = std.fmt.parseInt(u64, fields[9], 10) catch return error.Malformed,
.drops = std.fmt.parseInt(u64, fields[12], 10) catch return error.Malformed,
};
}
/// The inode `/proc/net/udp` names this socket by.
pub fn socketInode(io: std.Io, socket: std.Io.net.Socket) std.Io.File.StatError!u64 {
const file: std.Io.File = .{ .handle = socket.handle, .flags = .{ .nonblocking = false } };
return @intCast((try file.stat(io)).inode);
}
const testing = std.testing;
const udp4_table =
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops \n" ++
" 123: 00000000:0035 00000000:0000 07 00000000:00000a00 00:00000000 00000000 997 0 41001 2 0000000000000000 3 \n" ++
" 456: 0100007F:0277 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 52 2 0000000000000000 9 \n";
const udp6_table =
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops\n" ++
" 789: 00000000000000000000000000000000:0035 00000000000000000000000000000000:0000 07 00000000:00000100 00:00000000 00000000 997 0 41002 2 0000000000000000 4\n";
test "only the named sockets are summed" {
var reading: Reading = .{};
try testing.expectEqual(@as(usize, 1), try accumulate(udp4_table, &.{ 41001, 41002 }, &reading));
try testing.expectEqual(@as(usize, 1), try accumulate(udp6_table, &.{ 41001, 41002 }, &reading));
try testing.expectEqual(@as(u64, 0xa00 + 0x100), reading.receive_queue_bytes);
try testing.expectEqual(@as(u64, 3 + 4), reading.drops);
}
test "a header with no rows matches nothing" {
var reading: Reading = .{};
const header_only = udp4_table[0 .. std.mem.indexOfScalar(u8, udp4_table, '\n').? + 1];
try testing.expectEqual(@as(usize, 0), try accumulate(header_only, &.{41001}, &reading));
try testing.expectEqual(@as(usize, 0), try accumulate("", &.{41001}, &reading));
}
test "a short or garbled row is malformed, not skipped" {
var reading: Reading = .{};
try testing.expectError(error.Malformed, accumulate("header\n 1: 0:0 0:0 07\n", &.{1}, &reading));
try testing.expectError(
error.Malformed,
accumulate("header\n 1: a b 07 00000000 0 0 0 0 7 2 0 0\n", &.{7}, &reading),
);
}
test "a bound socket is found in the live tables with its own counters" {
const io = testing.io;
const any: std.Io.net.IpAddress = .{ .ip4 = .loopback(0) };
const socket = try any.bind(io, .{ .mode = .dgram });
defer socket.close(io);
const inode = try socketInode(io, socket);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const reading = read(io, arena.allocator(), &.{inode}) catch |err| switch (err) {
// A sandbox without procfs cannot run this; the parser tests above
// still cover the format.
error.Unreadable => return error.SkipZigTest,
else => return err,
};
try testing.expectEqual(@as(u64, 0), reading.receive_queue_bytes);
try testing.expectEqual(@as(u64, 0), reading.drops);
const before = readFailures();
try testing.expect(readSockets(io, arena.allocator(), &.{socket}) != null);
try testing.expectEqual(before, readFailures());
}
test "a socket the tables do not list fails the whole reading" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Inode 0 is never a socket's.
if (read(testing.io, arena.allocator(), &.{0})) |_| {
return error.TestUnexpectedResult;
} else |err| switch (err) {
error.SocketNotFound => {},
error.Unreadable => return error.SkipZigTest,
else => return err,
}
}
+89
View File
@@ -0,0 +1,89 @@
//! A futex word that counts releases, so a task can wait for "something was
//! released since I looked" with a deadline on its own thread.
//!
//! `std.Io.Condition` and `std.Io.Semaphore` have no timed wait in 0.16, and
//! racing one against a timer costs two extra tasks per waiter. A waiter reads
//! `observe` *before* it checks for a free resource; a release bumps the word
//! *after* freeing one. A release that lands between the check and the wait
//! therefore changes the word, and the wait returns at once instead of
//! sleeping through it.
//!
//! Like any futex, a wait can return spuriously, so callers loop on their own
//! condition. A waiter that leaves without taking what it was woken for — a
//! timeout or a cancel that consumed a `wakeOne` — must call `wakeOne` again
//! if something is still free, or a peer sleeps through it.
const std = @import("std");
pub const WakeWord = struct {
releases: std.atomic.Value(u32) = .init(0),
pub fn observe(self: *const WakeWord) u32 {
return self.releases.load(.acquire);
}
/// Returns on a wake, a spurious wake, or `deadline`; `null` waits for a
/// wake only. Returns at once when the word has moved past `seen`.
pub fn wait(
self: *const WakeWord,
io: std.Io,
seen: u32,
deadline: ?std.Io.Clock.Timestamp,
) std.Io.Cancelable!void {
const timeout: std.Io.Timeout = if (deadline) |at| .{ .deadline = at } else .none;
return io.futexWaitTimeout(u32, &self.releases.raw, seen, timeout);
}
pub fn wakeOne(self: *WakeWord, io: std.Io) void {
_ = self.releases.fetchAdd(1, .release);
io.futexWake(u32, &self.releases.raw, 1);
}
pub fn wakeAll(self: *WakeWord, io: std.Io) void {
_ = self.releases.fetchAdd(1, .release);
io.futexWake(u32, &self.releases.raw, std.math.maxInt(u32));
}
};
const testing = std.testing;
test "a release between observe and wait is not slept through" {
var word: WakeWord = .{};
const seen = word.observe();
word.wakeOne(testing.io);
// No deadline: a lost wake would hang this test.
try word.wait(testing.io, seen, null);
}
test "a wait with nothing released returns at its deadline" {
const io = testing.io;
var word: WakeWord = .{};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try word.wait(io, word.observe(), deadline);
try testing.expect(std.Io.Clock.Timestamp.now(io, .awake).compare(.gte, deadline));
}
fn waitForever(word: *const WakeWord, io: std.Io, seen: u32) std.Io.Cancelable!void {
return word.wait(io, seen, null);
}
test "wakeAll releases every waiter" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var word: WakeWord = .{};
const seen = word.observe();
var first = io.concurrent(waitForever, .{ &word, io, seen }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
var second = io.concurrent(waitForever, .{ &word, io, seen }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
_ = first.cancel(io) catch {};
return error.SkipZigTest;
},
};
word.wakeAll(io);
try first.await(io);
try second.await(io);
}
+9 -6
View File
@@ -91,7 +91,6 @@ pub const Snapshot = struct {
idle_timeouts: u64, idle_timeouts: u64,
connection_errors: u64, connection_errors: u64,
bad_requests: u64, bad_requests: u64,
dropped_no_permit: u64,
}; };
pub const DohServer = struct { pub const DohServer = struct {
@@ -117,6 +116,7 @@ pub const DohServer = struct {
pub const Owner = DohServer; pub const Owner = DohServer;
pub const ConnPayload = Payload; pub const ConnPayload = Payload;
pub const serveConn = serveOne; pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = recv_buffer_len; pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len; pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.doh_server); pub const log = std.log.scoped(.doh_server);
@@ -165,7 +165,6 @@ pub const DohServer = struct {
.idle_timeouts = core.idle_timeouts.load(.monotonic), .idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic), .connection_errors = core.connection_errors.load(.monotonic),
.bad_requests = self.stats.bad_requests.load(.monotonic), .bad_requests = self.stats.bad_requests.load(.monotonic),
.dropped_no_permit = core.dropped_no_permit.load(.monotonic),
}; };
} }
@@ -174,6 +173,10 @@ pub const DohServer = struct {
self.core.serve(io); self.core.serve(io);
} }
fn wakeBudgetWaiters(self: *DohServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *DohServer, io: std.Io) void { pub fn deinit(self: *DohServer, io: std.Io) void {
self.core.deinit(io); self.core.deinit(io);
self.* = undefined; self.* = undefined;
@@ -389,10 +392,10 @@ pub const DohServer = struct {
request: *http.Server.Request, request: *http.Server.Request,
query: []const u8, query: []const u8,
) error{ WriteFailed, HttpExpectationFailed }!Next { ) error{ WriteFailed, HttpExpectationFailed }!Next {
const work = self.budget.tryAcquire(io) orelse { // Bounded like a read: a query still waiting when the idle budget runs
listener.bump(&self.core.stats.dropped_no_permit); // out closes the connection, with the timeout counted by the budget.
return .close; const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, self.options.idle_timeout);
}; const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return .close;
defer self.budget.release(io, work); defer self.budget.release(io, work);
const outcome = self.handler.handle( const outcome = self.handler.handle(
+10 -7
View File
@@ -60,7 +60,6 @@ pub const StatsSnapshot = struct {
tls_handshake_failures: u64, tls_handshake_failures: u64,
idle_timeouts: u64, idle_timeouts: u64,
connection_errors: u64, connection_errors: u64,
dropped_no_permit: u64,
}; };
pub const DotServer = struct { pub const DotServer = struct {
@@ -83,6 +82,7 @@ pub const DotServer = struct {
pub const Owner = DotServer; pub const Owner = DotServer;
pub const ConnPayload = Payload; pub const ConnPayload = Payload;
pub const serveConn = serveOne; pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = stream_buffer_len; pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len; pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.dot_server); pub const log = std.log.scoped(.dot_server);
@@ -121,6 +121,10 @@ pub const DotServer = struct {
self.core.serve(io); self.core.serve(io);
} }
fn wakeBudgetWaiters(self: *DotServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *DotServer, io: std.Io) void { pub fn deinit(self: *DotServer, io: std.Io) void {
self.core.deinit(io); self.core.deinit(io);
self.* = undefined; self.* = undefined;
@@ -138,7 +142,6 @@ pub const DotServer = struct {
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic), .idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic), .connection_errors = core.connection_errors.load(.monotonic),
.dropped_no_permit = core.dropped_no_permit.load(.monotonic),
}; };
} }
@@ -219,11 +222,11 @@ pub const DotServer = struct {
}, },
} }
// There is no framing for "no answer", so the connection ends. // Bounded like a read: a query still waiting when the idle budget
const work = self.budget.tryAcquire(io) orelse { // runs out ends the connection, with the timeout counted by the
listener.bump(&stats.dropped_no_permit); // budget. There is no framing for "no answer".
return; const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, budget);
}; const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return;
defer self.budget.release(io, work); defer self.budget.release(io, work);
const outcome = self.handler.handle( const outcome = self.handler.handle(
+5 -4
View File
@@ -91,10 +91,6 @@ pub const CoreStats = struct {
accept_errors: std.atomic.Value(u64) = .init(0), accept_errors: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0), connection_errors: std.atomic.Value(u64) = .init(0),
idle_timeouts: std.atomic.Value(u64) = .init(0), idle_timeouts: std.atomic.Value(u64) = .init(0),
/// A DNS listener read a whole query and found every permit of the shared
/// query budget taken, so the connection ended unanswered. The web
/// listener answers no DNS and keeps this at zero.
dropped_no_permit: std.atomic.Value(u64) = .init(0),
/// The most slots that have been taken at once (specs/memory-budget.md /// The most slots that have been taken at once (specs/memory-budget.md
/// item 0). A gauge, so no listener's `Snapshot` carries it: those export /// item 0). A gauge, so no listener's `Snapshot` carries it: those export
/// every field as a counter. Written only under `Core.mutex`. /// every field as a counter. Written only under `Core.mutex`.
@@ -268,6 +264,10 @@ pub fn decideClaim(conns: anytype, shutdown_begun: bool) Claim {
/// - `initPayload` / `deinitPayload` — for a payload that owns memory (the web /// - `initPayload` / `deinitPayload` — for a payload that owns memory (the web
/// listener's per-connection arena). `initPayload` runs inside `listen`, /// listener's per-connection arena). `initPayload` runs inside `listen`,
/// `deinitPayload` inside `deinit` after every connection task has joined. /// `deinitPayload` inside `deinit` after every connection task has joined.
/// - `closing: fn (*Owner, std.Io) void` — runs in `deinit` once `.closing` is
/// published, for a connection blocked on something other than its socket,
/// which the shutdown of each stream cannot reach. The DNS listeners wake
/// their query-budget waiters here.
pub fn Core(comptime Cfg: type) type { pub fn Core(comptime Cfg: type) type {
return struct { return struct {
const Self = @This(); const Self = @This();
@@ -336,6 +336,7 @@ pub fn Core(comptime Cfg: type) type {
pub fn deinit(self: *Self, io: std.Io) void { pub fn deinit(self: *Self, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
if (@hasDecl(Cfg, "closing")) Cfg.closing(self.owner(), io);
// Shutting the listening socket down is the documented way to // Shutting the listening socket down is the documented way to
// unblock a pending `accept`: it fails with // unblock a pending `accept`: it fails with
+311 -18
View File
@@ -7,21 +7,29 @@
//! it when its reply is written, so a keep-alive connection between requests //! it when its reply is written, so a keep-alive connection between requests
//! holds none. //! holds none.
//! //!
//! Nothing waits for a permit. A listener that finds the pool empty drops the //! A query that finds every permit taken waits for one, on its own thread,
//! query and counts it, because a wait would only turn a visible drop into //! rather than being dropped or answered SERVFAIL: a stub resolver treats
//! invisible latency, and every permit is also the tasks its query may start, //! either as a failed lookup, while a short wait is invisible to it. Each held
//! which `app.concurrent_limit` adds up from `permits`. //! permit is bounded by the upstream deadline and the stream write budget, so a
//! wait always ends. A UDP listener waits with its datagram staged and stops
//! receiving meanwhile, so the backlog sits in the socket's kernel buffer,
//! where `/proc/net/udp` counts what overflows. A stream query waits at most
//! its connection's idle budget. Every wait is counted.
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const handler = @import("handler.zig"); const handler = @import("handler.zig");
const listener = @import("listener.zig");
const transport = @import("../upstream/transport.zig"); const transport = @import("../upstream/transport.zig");
const WakeWord = @import("../platform/wake_word.zig").WakeWord;
/// The most DNS queries answered at once, across every listener. The Pi's /// The most DNS queries answered at once, across every listener. The Pi's
/// highest reading of `nxdns_dns_in_flight_peak` was 27 over 37 hours of /// highest reading of `nxdns_dns_in_flight_peak` was 27 over a day of
/// household traffic, and a query beyond this is dropped and counted. /// household traffic; a burst of 64 simultaneous uncached queries was answered
pub const permits = 32; /// whole on two upstreams with the slowest at 0.2 s. Each answered query can
/// hold up to three threads while its exchange runs.
pub const permits = 64;
/// The EDNS ceiling the UDP listener advertises. A larger datagram arrives /// The EDNS ceiling the UDP listener advertises. A larger datagram arrives
/// truncated and is dropped rather than guessed at. /// truncated and is dropped rather than guessed at.
@@ -48,6 +56,26 @@ pub const Work = struct {
scratch: handler.Scratch, scratch: handler.Scratch,
}; };
/// Why `acquire` returned without a permit.
pub const AcquireError = error{
/// The waiting task is being torn down.
Canceled,
/// The caller's deadline passed. Counted in `wait_timeouts`.
Timeout,
/// The caller's listener left `.serving`.
Stopped,
};
pub const Snapshot = struct {
/// Queries that found every permit taken and had to wait.
waits: u64,
wait_seconds: f64,
/// Waits that ran out of their deadline. Only stream queries have one.
wait_timeouts: u64,
/// Queries waiting right now.
waiting: u64,
};
pub const QueryBudget = struct { pub const QueryBudget = struct {
gpa: Allocator, gpa: Allocator,
work: []Work, work: []Work,
@@ -56,6 +84,11 @@ pub const QueryBudget = struct {
/// `free[0..free_len]`. /// `free[0..free_len]`.
free: []u16, free: []u16,
free_len: usize, free_len: usize,
released: WakeWord,
waits: std.atomic.Value(u64),
wait_ns: std.atomic.Value(u64),
wait_timeouts: std.atomic.Value(u64),
waiting: std.atomic.Value(u64),
pub fn init(gpa: Allocator, count: u16) Allocator.Error!QueryBudget { pub fn init(gpa: Allocator, count: u16) Allocator.Error!QueryBudget {
std.debug.assert(count > 0); std.debug.assert(count > 0);
@@ -63,21 +96,52 @@ pub const QueryBudget = struct {
errdefer gpa.free(work); errdefer gpa.free(work);
const free = try gpa.alloc(u16, count); const free = try gpa.alloc(u16, count);
for (free, 0..) |*slot, index| slot.* = @intCast(index); for (free, 0..) |*slot, index| slot.* = @intCast(index);
return .{ .gpa = gpa, .work = work, .mutex = .init, .free = free, .free_len = count }; return .{
.gpa = gpa,
.work = work,
.mutex = .init,
.free = free,
.free_len = count,
.released = .{},
.waits = .init(0),
.wait_ns = .init(0),
.wait_timeouts = .init(0),
.waiting = .init(0),
};
} }
/// Every listener has stopped, so every permit is back. /// Every listener has stopped, so every permit is back.
pub fn deinit(self: *QueryBudget) void { pub fn deinit(self: *QueryBudget) void {
std.debug.assert(self.free_len == self.work.len); std.debug.assert(self.free_len == self.work.len);
std.debug.assert(self.waiting.load(.monotonic) == 0);
self.gpa.free(self.free); self.gpa.free(self.free);
self.gpa.free(self.work); self.gpa.free(self.work);
self.* = undefined; self.* = undefined;
} }
/// A permit, waiting for one while every permit is taken, until `deadline`
/// (`null`: no deadline) or until `run_state` leaves `.serving`. A listener
/// that publishes `.closing` calls `wakeWaiters` so a waiter sees it.
pub fn acquire(
self: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
if (self.tryAcquire(io)) |work| return work;
_ = self.waits.fetchAdd(1, .monotonic);
_ = self.waiting.fetchAdd(1, .monotonic);
const started = std.Io.Clock.awake.now(io);
const outcome = self.waitForPermit(io, deadline, run_state);
const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).toNanoseconds();
_ = self.wait_ns.fetchAdd(@intCast(@max(elapsed, 0)), .monotonic);
_ = self.waiting.fetchSub(1, .monotonic);
return outcome;
}
/// A permit, or null when every one is taken. Waits for the bookkeeping /// A permit, or null when every one is taken. Waits for the bookkeeping
/// mutex, which no holder keeps across any Io, but never for a permit: /// mutex, which no holder keeps across any Io, but never for a permit.
/// treating a contended lock as an empty pool would drop queries the
/// budget has room for.
pub fn tryAcquire(self: *QueryBudget, io: std.Io) ?*Work { pub fn tryAcquire(self: *QueryBudget, io: std.Io) ?*Work {
self.mutex.lockUncancelable(io); self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io); defer self.mutex.unlock(io);
@@ -90,11 +154,20 @@ pub const QueryBudget = struct {
const index = (@intFromPtr(work) - @intFromPtr(self.work.ptr)) / @sizeOf(Work); const index = (@intFromPtr(work) - @intFromPtr(self.work.ptr)) / @sizeOf(Work);
std.debug.assert(&self.work[index] == work); std.debug.assert(&self.work[index] == work);
self.mutex.lockUncancelable(io); {
defer self.mutex.unlock(io); self.mutex.lockUncancelable(io);
std.debug.assert(self.free_len < self.work.len); defer self.mutex.unlock(io);
self.free[self.free_len] = @intCast(index); std.debug.assert(self.free_len < self.work.len);
self.free_len += 1; self.free[self.free_len] = @intCast(index);
self.free_len += 1;
}
self.released.wakeOne(io);
}
/// Wakes every waiter so each re-reads its listener's `run_state`. A
/// listener calls this after publishing `.closing`.
pub fn wakeWaiters(self: *QueryBudget, io: std.Io) void {
self.released.wakeAll(io);
} }
/// Permits held right now. /// Permits held right now.
@@ -103,10 +176,61 @@ pub const QueryBudget = struct {
defer self.mutex.unlock(io); defer self.mutex.unlock(io);
return self.work.len - self.free_len; return self.work.len - self.free_len;
} }
/// The counters, read one at a time; a scrape mid-wait can see a wait
/// counted before its seconds are.
pub fn snapshot(self: *const QueryBudget) Snapshot {
const ns: f64 = @floatFromInt(self.wait_ns.load(.monotonic));
return .{
.waits = self.waits.load(.monotonic),
.wait_seconds = ns / std.time.ns_per_s,
.wait_timeouts = self.wait_timeouts.load(.monotonic),
.waiting = self.waiting.load(.monotonic),
};
}
fn waitForPermit(
self: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
while (true) {
// Read before the check, so a release or a `wakeWaiters` that
// lands after the check moves the word and the wait returns at
// once.
const seen = self.released.observe();
if (run_state.load(.acquire) != .serving) return self.leave(io, error.Stopped);
// Before the take, so a permit freed just as the deadline passes
// does not start a query its caller has already given up on.
if (deadline) |at| {
if (std.Io.Clock.Timestamp.now(io, at.clock).compare(.gte, at)) {
_ = self.wait_timeouts.fetchAdd(1, .monotonic);
return self.leave(io, error.Timeout);
}
}
if (self.tryAcquire(io)) |work| return work;
self.released.wait(io, seen, deadline) catch return self.leave(io, error.Canceled);
}
}
/// This waiter may have consumed the one wake a release sent, so a permit
/// still free is handed on rather than left for nobody.
fn leave(self: *QueryBudget, io: std.Io, err: AcquireError) AcquireError {
const free_len = free: {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
break :free self.free_len;
};
if (free_len != 0) self.released.wakeOne(io);
return err;
}
}; };
const testing = std.testing; const testing = std.testing;
var test_serving: std.atomic.Value(listener.State) = .init(.serving);
test "a budget hands out every permit once, then refuses" { test "a budget hands out every permit once, then refuses" {
var budget: QueryBudget = try .init(testing.allocator, 3); var budget: QueryBudget = try .init(testing.allocator, 3);
defer budget.deinit(); defer budget.deinit();
@@ -135,6 +259,175 @@ test "a released permit is handed out again" {
budget.release(io, second); budget.release(io, second);
} }
test "a free permit is taken without counting a wait" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const work = try budget.acquire(io, null, &test_serving);
budget.release(io, work);
try testing.expectEqual(@as(u64, 0), budget.snapshot().waits);
}
test "a wait past its deadline times out, holds nothing and is counted" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const held = budget.tryAcquire(io).?;
defer budget.release(io, held);
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try testing.expectError(error.Timeout, budget.acquire(io, deadline, &test_serving));
const snap = budget.snapshot();
try testing.expectEqual(@as(u64, 1), snap.waits);
try testing.expectEqual(@as(u64, 1), snap.wait_timeouts);
try testing.expectEqual(@as(u64, 0), snap.waiting);
try testing.expect(snap.wait_seconds >= 0.02);
try testing.expectEqual(@as(usize, 1), budget.taken(io));
}
test "a listener that is not serving gets no permit, even a free one" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const held = budget.tryAcquire(io).?;
var closing: std.atomic.Value(listener.State) = .init(.closing);
try testing.expectError(error.Stopped, budget.acquire(io, null, &closing));
budget.release(io, held);
try testing.expectEqual(@as(usize, 0), budget.taken(io));
}
fn acquireAndHold(
budget: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
return budget.acquire(io, deadline, run_state);
}
fn waitUntilWaiting(budget: *QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (budget.waiting.load(.monotonic) != count) try tick.sleep(io);
}
test "a release wakes a waiter and hands it the permit" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
var waiter = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
budget.release(io, held);
return error.SkipZigTest;
},
};
try waitUntilWaiting(&budget, io, 1);
budget.release(io, held);
const got = try waiter.await(io);
try testing.expectEqual(held, got);
budget.release(io, got);
try testing.expectEqual(@as(u64, 1), budget.snapshot().waits);
}
test "a canceled waiter hands its wake on to a peer" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
var canceled = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
budget.release(io, held);
return error.SkipZigTest;
},
};
var peer = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
_ = canceled.cancel(io) catch {};
budget.release(io, held);
return error.SkipZigTest;
},
};
try waitUntilWaiting(&budget, io, 2);
// One release, one wake. Whichever task the wake reaches, the peer must
// end up with the permit: the canceled task either never saw the wake or
// hands it on as it leaves. A lost wake hangs this test.
budget.release(io, held);
if (canceled.cancel(io)) |work| {
// The cancel lost the race to the wake; give the permit to the peer.
budget.release(io, work);
} else |err| try testing.expectEqual(error.Canceled, err);
const got = try peer.await(io);
budget.release(io, got);
try testing.expectEqual(@as(usize, 0), budget.taken(io));
}
test "wakeWaiters ends a wait whose listener is closing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
defer budget.release(io, held);
var state: std.atomic.Value(listener.State) = .init(.serving);
var waiter = io.concurrent(acquireAndHold, .{ &budget, io, null, &state }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
try waitUntilWaiting(&budget, io, 1);
state.store(.closing, .release);
budget.wakeWaiters(io);
try testing.expectError(error.Stopped, waiter.await(io));
}
test "many waiters on few permits all get one, and every permit comes back" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 2);
defer budget.deinit();
var group: std.Io.Group = .init;
var taken_total: std.atomic.Value(u32) = .init(0);
const rounds = 64;
for (0..rounds) |_| {
group.concurrent(io, takeBriefly, .{ &budget, io, &taken_total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
try group.await(io);
return error.SkipZigTest;
},
};
}
try group.await(io);
try testing.expectEqual(@as(u32, rounds), taken_total.load(.monotonic));
try testing.expectEqual(@as(usize, 0), budget.taken(io));
try testing.expectEqual(@as(u64, 0), budget.snapshot().waiting);
}
fn takeBriefly(budget: *QueryBudget, io: std.Io, taken_total: *std.atomic.Value(u32)) void {
const work = budget.acquire(io, null, &test_serving) catch return;
defer budget.release(io, work);
_ = taken_total.fetchAdd(1, .monotonic);
const hold: std.Io.Clock.Duration = .{ .raw = .fromMicroseconds(200), .clock = .awake };
hold.sleep(io) catch {};
}
test "init survives every allocation failure" { test "init survives every allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, struct { try testing.checkAllAllocationFailures(testing.allocator, struct {
fn run(gpa: Allocator) !void { fn run(gpa: Allocator) !void {
@@ -145,7 +438,7 @@ test "init survives every allocation failure" {
} }
test "the default budget stays small" { test "the default budget stays small" {
// 4096 + 65535 + scratch per permit: about 2.4 MiB for the whole process, // 4096 + 65535 + scratch per permit: under 5 MiB for the whole process,
// where each UDP and TCP listener used to hold 4.8 and 8.7 MiB of its own. // where each UDP and TCP listener used to hold 4.8 and 8.7 MiB of its own.
try testing.expect(@sizeOf(Work) * permits < 3 * 1024 * 1024); try testing.expect(@sizeOf(Work) * permits < 5 * 1024 * 1024);
} }
+1 -1
View File
@@ -179,7 +179,7 @@ const EntryStorage = struct {
.priority = priority, .priority = priority,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = self.slots.len }, .admission = .{ .permits = .init(self.slots.len) },
.reuse_recoveries = &self.recoveries, .reuse_recoveries = &self.recoveries,
}; };
} }
+10 -7
View File
@@ -50,7 +50,6 @@ pub const Snapshot = struct {
accept_errors: u64, accept_errors: u64,
connection_errors: u64, connection_errors: u64,
idle_timeouts: u64, idle_timeouts: u64,
dropped_no_permit: u64,
}; };
pub const TcpServer = struct { pub const TcpServer = struct {
@@ -70,6 +69,7 @@ pub const TcpServer = struct {
pub const Owner = TcpServer; pub const Owner = TcpServer;
pub const ConnPayload = Payload; pub const ConnPayload = Payload;
pub const serveConn = serveOne; pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = stream_buffer_len; pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len; pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.tcp_server); pub const log = std.log.scoped(.tcp_server);
@@ -116,7 +116,6 @@ pub const TcpServer = struct {
.accept_errors = stats.accept_errors.load(.monotonic), .accept_errors = stats.accept_errors.load(.monotonic),
.connection_errors = stats.connection_errors.load(.monotonic), .connection_errors = stats.connection_errors.load(.monotonic),
.idle_timeouts = stats.idle_timeouts.load(.monotonic), .idle_timeouts = stats.idle_timeouts.load(.monotonic),
.dropped_no_permit = stats.dropped_no_permit.load(.monotonic),
}; };
} }
@@ -125,6 +124,10 @@ pub const TcpServer = struct {
self.core.serve(io); self.core.serve(io);
} }
fn wakeBudgetWaiters(self: *TcpServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *TcpServer, io: std.Io) void { pub fn deinit(self: *TcpServer, io: std.Io) void {
self.core.deinit(io); self.core.deinit(io);
self.* = undefined; self.* = undefined;
@@ -182,11 +185,11 @@ pub const TcpServer = struct {
}, },
} }
// There is no framing for "no answer", so the connection ends. // Bounded like a read: a query still waiting when the idle budget
const work = self.budget.tryAcquire(io) orelse { // runs out ends the connection, with the timeout counted by the
listener.bump(&stats.dropped_no_permit); // budget. There is no framing for "no answer".
return; const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, budget);
}; const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return;
defer self.budget.release(io, work); defer self.budget.release(io, work);
const outcome = self.handler.handle( const outcome = self.handler.handle(
+137 -12
View File
@@ -527,9 +527,21 @@ fn askOn(reader: *std.Io.Reader, writer: *std.Io.Writer) ![]const u8 {
return reader.take(len); return reader.take(len);
} }
fn waitForWaiters(permits: *query_budget.QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (permits.snapshot().waiting != count) try tick.sleep(io);
}
fn sendQuery(writer: *std.Io.Writer) !void {
try writer.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
try writer.writeAll(query_bytes);
try writer.flush();
}
/// One answered query, then the connection idles with every permit back in the /// One answered query, then the connection idles with every permit back in the
/// budget; a second query that finds none taken ends the connection unanswered. /// budget; a second query that finds every permit taken waits, and is answered
fn keepAliveThenStarved(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget) anyerror!void { /// once one is released.
fn keepAliveThenWait(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget) anyerror!void {
var stream = try address.connect(io, .{ .mode = .stream }); var stream = try address.connect(io, .{ .mode = .stream });
defer stream.close(io); defer stream.close(io);
@@ -546,17 +558,19 @@ fn keepAliveThenStarved(io: std.Io, address: net.IpAddress, permits: *query_budg
while (permits.taken(io) != 0) try tick.sleep(io); while (permits.taken(io) != 0) try tick.sleep(io);
const held = permits.tryAcquire(io).?; const held = permits.tryAcquire(io).?;
defer permits.release(io, held); var released = false;
defer if (!released) permits.release(io, held);
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len))); try sendQuery(&writer.interface);
try writer.interface.writeAll(query_bytes); try waitForWaiters(permits, io, 1);
try writer.interface.flush(); permits.release(io, held);
released = true;
var sink: [64]u8 = undefined; const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
if (try reader.interface.readSliceShort(&sink) != 0) return error.TestUnexpectedBytes; try expectAnswersQuery(try reader.interface.take(len));
} }
test "a keep-alive connection holds no permit between queries, and a query without one ends it" { test "a keep-alive connection holds no permit between queries, and a query without one waits for it" {
if (!build_options.integration) return error.SkipZigTest; if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator; const gpa = testing.allocator;
@@ -583,11 +597,122 @@ test "a keep-alive connection holds no permit between queries, and a query witho
}; };
} }
try bounded(io, keepAliveThenStarved, .{ io, server_address, &permits }); try bounded(io, keepAliveThenWait, .{ io, server_address, &permits });
try testing.expectEqual(@as(u64, 1), server.core.stats.dropped_no_permit.load(.monotonic)); try testing.expectEqual(@as(u64, 1), permits.snapshot().waits);
try testing.expectEqual(@as(u64, 0), permits.snapshot().wait_timeouts);
try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
}
/// A query that waits out the whole idle budget for a permit: the server ends
/// the connection without an answer.
fn waitOutlastsIdle(io: std.Io, address: net.IpAddress) anyerror!void {
var stream = try address.connect(io, .{ .mode = .stream });
defer stream.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
try sendQuery(&writer.interface);
var sink: [64]u8 = undefined;
if (try reader.interface.readSliceShort(&sink) != 0) return error.TestUnexpectedBytes;
}
test "a stream query whose permit wait outlasts the idle budget ends its connection, counted" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = short_idle,
});
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
try bounded(io, waitOutlastsIdle, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), permits.snapshot().wait_timeouts);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
/// Leaves a query waiting for a permit and returns once the server sees it
/// waiting.
fn queryThenWait(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget, stream: *net.Stream) anyerror!void {
stream.* = try address.connect(io, .{ .mode = .stream });
var write_buf: [1024]u8 = undefined;
var writer = stream.writer(io, &write_buf);
try sendQuery(&writer.interface);
try waitForWaiters(permits, io, 1);
}
test "closing a tcp listener ends a connection that waits for a permit" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
// An idle budget far past `bounded`'s, so only the close can end the wait.
const long_idle: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = long_idle,
});
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
var stream: net.Stream = undefined;
try bounded(io, queryThenWait, .{ io, server_address, &permits, &stream });
defer stream.close(io);
try bounded(io, closeAndJoin, .{ &server, io, &group });
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
fn closeAndJoin(server: *tcp_server.TcpServer, io: std.Io, group: *std.Io.Group) anyerror!void {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
} }
/// Holds the only slot with an answered connection, then opens a second one, /// Holds the only slot with an answered connection, then opens a second one,
+25 -19
View File
@@ -1,9 +1,10 @@
//! The UDP/53 listener. //! The UDP/53 listener.
//! //!
//! One receive loop hands each datagram to a task that holds a permit from the //! One receive loop hands each datagram to a task that holds a permit from the
//! shared query budget. A datagram that finds no permit is dropped and counted, //! shared query budget. While every permit is taken the loop waits for one with
//! never queued: an unbounded queue only moves the failure from "visible drop" //! the datagram it holds and receives nothing more, so the backlog stays in the
//! to "invisible latency", which is the failure mode PLAN §1 names. //! socket's kernel buffer: bounded by `SO_RCVBUF`, and an overflow is counted
//! by the kernel and exported as `nxdns_udp_server_kernel_drops_total`.
//! //!
//! The permit carries the query, the reply and the handler's scratch, so //! The permit carries the query, the reply and the handler's scratch, so
//! answering a datagram allocates nothing. //! answering a datagram allocates nothing.
@@ -12,6 +13,7 @@ const std = @import("std");
const address = @import("../platform/address.zig"); const address = @import("../platform/address.zig");
const concurrency = @import("../platform/concurrency.zig"); const concurrency = @import("../platform/concurrency.zig");
const handler = @import("handler.zig"); const handler = @import("handler.zig");
const listener = @import("listener.zig");
const query_budget = @import("query_budget.zig"); const query_budget = @import("query_budget.zig");
const log = std.log.scoped(.udp_server); const log = std.log.scoped(.udp_server);
@@ -27,14 +29,14 @@ pub const Stats = struct {
received: std.atomic.Value(u64) = .init(0), received: std.atomic.Value(u64) = .init(0),
/// The datagram arrived with `flags.trunc`: the kernel discarded its tail. /// The datagram arrived with `flags.trunc`: the kernel discarded its tail.
dropped_oversize: std.atomic.Value(u64) = .init(0), dropped_oversize: std.atomic.Value(u64) = .init(0),
/// Every permit of the shared query budget was taken.
dropped_no_slot: std.atomic.Value(u64) = .init(0),
/// The handler answered `.drop` — an upstream exchange canceled by /// The handler answered `.drop` — an upstream exchange canceled by
/// shutdown. The handler counts its own malformed drops; this counter is /// shutdown. The handler counts its own malformed drops; this counter is
/// what keeps the listener's "every dropped datagram is counted" rule true /// what keeps the listener's "every dropped datagram is counted" rule true
/// for the drops it cannot see. A datagram that got a permit but no task /// for the drops it cannot see. A datagram that got a permit but no task
/// is counted under `nxdns_concurrency_unavailable_total{site="udp_query"}`. /// is counted under `nxdns_concurrency_unavailable_total{site="udp_query"}`.
dropped_handler: std.atomic.Value(u64) = .init(0), dropped_handler: std.atomic.Value(u64) = .init(0),
/// The listener closed while the datagram waited for a permit.
dropped_at_shutdown: std.atomic.Value(u64) = .init(0),
receive_errors: std.atomic.Value(u64) = .init(0), receive_errors: std.atomic.Value(u64) = .init(0),
send_errors: std.atomic.Value(u64) = .init(0), send_errors: std.atomic.Value(u64) = .init(0),
}; };
@@ -46,23 +48,20 @@ pub const Stats = struct {
pub const Snapshot = struct { pub const Snapshot = struct {
received: u64, received: u64,
dropped_oversize: u64, dropped_oversize: u64,
dropped_no_slot: u64,
dropped_handler: u64, dropped_handler: u64,
dropped_at_shutdown: u64,
receive_errors: u64, receive_errors: u64,
send_errors: u64, send_errors: u64,
}; };
/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches the socket
/// after it is closed.
const State = enum(u32) { idle, serving, closing };
pub const UdpServer = struct { pub const UdpServer = struct {
socket: std.Io.net.Socket, socket: std.Io.net.Socket,
handler: *handler.Handler, handler: *handler.Handler,
budget: *query_budget.QueryBudget, budget: *query_budget.QueryBudget,
stats: Stats, stats: Stats,
state: std.atomic.Value(State), /// `serve` claims `.serving`, `deinit` publishes `.closing`, and the two
/// meet at `stopped` so no task touches the socket after it is closed.
state: std.atomic.Value(listener.State),
stopped: std.Io.Event, stopped: std.Io.Event,
pub const BindError = std.Io.net.IpAddress.BindError; pub const BindError = std.Io.net.IpAddress.BindError;
@@ -96,8 +95,8 @@ pub const UdpServer = struct {
return .{ return .{
.received = self.stats.received.load(.monotonic), .received = self.stats.received.load(.monotonic),
.dropped_oversize = self.stats.dropped_oversize.load(.monotonic), .dropped_oversize = self.stats.dropped_oversize.load(.monotonic),
.dropped_no_slot = self.stats.dropped_no_slot.load(.monotonic),
.dropped_handler = self.stats.dropped_handler.load(.monotonic), .dropped_handler = self.stats.dropped_handler.load(.monotonic),
.dropped_at_shutdown = self.stats.dropped_at_shutdown.load(.monotonic),
.receive_errors = self.stats.receive_errors.load(.monotonic), .receive_errors = self.stats.receive_errors.load(.monotonic),
.send_errors = self.stats.send_errors.load(.monotonic), .send_errors = self.stats.send_errors.load(.monotonic),
}; };
@@ -122,9 +121,13 @@ pub const UdpServer = struct {
} }
pub fn deinit(self: *UdpServer, io: std.Io) void { pub fn deinit(self: *UdpServer, io: std.Io) void {
// `serve` reads this between receives, so it stops on its own and the // `serve` reads this between receives and while it waits for a permit,
// socket is closed only once nothing can be reading from it. // so it stops on its own and the socket is closed only once nothing
if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io); // can be reading from it.
if (self.state.swap(.closing, .acq_rel) == .serving) {
self.budget.wakeWaiters(io);
self.stopped.waitUncancelable(io);
}
self.socket.close(io); self.socket.close(io);
self.* = undefined; self.* = undefined;
} }
@@ -161,9 +164,12 @@ pub const UdpServer = struct {
continue; continue;
} }
const work = self.budget.tryAcquire(io) orelse { const work = self.budget.acquire(io, null, &self.state) catch |err| switch (err) {
bump(&self.stats.dropped_no_slot); error.Canceled, error.Stopped => {
continue; bump(&self.stats.dropped_at_shutdown);
return;
},
error.Timeout => unreachable,
}; };
@memcpy(work.datagram[0..msg.data.len], msg.data); @memcpy(work.datagram[0..msg.data.len], msg.data);
+97 -5
View File
@@ -260,7 +260,12 @@ test "deinit ends a serve loop that is blocked on receive" {
}; };
} }
test "with every permit taken a udp query is dropped and counted" { fn waitForWaiters(permits: *query_budget.QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (permits.snapshot().waiting != count) try tick.sleep(io);
}
test "with every permit taken a udp query waits, and is answered once one frees" {
if (!build_options.integration) return error.SkipZigTest; if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator; const gpa = testing.allocator;
@@ -288,6 +293,52 @@ test "with every permit taken a udp query is dropped and counted" {
} }
// Another listener's query, as far as this one can tell. // Another listener's query, as far as this one can tell.
var held: ?*query_budget.Work = permits.tryAcquire(io).?;
defer if (held) |work| permits.release(io, work);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
var buf: [udp_server.max_datagram]u8 = undefined;
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
permits.release(io, held.?);
held = null;
const msg = try client.receiveTimeout(io, &buf, budget);
try expectAnswersQuery(msg.data);
const waits = permits.snapshot();
try testing.expectEqual(@as(u64, 1), waits.waits);
try testing.expectEqual(@as(u64, 0), waits.wait_timeouts);
try testing.expect(waits.wait_seconds > 0);
}
test "a udp listener waiting for a permit stops when it is closed" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?; const held = permits.tryAcquire(io).?;
defer permits.release(io, held); defer permits.release(io, held);
@@ -296,13 +347,55 @@ test "with every permit taken a udp query is dropped and counted" {
defer client.close(io); defer client.close(io);
try client.send(io, &server_address, query_bytes); try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
var buf: [udp_server.max_datagram]u8 = undefined; // The permit is still held, so only the close can end this wait; a deinit
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence)); // that missed the waiter would hang here.
try testing.expectEqual(@as(u64, 1), server.stats.dropped_no_slot.load(.monotonic)); server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
} }
test "a udp listener canceled while it waits for a permit counts the datagram it held" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
defer server.deinit(io);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
group.cancel(io);
try testing.expectEqual(@as(u64, 1), server.stats.dropped_at_shutdown.load(.monotonic));
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
}
test "a udp query with a permit but no task is dropped, counted, and returns its permit" { test "a udp query with a permit but no task is dropped, counted, and returns its permit" {
if (!build_options.integration) return error.SkipZigTest; if (!build_options.integration) return error.SkipZigTest;
@@ -342,5 +435,4 @@ test "a udp query with a permit but no task is dropped, counted, and returns its
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence)); try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.udp_query) - before); try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.udp_query) - before);
try testing.expectEqual(@as(usize, 0), permits.taken(io)); try testing.expectEqual(@as(usize, 0), permits.taken(io));
try testing.expectEqual(@as(u64, 0), server.stats.dropped_no_slot.load(.monotonic));
} }
+3
View File
@@ -15,6 +15,9 @@ comptime {
_ = @import("dns/dns.zig"); _ = @import("dns/dns.zig");
_ = @import("platform/address.zig"); _ = @import("platform/address.zig");
_ = @import("platform/concurrency.zig"); _ = @import("platform/concurrency.zig");
_ = @import("platform/huge_pages.zig");
_ = @import("platform/udp_socket_stats.zig");
_ = @import("platform/wake_word.zig");
_ = @import("platform/tls_client.zig"); _ = @import("platform/tls_client.zig");
_ = @import("platform/tls_client_integration_test.zig"); _ = @import("platform/tls_client_integration_test.zig");
_ = @import("platform/tls_server.zig"); _ = @import("platform/tls_server.zig");
+1 -1
View File
@@ -458,7 +458,7 @@ test "a session the upstream closed is recovered by one redial and counted, not
.priority = 10, .priority = 10,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = slots.len }, .admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &fixture.recoveries, .reuse_recoveries = &fixture.recoveries,
}}; }};
var pool: pool_mod.Pool = .init(&entries, .{ var pool: pool_mod.Pool = .init(&entries, .{
+1 -1
View File
@@ -577,7 +577,7 @@ const Upstreams = struct {
.priority = server.priority, .priority = server.priority,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = entry_slots.len }, .admission = .{ .permits = .init(entry_slots.len) },
.reuse_recoveries = &self.recovery_counters[self.used], .reuse_recoveries = &self.recovery_counters[self.used],
}; };
self.used += 1; self.used += 1;
+53 -115
View File
@@ -72,6 +72,7 @@ const events = @import("../storage/events.zig");
const health = @import("health.zig"); const health = @import("health.zig");
const safe_url = @import("../safe_url.zig"); const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig"); const transport = @import("transport.zig");
const WakeWord = @import("../platform/wake_word.zig").WakeWord;
const log = std.log.scoped(.upstream); const log = std.log.scoped(.upstream);
@@ -116,17 +117,9 @@ pub const slots_per_entry = 8;
/// Admission control for one entry: one permit per slot, a try-acquire, and an /// Admission control for one entry: one permit per slot, a try-acquire, and an
/// acquire bounded by the caller's deadline. `std.Io.Semaphore` offers neither /// acquire bounded by the caller's deadline. `std.Io.Semaphore` offers neither
/// in 0.16, and `std.Io.Condition` has no timed wait, so this mirrors the /// in 0.16, so the count is an atomic and a waiter sleeps on a `WakeWord`
/// standard semaphore's mutex/count/condition protocol with the two operations /// until a release or its deadline, on its own thread: a timed race would cost
/// the failover loop needs added. The stdlib is not patched and nothing spins. /// two more tasks per waiter.
///
/// The mutex owns the permit count and the decision to take one, and the caller
/// takes its permit only *after* the timed race has resolved. That ordering is
/// the whole design: racing a bare `Semaphore.wait` against an expiry can
/// decrement the count in the same instant the expiry wins, and the discarded
/// success never reaches the caller's release, so the permit is gone for good. A
/// wait discarded here loses a notification instead, which the exiting waiter
/// repairs by re-signalling.
/// ///
/// Bound worth knowing: `acquireUntil` blocks on one entry. A permit freeing on /// Bound worth knowing: `acquireUntil` blocks on one entry. A permit freeing on
/// some *other* entry does not wake this waiter, so the pool's no-head-of-line /// some *other* entry does not wake this waiter, so the pool's no-head-of-line
@@ -134,85 +127,56 @@ pub const slots_per_entry = 8;
/// blocked. Waking on any of several entries needs multi-wait machinery the pool /// blocked. Waking on any of several entries needs multi-wait machinery the pool
/// does not have. /// does not have.
pub const Admission = struct { pub const Admission = struct {
mutex: std.Io.Mutex = .init,
cond: std.Io.Condition = .init,
/// One per slot at build time; `Pool.init` asserts it. /// One per slot at build time; `Pool.init` asserts it.
permits: usize = 0, permits: std.atomic.Value(usize) = .init(0),
released: WakeWord = .{},
/// Which side of `acquireUntil` ended the wait. `.expired` holds no permit. /// Which side of `acquireUntil` ended the wait. `.expired` holds no permit.
pub const Outcome = enum { acquired, expired }; pub const Outcome = enum { acquired, expired };
/// Takes a permit if one is free this instant; never blocks. /// Takes a permit if one is free this instant; never blocks.
/// pub fn tryAcquire(self: *Admission) bool {
/// A contended mutex reads as "no permit now" rather than as a reason to var current = self.permits.load(.acquire);
/// wait. The failover loop calls this on every candidate in turn, so while (current != 0) {
/// blocking here for a descheduled permit holder would stall the whole current = self.permits.cmpxchgWeak(current, current - 1, .acquire, .acquire) orelse return true;
/// sweep and spend the caller's budget outside `acquireUntil`, which is the }
/// only step allowed to consume it. return false;
pub fn tryAcquire(self: *Admission, io: std.Io) bool {
if (!self.mutex.tryLock()) return false;
defer self.mutex.unlock(io);
if (self.permits == 0) return false;
self.permits -= 1;
return true;
} }
/// Returns a permit. Uncancelable, so a torn-down attempt still gives its
/// slot back.
pub fn release(self: *Admission, io: std.Io) void { pub fn release(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io); _ = self.permits.fetchAdd(1, .release);
defer self.mutex.unlock(io); self.released.wakeOne(io);
self.permits += 1;
self.cond.signal(io);
} }
/// Waits for a permit until `deadline`. Returns `.acquired` holding one, /// Waits for a permit until `deadline`. Returns `.acquired` holding one,
/// `.expired` holding none, or an error holding none — `error.Canceled` /// `.expired` holding none, or `error.Canceled` holding none when the whole
/// when the whole task is torn down, `error.SystemResources` when the race /// task is torn down.
/// cannot start.
pub fn acquireUntil( pub fn acquireUntil(
self: *Admission, self: *Admission,
io: std.Io, io: std.Io,
deadline: std.Io.Clock.Timestamp, deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError!Outcome { ) std.Io.Cancelable!Outcome {
while (true) { while (true) {
// Every take is non-blocking, including the retries. A take that // Read before the take, so a release that lands after a missed
// waits on the mutex spends time no deadline bounds: before the // take moves the word and the wait returns at once.
// first race it carries the call past the deadline outright, and const seen = self.released.observe();
// after a wake it can return `.acquired` late. A miss instead if (self.tryAcquire()) return .acquired;
// re-enters the race below, which is aimed at the same absolute if (std.Io.Clock.Timestamp.now(io, deadline.clock).compare(.gte, deadline)) {
// instant every time, so the cycle ends at the deadline with self.handOn(io);
// `.expired` — the correct answer, not a spin. return .expired;
if (self.tryAcquire(io)) return .acquired; }
var race: transport.RaceOutcome = .completed; self.released.wait(io, seen, deadline) catch {
_ = transport.raceUntilTagged(io, deadline, &race, waitForPermit, .{ self, io }) catch |err| { self.handOn(io);
// This task may be leaving with a notification meant for a peer: return error.Canceled;
// `Condition`'s cancel path consumes a pending signal before it
// reports the cancellation, and a wait whose success the race
// discarded consumed one outright. Hand it back before exiting.
self.resignal(io);
if (err == error.Timeout and race == .expired) return .expired;
return err;
}; };
// The wait saw a permit but did not take one, so another task may
// have taken it first. The loop re-checks rather than assuming, and
// does not spin: `waitForPermit` blocks whenever the count is zero.
} }
} }
/// Returns once the count is non-zero, without touching it. /// A waiter leaving without a permit may have consumed the one wake a
fn waitForPermit(self: *Admission, io: std.Io) transport.ExchangeError!void { /// release sent, so a permit still free is handed on rather than left for
self.mutex.lock(io) catch return error.Canceled; /// nobody.
defer self.mutex.unlock(io); fn handOn(self: *Admission, io: std.Io) void {
while (self.permits == 0) self.cond.wait(io, &self.mutex) catch return error.Canceled; if (self.permits.load(.acquire) != 0) self.released.wakeOne(io);
}
/// Only when a permit is actually there to hand over: an unconditional
/// signal would wake a peer that would find nothing.
fn resignal(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.permits > 0) self.cond.signal(io);
} }
}; };
@@ -236,9 +200,8 @@ pub const Entry = struct {
enabled: bool, enabled: bool,
health: health.State, health: health.State,
/// One permit per slot, so a task waits here rather than spinning over /// One permit per slot, so a task waits here rather than spinning over
/// `Slot.busy`. `.{ .permits = slots.len }` at build time; `Pool.init` /// `Slot.busy`. `.{ .permits = .init(slots.len) }` at build time;
/// asserts it. Its mutex and condition are plain values, so the sort in /// `Pool.init` asserts it. Plain values, so the sort in `init` may copy it.
/// `init` may copy it.
admission: Admission, admission: Admission,
in_flight: std.atomic.Value(u32) = .init(0), in_flight: std.atomic.Value(u32) = .init(0),
/// Test-only: the most exchanges this entry ever had in flight at once. /// Test-only: the most exchanges this entry ever had in flight at once.
@@ -411,7 +374,7 @@ pub const Pool = struct {
std.debug.assert(cfg.failure_threshold >= 1); std.debug.assert(cfg.failure_threshold >= 1);
for (entries) |*entry| { for (entries) |*entry| {
std.debug.assert(entry.slots.len >= 1); std.debug.assert(entry.slots.len >= 1);
std.debug.assert(entry.admission.permits == entry.slots.len); std.debug.assert(entry.admission.permits.load(.monotonic) == entry.slots.len);
} }
// Stable, so entries sharing a priority keep their configured order. // Stable, so entries sharing a priority keep their configured order.
std.mem.sort(Entry, entries, {}, byPriority); std.mem.sort(Entry, entries, {}, byPriority);
@@ -709,7 +672,7 @@ pub const Pool = struct {
if (spent.isSet(index)) continue; if (spent.isSet(index)) continue;
if (!entry.enabled) continue; if (!entry.enabled) continue;
if (!self.entryAvailable(io, entry, now)) continue; if (!self.entryAvailable(io, entry, now)) continue;
if (entry.admission.tryAcquire(io)) return index; if (entry.admission.tryAcquire()) return index;
if (first_eligible == null) first_eligible = index; if (first_eligible == null) first_eligible = index;
} }
@@ -1033,7 +996,7 @@ fn testEntrySlots(url: []const u8, fake: *Fake, priority: i32, slot_count: usize
.priority = priority, .priority = priority,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = slot_count }, .admission = .{ .permits = .init(slot_count) },
.reuse_recoveries = &fake.recoveries, .reuse_recoveries = &fake.recoveries,
}; };
} }
@@ -1092,7 +1055,7 @@ test "a canceled admission waiter holds nothing and its peer still wakes" {
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); const io = threaded.io();
var admission: Admission = .{ .permits = 0 }; var admission: Admission = .{};
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake }); const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
var canceled = io.concurrent(Admission.acquireUntil, .{ &admission, io, far }) catch |err| switch (err) { var canceled = io.concurrent(Admission.acquireUntil, .{ &admission, io, far }) catch |err| switch (err) {
@@ -1114,41 +1077,16 @@ test "a canceled admission waiter holds nothing and its peer still wakes" {
// forever and this test would hang. // forever and this test would hang.
admission.release(io); admission.release(io);
try testing.expectEqual(Admission.Outcome.acquired, try peer.await(io)); try testing.expectEqual(Admission.Outcome.acquired, try peer.await(io));
try testing.expectEqual(@as(usize, 0), admission.permits); try testing.expectEqual(@as(usize, 0), admission.permits.load(.monotonic));
} }
fn holdAdmissionMutex(admission: *Admission, io: std.Io, hold: std.Io.Clock.Duration) void { test "an admission wait with no release expires at its deadline holding nothing" {
admission.mutex.lockUncancelable(io); const io = testing.io;
defer admission.mutex.unlock(io); var admission: Admission = .{};
hold.sleep(io) catch {}; const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
}
test "a contended admission mutex does not carry acquireUntil past its deadline" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// One permit, but the mutex guarding it is held far past the caller's
// deadline. Every take must therefore run inside the deadline race: a
// blocking lock before the race starts would return `.acquired` only once
// the holder let go, long after the budget was gone.
var admission: Admission = .{ .permits = 1 };
const hold: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
var holder = io.concurrent(holdAdmissionMutex, .{ &admission, io, hold }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer holder.await(io);
const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
try settle.sleep(io);
const deadline: std.Io.Clock.Timestamp = .fromNow(io, settle);
// `.expired` is the whole assertion: the permit is free, so a take that
// waited on the mutex outside the race would report `.acquired` instead,
// hundreds of milliseconds after the deadline. The exit path itself still
// waits for the mutex, uncancelably, to re-signal the condition.
try testing.expectEqual(Admission.Outcome.expired, try admission.acquireUntil(io, deadline)); try testing.expectEqual(Admission.Outcome.expired, try admission.acquireUntil(io, deadline));
try testing.expectEqual(@as(usize, 1), admission.permits); try testing.expect(std.Io.Clock.Timestamp.now(io, .awake).compare(.gte, deadline));
try testing.expectEqual(@as(usize, 0), admission.permits.load(.monotonic));
} }
test "an admission expiry racing a release leaves the permit count exact" { test "an admission expiry racing a release leaves the permit count exact" {
@@ -1156,13 +1094,13 @@ test "an admission expiry racing a release leaves the permit count exact" {
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); const io = threaded.io();
// The tie the design exists for: the permit is taken by the caller under // An expiry and a release in the same instant: the waiter either takes
// the mutex only after the race resolves, so a discarded wait can lose a // the permit or leaves it counted, never both and never neither. Repeated
// notification but never a permit. Repeated because the interleaving is a // because the interleaving is a race — a leak would show as a count that
// race — a leak would show as a count that drifts below one. // drifts below one.
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(2), .clock = .awake }; const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(2), .clock = .awake };
for (0..50) |_| { for (0..50) |_| {
var admission: Admission = .{ .permits = 0 }; var admission: Admission = .{};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, tick); const deadline: std.Io.Clock.Timestamp = .fromNow(io, tick);
var waiter = io.concurrent(Admission.acquireUntil, .{ &admission, io, deadline }) catch |err| switch (err) { var waiter = io.concurrent(Admission.acquireUntil, .{ &admission, io, deadline }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest, error.ConcurrencyUnavailable => return error.SkipZigTest,
@@ -1174,7 +1112,7 @@ test "an admission expiry racing a release leaves the permit count exact" {
.acquired => admission.release(io), .acquired => admission.release(io),
.expired => {}, .expired => {},
} }
try testing.expectEqual(@as(usize, 1), admission.permits); try testing.expectEqual(@as(usize, 1), admission.permits.load(.monotonic));
} }
} }
@@ -1635,7 +1573,7 @@ test "an exchange whose deadline is already gone starts no attempt" {
try testing.expectEqual(@as(u64, 0), entries[0].health.total_successes); try testing.expectEqual(@as(u64, 0), entries[0].health.total_successes);
try testing.expectEqual(@as(u64, 1), pool.budgetExhaustedTotal()); try testing.expectEqual(@as(u64, 1), pool.budgetExhaustedTotal());
// The permit was released on the way out, so the entry is usable again. // The permit was released on the way out, so the entry is usable again.
try testing.expectEqual(@as(usize, 1), entries[0].admission.permits); try testing.expectEqual(@as(usize, 1), entries[0].admission.permits.load(.monotonic));
} }
/// The pass-two probing test's numbers: the holder's stall outlives the /// The pass-two probing test's numbers: the holder's stall outlives the
+72 -15
View File
@@ -42,6 +42,8 @@ const safe_url = @import("../safe_url.zig");
const server = @import("server.zig"); const server = @import("server.zig");
const tcp_server = @import("../server/tcp_server.zig"); const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig"); const udp_server = @import("../server/udp_server.zig");
const udp_socket_stats = @import("../platform/udp_socket_stats.zig");
const query_budget = @import("../server/query_budget.zig");
/// The exposition format version, as the 0.0.4 specification writes it. /// The exposition format version, as the 0.0.4 specification writes it.
pub const content_type = "text/plain; version=0.0.4; charset=utf-8"; pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
@@ -105,7 +107,6 @@ pub const DohListenerSample = struct {
idle_timeouts: u64, idle_timeouts: u64,
connection_errors: u64, connection_errors: u64,
bad_requests: u64, bad_requests: u64,
dropped_no_permit: u64,
}; };
/// One upstream, with every string owned by the caller's arena. /// One upstream, with every string owned by the caller's arena.
@@ -187,7 +188,14 @@ pub const Sample = struct {
/// operator asks how much UDP/53 dropped, not how much of it arrived over /// operator asks how much UDP/53 dropped, not how much of it arrived over
/// IPv6. Absent when no listener is wired, like every other collaborator. /// IPv6. Absent when no listener is wired, like every other collaborator.
udp_listener: ?udp_server.Snapshot = null, udp_listener: ?udp_server.Snapshot = null,
/// The kernel's counters for the UDP listeners' sockets, summed like the
/// listener family. Absent with no UDP listener, or when the scrape could
/// not read them, which `udp_kernel_read_failures_total` then counts.
udp_kernel: ?udp_socket_stats.Reading = null,
udp_kernel_read_failures_total: ?u64 = null,
tcp_listener: ?tcp_server.Snapshot = null, tcp_listener: ?tcp_server.Snapshot = null,
/// The shared query budget's waits. Absent while no budget is wired.
query_budget: ?query_budget.Snapshot = null,
upstreams: []const UpstreamSample = &.{}, upstreams: []const UpstreamSample = &.{},
/// Exchanges the pool gave up on because the request's own budget ran out. /// Exchanges the pool gave up on because the request's own budget ran out.
/// Pool-wide rather than per-upstream on purpose: budget exhaustion is /// Pool-wide rather than per-upstream on purpose: budget exhaustion is
@@ -302,7 +310,6 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.idle_timeouts = snapshot.idle_timeouts, .idle_timeouts = snapshot.idle_timeouts,
.connection_errors = snapshot.connection_errors, .connection_errors = snapshot.connection_errors,
.bad_requests = snapshot.bad_requests, .bad_requests = snapshot.bad_requests,
.dropped_no_permit = snapshot.dropped_no_permit,
}; };
} }
if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats(); if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();
@@ -310,6 +317,13 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
if (state.dot_listener) |listener| sample.peaks.dot_connections = listener.connectionsPeak(); if (state.dot_listener) |listener| sample.peaks.dot_connections = listener.connectionsPeak();
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners); sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
if (state.udp_listeners.len != 0) {
const sockets = try arena.alloc(std.Io.net.Socket, state.udp_listeners.len);
for (state.udp_listeners, sockets) |listener, *socket| socket.* = listener.socket;
sample.udp_kernel = udp_socket_stats.readSockets(io, arena, sockets);
sample.udp_kernel_read_failures_total = udp_socket_stats.readFailures();
}
if (state.query_budget) |budget| sample.query_budget = budget.snapshot();
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners); sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
sample.peaks.tcp_connections = largestConnectionsPeak(state.tcp_listeners); sample.peaks.tcp_connections = largestConnectionsPeak(state.tcp_listeners);
@@ -426,7 +440,7 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
try labeledHead( try labeledHead(
w, w,
"nxdns_concurrency_unavailable_total", "nxdns_concurrency_unavailable_total",
"Work lost because the task pool was at its limit or could not start a thread, by where it happened.", "Work lost because the system refused a thread or a task could not be allocated, by where it happened.",
"counter", "counter",
); );
for (std.enums.values(concurrency.Site)) |site| { for (std.enums.values(concurrency.Site)) |site| {
@@ -527,6 +541,27 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
if (sample.udp_listener) |listener| { if (sample.udp_listener) |listener| {
try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener); try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
} }
if (sample.udp_kernel) |kernel| {
try gauge(
w,
"nxdns_udp_server_receive_queue_bytes",
"Bytes the kernel holds for the UDP/53 sockets, waiting to be received.",
kernel.receive_queue_bytes,
);
try counter(
w,
"nxdns_udp_server_kernel_drops_total",
"Datagrams the kernel dropped for the UDP/53 sockets, mostly a full receive buffer.",
kernel.drops,
);
}
if (sample.udp_kernel_read_failures_total) |failures| try counter(
w,
"nxdns_udp_server_kernel_read_failures_total",
"Scrapes that could not read the UDP/53 sockets' kernel counters.",
failures,
);
if (sample.query_budget) |budget| try renderQueryBudget(w, budget);
if (sample.tcp_listener) |listener| { if (sample.tcp_listener) |listener| {
try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener); try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
} }
@@ -570,6 +605,34 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
} }
} }
fn renderQueryBudget(w: *std.Io.Writer, budget: query_budget.Snapshot) std.Io.Writer.Error!void {
try counter(
w,
"nxdns_dns_query_permit_waits_total",
"DNS queries that found every query permit taken and waited for one.",
budget.waits,
);
try labeledHead(
w,
"nxdns_dns_query_permit_wait_seconds_total",
"Time DNS queries spent waiting for a query permit.",
"counter",
);
try w.print("nxdns_dns_query_permit_wait_seconds_total {d:.4}\n", .{budget.wait_seconds});
try counter(
w,
"nxdns_dns_query_permit_wait_timeouts_total",
"Stream DNS queries whose connection ended because the wait for a permit outlasted the idle budget.",
budget.wait_timeouts,
);
try gauge(
w,
"nxdns_dns_query_permits_waiting",
"DNS queries waiting for a query permit right now.",
budget.waiting,
);
}
fn renderPeaks(w: *std.Io.Writer, peaks: PeakSample) std.Io.Writer.Error!void { fn renderPeaks(w: *std.Io.Writer, peaks: PeakSample) std.Io.Writer.Error!void {
if (peaks.dns_in_flight) |value| try gauge( if (peaks.dns_in_flight) |value| try gauge(
w, w,
@@ -1152,8 +1215,8 @@ test "the plain-DNS listener families carry every counter of both listeners" {
.udp_listener = .{ .udp_listener = .{
.received = 90, .received = 90,
.dropped_oversize = 1, .dropped_oversize = 1,
.dropped_no_slot = 2,
.dropped_handler = 3, .dropped_handler = 3,
.dropped_at_shutdown = 6,
.receive_errors = 4, .receive_errors = 4,
.send_errors = 5, .send_errors = 5,
}, },
@@ -1164,22 +1227,20 @@ test "the plain-DNS listener families carry every counter of both listeners" {
.accept_errors = 8, .accept_errors = 8,
.connection_errors = 9, .connection_errors = 9,
.idle_timeouts = 10, .idle_timeouts = 10,
.dropped_no_permit = 11,
}, },
}); });
defer testing.allocator.free(text); defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_at_shutdown_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_dropped_no_permit_total 11\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n"));
@@ -1198,12 +1259,12 @@ test "one family covers all four listeners, summed" {
var udp6: udp_server.UdpServer = undefined; var udp6: udp_server.UdpServer = undefined;
udp6.stats = .{}; udp6.stats = .{};
udp6.stats.received.store(10, .monotonic); udp6.stats.received.store(10, .monotonic);
udp6.stats.dropped_no_slot.store(1, .monotonic); udp6.stats.dropped_oversize.store(1, .monotonic);
var udp4: udp_server.UdpServer = undefined; var udp4: udp_server.UdpServer = undefined;
udp4.stats = .{}; udp4.stats = .{};
udp4.stats.received.store(7, .monotonic); udp4.stats.received.store(7, .monotonic);
udp4.stats.dropped_no_slot.store(2, .monotonic); udp4.stats.dropped_oversize.store(2, .monotonic);
var tcp6: tcp_server.TcpServer = undefined; var tcp6: tcp_server.TcpServer = undefined;
tcp6.core.stats = .{}; tcp6.core.stats = .{};
@@ -1216,7 +1277,7 @@ test "one family covers all four listeners, summed" {
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?; const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
try testing.expectEqual(@as(u64, 17), udp.received); try testing.expectEqual(@as(u64, 17), udp.received);
try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot); try testing.expectEqual(@as(u64, 3), udp.dropped_oversize);
try testing.expectEqual(@as(u64, 0), udp.send_errors); try testing.expectEqual(@as(u64, 0), udp.send_errors);
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?; const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
@@ -1253,7 +1314,6 @@ test "listener counters render only for the wired servers" {
.idle_timeouts = 1, .idle_timeouts = 1,
.connection_errors = 0, .connection_errors = 0,
.bad_requests = 4, .bad_requests = 4,
.dropped_no_permit = 7,
}, },
}); });
defer testing.allocator.free(doh_only); defer testing.allocator.free(doh_only);
@@ -1264,7 +1324,6 @@ test "listener counters render only for the wired servers" {
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_rejected_at_capacity_total 6\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_rejected_at_capacity_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_dropped_no_permit_total 7\n"));
try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_")); try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_"));
const dot_only = try renderToString(testing.allocator, .{ const dot_only = try renderToString(testing.allocator, .{
@@ -1274,7 +1333,6 @@ test "listener counters render only for the wired servers" {
.tls_handshake_failures = 0, .tls_handshake_failures = 0,
.idle_timeouts = 3, .idle_timeouts = 3,
.connection_errors = 1, .connection_errors = 1,
.dropped_no_permit = 4,
}, },
}); });
defer testing.allocator.free(dot_only); defer testing.allocator.free(dot_only);
@@ -1283,7 +1341,6 @@ test "listener counters render only for the wired servers" {
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_rejected_at_capacity_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_rejected_at_capacity_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_dropped_no_permit_total 4\n"));
// The DoT listener has no HTTP layer, so no bad_requests family. // The DoT listener has no HTTP layer, so no bad_requests family.
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total")); try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total"));
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_")); try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_"));
@@ -1747,7 +1804,7 @@ test "the queue families carry what a real pool recorded, through the real snaps
.priority = 10, .priority = 10,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = slots.len }, .admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &recoveries, .reuse_recoveries = &recoveries,
}}; }};
var pool: pool_mod.Pool = .init(&entries, .{}, .{ var pool: pool_mod.Pool = .init(&entries, .{}, .{
+4
View File
@@ -47,6 +47,7 @@ const router = @import("router.zig");
const sse = @import("sse.zig"); const sse = @import("sse.zig");
const tcp_server = @import("../server/tcp_server.zig"); const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig"); const udp_server = @import("../server/udp_server.zig");
const query_budget = @import("../server/query_budget.zig");
const log = std.log.scoped(.web_server); const log = std.log.scoped(.web_server);
@@ -340,6 +341,9 @@ pub const WebState = struct {
/// (milestone-10 ruling 10). /// (milestone-10 ruling 10).
doh_listener: ?*doh_server.DohServer = null, doh_listener: ?*doh_server.DohServer = null,
dot_listener: ?*dot_server.DotServer = null, dot_listener: ?*dot_server.DotServer = null,
/// The query permits every DNS listener shares; `/metrics` reads its
/// waits.
query_budget: ?*query_budget.QueryBudget = null,
/// The plain DNS listeners. The app binds one per family per protocol, so /// The plain DNS listeners. The app binds one per family per protocol, so
/// `/metrics` sums each family across its slice (milestone-16 ruling 13). /// `/metrics` sums each family across its slice (milestone-16 ruling 13).
udp_listeners: []const *udp_server.UdpServer = &.{}, udp_listeners: []const *udp_server.UdpServer = &.{},
+1 -1
View File
@@ -494,7 +494,7 @@ const Env = struct {
.priority = 1, .priority = 1,
.enabled = true, .enabled = true,
.health = .init, .health = .init,
.admission = .{ .permits = self.pool_slots.len }, .admission = .{ .permits = .init(self.pool_slots.len) },
.reuse_recoveries = &self.pool_recoveries, .reuse_recoveries = &self.pool_recoveries,
}}; }};
self.pool_owner = .{}; self.pool_owner = .{};