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:
+2
-2
@@ -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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **`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.
|
||||
- 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
@@ -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.
|
||||
- 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.
|
||||
|
||||
## Contract
|
||||
@@ -56,13 +56,19 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
|
||||
|
||||
### 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- UDP keeps today's counted drop when no permit is free (`dropped_no_slot`). The kernel buffer does not absorb overload invisibly.
|
||||
- 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.
|
||||
- UDP slots and TCP connection buffers are sized from these bounds, not 64 per listener.
|
||||
- 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`).
|
||||
- Two kinds of bound: one budget of query permits, shared by every listener (UDP and TCP, both families, DoT/DoH when enabled), and a slot cap per stream listener on accepted connections. A query takes a permit only while it does work; a keep-alive connection releases its permit between requests.
|
||||
- The work is bounded where it enters, not in the thread pool. `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`).
|
||||
- A query that finds every permit taken waits for one; it is neither dropped nor answered SERVFAIL. glibc's stub retries a SERVFAIL once at once and then fails the lookup, and a dropped UDP query costs the client its whole retry timeout, while a wait behind a permit that frees within the upstream deadline is invisible to it. Every held permit is bounded (the upstream total deadline, the stream write budget), so a wait always ends.
|
||||
- UDP: the receive loop receives into its own staging buffer, then waits for a permit holding that datagram, and receives nothing meanwhile. The backlog stays in the socket's kernel buffer, bounded by `SO_RCVBUF`; what overflows it is dropped by the kernel and exported from `/proc/net/udp{,6}` by socket inode 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`.
|
||||
- Streams: a whole query read off the connection waits at most the connection's idle budget. A wait that outlasts it ends the connection unanswered, because DNS-over-TCP has no framing for "no answer"; DoH closes too.
|
||||
- Every wait is counted: `nxdns_dns_query_permit_waits_total`, `nxdns_dns_query_permit_wait_seconds_total`, `nxdns_dns_query_permit_wait_timeouts_total` and the gauge `nxdns_dns_query_permits_waiting`.
|
||||
- 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
|
||||
|
||||
@@ -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).
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
+7
-35
@@ -48,7 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
|
||||
const forward_zones = @import("local/forward_zones.zig");
|
||||
const handler = @import("server/handler.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 local_records = @import("local/records.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.
|
||||
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 {
|
||||
const code = serve(runner, args) catch |err| code: {
|
||||
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 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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -979,6 +950,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
udp_count += 1;
|
||||
}
|
||||
web_state.udp_listeners = udp_listeners[0..udp_count];
|
||||
web_state.query_budget = &budget;
|
||||
|
||||
var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
|
||||
var tcp_count: usize = 0;
|
||||
|
||||
+1
-1
@@ -1038,7 +1038,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = .init(slots.len) },
|
||||
.reuse_recoveries = &recoveries,
|
||||
}};
|
||||
var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
|
||||
|
||||
+8
-33
@@ -3,8 +3,6 @@
|
||||
//! parameters and is therefore testable without a process.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const app = @import("app.zig");
|
||||
const cli = @import("cli.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);
|
||||
}
|
||||
|
||||
/// The allocator `start.zig` would choose: leak-checking in Debug, and libc's
|
||||
/// 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();
|
||||
|
||||
pub fn main(init: std.process.Init) u8 {
|
||||
// Both `File.Writer` values are self-referential and must not move, so they
|
||||
// stay in these `var` slots for the whole of `main`.
|
||||
var out_buffer: [4096]u8 = undefined;
|
||||
var err_buffer: [4096]u8 = undefined;
|
||||
var out = runnerWriter(std.Io.File.stdout(), io, &out_buffer);
|
||||
var err = runnerWriter(std.Io.File.stderr(), io, &err_buffer);
|
||||
var out = runnerWriter(std.Io.File.stdout(), init.io, &out_buffer);
|
||||
var err = runnerWriter(std.Io.File.stderr(), init.io, &err_buffer);
|
||||
|
||||
const runner: cli.Runner = .{
|
||||
.io = io,
|
||||
.gpa = gpa,
|
||||
.io = init.io,
|
||||
.gpa = init.gpa,
|
||||
.out = &out.interface,
|
||||
.err = &err.interface,
|
||||
};
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! Where `error.ConcurrencyUnavailable` surfaced (specs/memory-budget.md item 3).
|
||||
//!
|
||||
//! `main` caps the process's one `std.Io.Threaded` at a task count the
|
||||
//! accounting in `app.zig` says is never reached, so this error means the
|
||||
//! accounting is wrong or the system refused a thread. Either way the work 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.
|
||||
//! The process's `std.Io.Threaded` sets no task limit: the work is bounded
|
||||
//! where it enters, by the shared query budget and each listener's connection
|
||||
//! cap, and the pool grows to that peak. So this error means the system
|
||||
//! refused a thread or the pool could not allocate a task. Either way the work
|
||||
//! 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
|
||||
//! one `Threaded` every subsystem shares, not of any one of them, and a
|
||||
//! counter beside each call site keeps transport helpers free of a stats
|
||||
//! pointer they would otherwise thread through for this alone. Never reset,
|
||||
//! Process-wide, like the log sink's counters: the failure belongs to the one
|
||||
//! `Threaded` every subsystem shares, not to any one of them, and a counter
|
||||
//! beside each call site keeps transport helpers free of a stats pointer they
|
||||
//! would otherwise thread through for this alone. Never reset,
|
||||
//! so a test reads a delta, not a value.
|
||||
|
||||
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
|
||||
/// its race against the idle budget. The connection ends.
|
||||
listener_race,
|
||||
/// An upstream or forward-zone exchange, or an upstream admission wait,
|
||||
/// that could not start its race against the query's deadline. The
|
||||
/// exchange fails as a local resource fault.
|
||||
/// An upstream or forward-zone exchange that could not start its race
|
||||
/// against the query's deadline. The exchange fails as a local resource
|
||||
/// fault.
|
||||
exchange_race,
|
||||
/// A forward-zone receive whose timed wait could not start.
|
||||
forward_receive,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -91,7 +91,6 @@ pub const Snapshot = struct {
|
||||
idle_timeouts: u64,
|
||||
connection_errors: u64,
|
||||
bad_requests: u64,
|
||||
dropped_no_permit: u64,
|
||||
};
|
||||
|
||||
pub const DohServer = struct {
|
||||
@@ -117,6 +116,7 @@ pub const DohServer = struct {
|
||||
pub const Owner = DohServer;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const closing = wakeBudgetWaiters;
|
||||
pub const read_buffer_len = recv_buffer_len;
|
||||
pub const write_buffer_len = send_buffer_len;
|
||||
pub const log = std.log.scoped(.doh_server);
|
||||
@@ -165,7 +165,6 @@ pub const DohServer = struct {
|
||||
.idle_timeouts = core.idle_timeouts.load(.monotonic),
|
||||
.connection_errors = core.connection_errors.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);
|
||||
}
|
||||
|
||||
fn wakeBudgetWaiters(self: *DohServer, io: std.Io) void {
|
||||
self.budget.wakeWaiters(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DohServer, io: std.Io) void {
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
@@ -389,10 +392,10 @@ pub const DohServer = struct {
|
||||
request: *http.Server.Request,
|
||||
query: []const u8,
|
||||
) error{ WriteFailed, HttpExpectationFailed }!Next {
|
||||
const work = self.budget.tryAcquire(io) orelse {
|
||||
listener.bump(&self.core.stats.dropped_no_permit);
|
||||
return .close;
|
||||
};
|
||||
// Bounded like a read: a query still waiting when the idle budget runs
|
||||
// out closes the connection, with the timeout counted by the budget.
|
||||
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);
|
||||
|
||||
const outcome = self.handler.handle(
|
||||
|
||||
@@ -60,7 +60,6 @@ pub const StatsSnapshot = struct {
|
||||
tls_handshake_failures: u64,
|
||||
idle_timeouts: u64,
|
||||
connection_errors: u64,
|
||||
dropped_no_permit: u64,
|
||||
};
|
||||
|
||||
pub const DotServer = struct {
|
||||
@@ -83,6 +82,7 @@ pub const DotServer = struct {
|
||||
pub const Owner = DotServer;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const closing = wakeBudgetWaiters;
|
||||
pub const read_buffer_len = stream_buffer_len;
|
||||
pub const write_buffer_len = stream_buffer_len;
|
||||
pub const log = std.log.scoped(.dot_server);
|
||||
@@ -121,6 +121,10 @@ pub const DotServer = struct {
|
||||
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 {
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
@@ -138,7 +142,6 @@ pub const DotServer = struct {
|
||||
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
|
||||
.idle_timeouts = core.idle_timeouts.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.
|
||||
const work = self.budget.tryAcquire(io) orelse {
|
||||
listener.bump(&stats.dropped_no_permit);
|
||||
return;
|
||||
};
|
||||
// Bounded like a read: a query still waiting when the idle budget
|
||||
// runs out ends the connection, with the timeout counted by the
|
||||
// budget. There is no framing for "no answer".
|
||||
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);
|
||||
|
||||
const outcome = self.handler.handle(
|
||||
|
||||
@@ -91,10 +91,6 @@ pub const CoreStats = struct {
|
||||
accept_errors: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: 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
|
||||
/// item 0). A gauge, so no listener's `Snapshot` carries it: those export
|
||||
/// 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
|
||||
/// listener's per-connection arena). `initPayload` runs inside `listen`,
|
||||
/// `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 {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
@@ -336,6 +336,7 @@ pub fn Core(comptime Cfg: type) type {
|
||||
|
||||
pub fn deinit(self: *Self, io: std.Io) void {
|
||||
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
|
||||
// unblock a pending `accept`: it fails with
|
||||
|
||||
+306
-13
@@ -7,21 +7,29 @@
|
||||
//! it when its reply is written, so a keep-alive connection between requests
|
||||
//! holds none.
|
||||
//!
|
||||
//! Nothing waits for a permit. A listener that finds the pool empty drops the
|
||||
//! query and counts it, because a wait would only turn a visible drop into
|
||||
//! invisible latency, and every permit is also the tasks its query may start,
|
||||
//! which `app.concurrent_limit` adds up from `permits`.
|
||||
//! A query that finds every permit taken waits for one, on its own thread,
|
||||
//! rather than being dropped or answered SERVFAIL: a stub resolver treats
|
||||
//! either as a failed lookup, while a short wait is invisible to it. Each held
|
||||
//! 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 Allocator = std.mem.Allocator;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const listener = @import("listener.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
|
||||
/// highest reading of `nxdns_dns_in_flight_peak` was 27 over 37 hours of
|
||||
/// household traffic, and a query beyond this is dropped and counted.
|
||||
pub const permits = 32;
|
||||
/// highest reading of `nxdns_dns_in_flight_peak` was 27 over a day of
|
||||
/// household traffic; a burst of 64 simultaneous uncached queries was answered
|
||||
/// 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
|
||||
/// truncated and is dropped rather than guessed at.
|
||||
@@ -48,6 +56,26 @@ pub const Work = struct {
|
||||
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 {
|
||||
gpa: Allocator,
|
||||
work: []Work,
|
||||
@@ -56,6 +84,11 @@ pub const QueryBudget = struct {
|
||||
/// `free[0..free_len]`.
|
||||
free: []u16,
|
||||
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 {
|
||||
std.debug.assert(count > 0);
|
||||
@@ -63,21 +96,52 @@ pub const QueryBudget = struct {
|
||||
errdefer gpa.free(work);
|
||||
const free = try gpa.alloc(u16, count);
|
||||
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.
|
||||
pub fn deinit(self: *QueryBudget) void {
|
||||
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.work);
|
||||
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
|
||||
/// 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.
|
||||
/// mutex, which no holder keeps across any Io, but never for a permit.
|
||||
pub fn tryAcquire(self: *QueryBudget, io: std.Io) ?*Work {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
@@ -90,12 +154,21 @@ pub const QueryBudget = struct {
|
||||
const index = (@intFromPtr(work) - @intFromPtr(self.work.ptr)) / @sizeOf(Work);
|
||||
std.debug.assert(&self.work[index] == work);
|
||||
|
||||
{
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
std.debug.assert(self.free_len < self.work.len);
|
||||
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.
|
||||
pub fn taken(self: *QueryBudget, io: std.Io) usize {
|
||||
@@ -103,10 +176,61 @@ pub const QueryBudget = struct {
|
||||
defer self.mutex.unlock(io);
|
||||
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;
|
||||
|
||||
var test_serving: std.atomic.Value(listener.State) = .init(.serving);
|
||||
|
||||
test "a budget hands out every permit once, then refuses" {
|
||||
var budget: QueryBudget = try .init(testing.allocator, 3);
|
||||
defer budget.deinit();
|
||||
@@ -135,6 +259,175 @@ test "a released permit is handed out again" {
|
||||
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" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, struct {
|
||||
fn run(gpa: Allocator) !void {
|
||||
@@ -145,7 +438,7 @@ test "init survives every allocation failure" {
|
||||
}
|
||||
|
||||
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.
|
||||
try testing.expect(@sizeOf(Work) * permits < 3 * 1024 * 1024);
|
||||
try testing.expect(@sizeOf(Work) * permits < 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ const EntryStorage = struct {
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = self.slots.len },
|
||||
.admission = .{ .permits = .init(self.slots.len) },
|
||||
.reuse_recoveries = &self.recoveries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ pub const Snapshot = struct {
|
||||
accept_errors: u64,
|
||||
connection_errors: u64,
|
||||
idle_timeouts: u64,
|
||||
dropped_no_permit: u64,
|
||||
};
|
||||
|
||||
pub const TcpServer = struct {
|
||||
@@ -70,6 +69,7 @@ pub const TcpServer = struct {
|
||||
pub const Owner = TcpServer;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const closing = wakeBudgetWaiters;
|
||||
pub const read_buffer_len = stream_buffer_len;
|
||||
pub const write_buffer_len = stream_buffer_len;
|
||||
pub const log = std.log.scoped(.tcp_server);
|
||||
@@ -116,7 +116,6 @@ pub const TcpServer = struct {
|
||||
.accept_errors = stats.accept_errors.load(.monotonic),
|
||||
.connection_errors = stats.connection_errors.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);
|
||||
}
|
||||
|
||||
fn wakeBudgetWaiters(self: *TcpServer, io: std.Io) void {
|
||||
self.budget.wakeWaiters(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *TcpServer, io: std.Io) void {
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
@@ -182,11 +185,11 @@ pub const TcpServer = struct {
|
||||
},
|
||||
}
|
||||
|
||||
// There is no framing for "no answer", so the connection ends.
|
||||
const work = self.budget.tryAcquire(io) orelse {
|
||||
listener.bump(&stats.dropped_no_permit);
|
||||
return;
|
||||
};
|
||||
// Bounded like a read: a query still waiting when the idle budget
|
||||
// runs out ends the connection, with the timeout counted by the
|
||||
// budget. There is no framing for "no answer".
|
||||
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);
|
||||
|
||||
const outcome = self.handler.handle(
|
||||
|
||||
@@ -527,9 +527,21 @@ fn askOn(reader: *std.Io.Reader, writer: *std.Io.Writer) ![]const u8 {
|
||||
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
|
||||
/// budget; a second query that finds none taken ends the connection unanswered.
|
||||
fn keepAliveThenStarved(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget) anyerror!void {
|
||||
/// budget; a second query that finds every permit taken waits, and is answered
|
||||
/// 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 });
|
||||
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);
|
||||
|
||||
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 writer.interface.writeAll(query_bytes);
|
||||
try writer.interface.flush();
|
||||
try sendQuery(&writer.interface);
|
||||
try waitForWaiters(permits, io, 1);
|
||||
permits.release(io, held);
|
||||
released = true;
|
||||
|
||||
var sink: [64]u8 = undefined;
|
||||
if (try reader.interface.readSliceShort(&sink) != 0) return error.TestUnexpectedBytes;
|
||||
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
|
||||
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;
|
||||
|
||||
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, 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,
|
||||
|
||||
+25
-19
@@ -1,9 +1,10 @@
|
||||
//! The UDP/53 listener.
|
||||
//!
|
||||
//! 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,
|
||||
//! never queued: an unbounded queue only moves the failure from "visible drop"
|
||||
//! to "invisible latency", which is the failure mode PLAN §1 names.
|
||||
//! shared query budget. While every permit is taken the loop waits for one with
|
||||
//! the datagram it holds and receives nothing more, so the backlog stays in the
|
||||
//! 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
|
||||
//! answering a datagram allocates nothing.
|
||||
@@ -12,6 +13,7 @@ const std = @import("std");
|
||||
const address = @import("../platform/address.zig");
|
||||
const concurrency = @import("../platform/concurrency.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const listener = @import("listener.zig");
|
||||
const query_budget = @import("query_budget.zig");
|
||||
|
||||
const log = std.log.scoped(.udp_server);
|
||||
@@ -27,14 +29,14 @@ pub const Stats = struct {
|
||||
received: std.atomic.Value(u64) = .init(0),
|
||||
/// The datagram arrived with `flags.trunc`: the kernel discarded its tail.
|
||||
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
|
||||
/// shutdown. The handler counts its own malformed drops; this counter is
|
||||
/// 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
|
||||
/// is counted under `nxdns_concurrency_unavailable_total{site="udp_query"}`.
|
||||
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),
|
||||
send_errors: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
@@ -46,23 +48,20 @@ pub const Stats = struct {
|
||||
pub const Snapshot = struct {
|
||||
received: u64,
|
||||
dropped_oversize: u64,
|
||||
dropped_no_slot: u64,
|
||||
dropped_handler: u64,
|
||||
dropped_at_shutdown: u64,
|
||||
receive_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 {
|
||||
socket: std.Io.net.Socket,
|
||||
handler: *handler.Handler,
|
||||
budget: *query_budget.QueryBudget,
|
||||
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,
|
||||
|
||||
pub const BindError = std.Io.net.IpAddress.BindError;
|
||||
@@ -96,8 +95,8 @@ pub const UdpServer = struct {
|
||||
return .{
|
||||
.received = self.stats.received.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_at_shutdown = self.stats.dropped_at_shutdown.load(.monotonic),
|
||||
.receive_errors = self.stats.receive_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 {
|
||||
// `serve` reads this between receives, so it stops on its own and the
|
||||
// socket is closed only once nothing can be reading from it.
|
||||
if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io);
|
||||
// `serve` reads this between receives and while it waits for a permit,
|
||||
// so it stops on its own and the socket is closed only once nothing
|
||||
// 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.* = undefined;
|
||||
}
|
||||
@@ -161,9 +164,12 @@ pub const UdpServer = struct {
|
||||
continue;
|
||||
}
|
||||
|
||||
const work = self.budget.tryAcquire(io) orelse {
|
||||
bump(&self.stats.dropped_no_slot);
|
||||
continue;
|
||||
const work = self.budget.acquire(io, null, &self.state) catch |err| switch (err) {
|
||||
error.Canceled, error.Stopped => {
|
||||
bump(&self.stats.dropped_at_shutdown);
|
||||
return;
|
||||
},
|
||||
error.Timeout => unreachable,
|
||||
};
|
||||
@memcpy(work.datagram[0..msg.data.len], msg.data);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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.
|
||||
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).?;
|
||||
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);
|
||||
|
||||
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, 1), server.stats.dropped_no_slot.load(.monotonic));
|
||||
// The permit is still held, so only the close can end this wait; a deinit
|
||||
// that missed the waiter would hang here.
|
||||
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));
|
||||
}
|
||||
|
||||
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" {
|
||||
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.expectEqual(@as(u64, 1), concurrency.snapshot().get(.udp_query) - before);
|
||||
try testing.expectEqual(@as(usize, 0), permits.taken(io));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.dropped_no_slot.load(.monotonic));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ comptime {
|
||||
_ = @import("dns/dns.zig");
|
||||
_ = @import("platform/address.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_integration_test.zig");
|
||||
_ = @import("platform/tls_server.zig");
|
||||
|
||||
@@ -458,7 +458,7 @@ test "a session the upstream closed is recovered by one redial and counted, not
|
||||
.priority = 10,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = .init(slots.len) },
|
||||
.reuse_recoveries = &fixture.recoveries,
|
||||
}};
|
||||
var pool: pool_mod.Pool = .init(&entries, .{
|
||||
|
||||
@@ -577,7 +577,7 @@ const Upstreams = struct {
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = entry_slots.len },
|
||||
.admission = .{ .permits = .init(entry_slots.len) },
|
||||
.reuse_recoveries = &self.recovery_counters[self.used],
|
||||
};
|
||||
self.used += 1;
|
||||
|
||||
+53
-115
@@ -72,6 +72,7 @@ const events = @import("../storage/events.zig");
|
||||
const health = @import("health.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const transport = @import("transport.zig");
|
||||
const WakeWord = @import("../platform/wake_word.zig").WakeWord;
|
||||
|
||||
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
|
||||
/// 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
|
||||
/// standard semaphore's mutex/count/condition protocol with the two operations
|
||||
/// the failover loop needs added. The stdlib is not patched and nothing spins.
|
||||
///
|
||||
/// 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.
|
||||
/// in 0.16, so the count is an atomic and a waiter sleeps on a `WakeWord`
|
||||
/// until a release or its deadline, on its own thread: a timed race would cost
|
||||
/// two more tasks per waiter.
|
||||
///
|
||||
/// 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
|
||||
@@ -134,85 +127,56 @@ pub const slots_per_entry = 8;
|
||||
/// blocked. Waking on any of several entries needs multi-wait machinery the pool
|
||||
/// does not have.
|
||||
pub const Admission = struct {
|
||||
mutex: std.Io.Mutex = .init,
|
||||
cond: std.Io.Condition = .init,
|
||||
/// 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.
|
||||
pub const Outcome = enum { acquired, expired };
|
||||
|
||||
/// Takes a permit if one is free this instant; never blocks.
|
||||
///
|
||||
/// A contended mutex reads as "no permit now" rather than as a reason to
|
||||
/// wait. The failover loop calls this on every candidate in turn, so
|
||||
/// blocking here for a descheduled permit holder would stall the whole
|
||||
/// sweep and spend the caller's budget outside `acquireUntil`, which is the
|
||||
/// only step allowed to consume it.
|
||||
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;
|
||||
pub fn tryAcquire(self: *Admission) bool {
|
||||
var current = self.permits.load(.acquire);
|
||||
while (current != 0) {
|
||||
current = self.permits.cmpxchgWeak(current, current - 1, .acquire, .acquire) orelse return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns a permit. Uncancelable, so a torn-down attempt still gives its
|
||||
/// slot back.
|
||||
pub fn release(self: *Admission, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
self.permits += 1;
|
||||
self.cond.signal(io);
|
||||
_ = self.permits.fetchAdd(1, .release);
|
||||
self.released.wakeOne(io);
|
||||
}
|
||||
|
||||
/// Waits for a permit until `deadline`. Returns `.acquired` holding one,
|
||||
/// `.expired` holding none, or an error holding none — `error.Canceled`
|
||||
/// when the whole task is torn down, `error.SystemResources` when the race
|
||||
/// cannot start.
|
||||
/// `.expired` holding none, or `error.Canceled` holding none when the whole
|
||||
/// task is torn down.
|
||||
pub fn acquireUntil(
|
||||
self: *Admission,
|
||||
io: std.Io,
|
||||
deadline: std.Io.Clock.Timestamp,
|
||||
) transport.ExchangeError!Outcome {
|
||||
) std.Io.Cancelable!Outcome {
|
||||
while (true) {
|
||||
// Every take is non-blocking, including the retries. A take that
|
||||
// waits on the mutex spends time no deadline bounds: before the
|
||||
// first race it carries the call past the deadline outright, and
|
||||
// after a wake it can return `.acquired` late. A miss instead
|
||||
// re-enters the race below, which is aimed at the same absolute
|
||||
// instant every time, so the cycle ends at the deadline with
|
||||
// `.expired` — the correct answer, not a spin.
|
||||
if (self.tryAcquire(io)) return .acquired;
|
||||
var race: transport.RaceOutcome = .completed;
|
||||
_ = transport.raceUntilTagged(io, deadline, &race, waitForPermit, .{ self, io }) catch |err| {
|
||||
// This task may be leaving with a notification meant for a peer:
|
||||
// `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;
|
||||
// Read before the take, so a release that lands after a missed
|
||||
// take moves the word and the wait returns at once.
|
||||
const seen = self.released.observe();
|
||||
if (self.tryAcquire()) return .acquired;
|
||||
if (std.Io.Clock.Timestamp.now(io, deadline.clock).compare(.gte, deadline)) {
|
||||
self.handOn(io);
|
||||
return .expired;
|
||||
}
|
||||
self.released.wait(io, seen, deadline) catch {
|
||||
self.handOn(io);
|
||||
return error.Canceled;
|
||||
};
|
||||
// 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.
|
||||
fn waitForPermit(self: *Admission, io: std.Io) transport.ExchangeError!void {
|
||||
self.mutex.lock(io) catch return error.Canceled;
|
||||
defer self.mutex.unlock(io);
|
||||
while (self.permits == 0) self.cond.wait(io, &self.mutex) catch return error.Canceled;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// A waiter leaving without a permit may have consumed the one wake a
|
||||
/// release sent, so a permit still free is handed on rather than left for
|
||||
/// nobody.
|
||||
fn handOn(self: *Admission, io: std.Io) void {
|
||||
if (self.permits.load(.acquire) != 0) self.released.wakeOne(io);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,9 +200,8 @@ pub const Entry = struct {
|
||||
enabled: bool,
|
||||
health: health.State,
|
||||
/// One permit per slot, so a task waits here rather than spinning over
|
||||
/// `Slot.busy`. `.{ .permits = slots.len }` at build time; `Pool.init`
|
||||
/// asserts it. Its mutex and condition are plain values, so the sort in
|
||||
/// `init` may copy it.
|
||||
/// `Slot.busy`. `.{ .permits = .init(slots.len) }` at build time;
|
||||
/// `Pool.init` asserts it. Plain values, so the sort in `init` may copy it.
|
||||
admission: Admission,
|
||||
in_flight: std.atomic.Value(u32) = .init(0),
|
||||
/// 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);
|
||||
for (entries) |*entry| {
|
||||
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.
|
||||
std.mem.sort(Entry, entries, {}, byPriority);
|
||||
@@ -709,7 +672,7 @@ pub const Pool = struct {
|
||||
if (spent.isSet(index)) continue;
|
||||
if (!entry.enabled) 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;
|
||||
}
|
||||
|
||||
@@ -1033,7 +996,7 @@ fn testEntrySlots(url: []const u8, fake: *Fake, priority: i32, slot_count: usize
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = slot_count },
|
||||
.admission = .{ .permits = .init(slot_count) },
|
||||
.reuse_recoveries = &fake.recoveries,
|
||||
};
|
||||
}
|
||||
@@ -1092,7 +1055,7 @@ test "a canceled admission waiter holds nothing and its peer still wakes" {
|
||||
defer threaded.deinit();
|
||||
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 });
|
||||
|
||||
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.
|
||||
admission.release(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 {
|
||||
admission.mutex.lockUncancelable(io);
|
||||
defer admission.mutex.unlock(io);
|
||||
hold.sleep(io) catch {};
|
||||
}
|
||||
|
||||
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.
|
||||
test "an admission wait with no release expires at its deadline holding nothing" {
|
||||
const io = testing.io;
|
||||
var admission: Admission = .{};
|
||||
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
|
||||
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" {
|
||||
@@ -1156,13 +1094,13 @@ test "an admission expiry racing a release leaves the permit count exact" {
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// The tie the design exists for: the permit is taken by the caller under
|
||||
// the mutex only after the race resolves, so a discarded wait can lose a
|
||||
// notification but never a permit. Repeated because the interleaving is a
|
||||
// race — a leak would show as a count that drifts below one.
|
||||
// An expiry and a release in the same instant: the waiter either takes
|
||||
// the permit or leaves it counted, never both and never neither. Repeated
|
||||
// because the interleaving is a race — a leak would show as a count that
|
||||
// drifts below one.
|
||||
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(2), .clock = .awake };
|
||||
for (0..50) |_| {
|
||||
var admission: Admission = .{ .permits = 0 };
|
||||
var admission: Admission = .{};
|
||||
const deadline: std.Io.Clock.Timestamp = .fromNow(io, tick);
|
||||
var waiter = io.concurrent(Admission.acquireUntil, .{ &admission, io, deadline }) catch |err| switch (err) {
|
||||
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),
|
||||
.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, 1), pool.budgetExhaustedTotal());
|
||||
// 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
|
||||
|
||||
+72
-15
@@ -42,6 +42,8 @@ const safe_url = @import("../safe_url.zig");
|
||||
const server = @import("server.zig");
|
||||
const tcp_server = @import("../server/tcp_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.
|
||||
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
|
||||
@@ -105,7 +107,6 @@ pub const DohListenerSample = struct {
|
||||
idle_timeouts: u64,
|
||||
connection_errors: u64,
|
||||
bad_requests: u64,
|
||||
dropped_no_permit: u64,
|
||||
};
|
||||
|
||||
/// 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
|
||||
/// IPv6. Absent when no listener is wired, like every other collaborator.
|
||||
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,
|
||||
/// The shared query budget's waits. Absent while no budget is wired.
|
||||
query_budget: ?query_budget.Snapshot = null,
|
||||
upstreams: []const UpstreamSample = &.{},
|
||||
/// 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
|
||||
@@ -302,7 +310,6 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
.idle_timeouts = snapshot.idle_timeouts,
|
||||
.connection_errors = snapshot.connection_errors,
|
||||
.bad_requests = snapshot.bad_requests,
|
||||
.dropped_no_permit = snapshot.dropped_no_permit,
|
||||
};
|
||||
}
|
||||
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();
|
||||
|
||||
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.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(
|
||||
w,
|
||||
"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",
|
||||
);
|
||||
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| {
|
||||
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| {
|
||||
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 {
|
||||
if (peaks.dns_in_flight) |value| try gauge(
|
||||
w,
|
||||
@@ -1152,8 +1215,8 @@ test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
.udp_listener = .{
|
||||
.received = 90,
|
||||
.dropped_oversize = 1,
|
||||
.dropped_no_slot = 2,
|
||||
.dropped_handler = 3,
|
||||
.dropped_at_shutdown = 6,
|
||||
.receive_errors = 4,
|
||||
.send_errors = 5,
|
||||
},
|
||||
@@ -1164,22 +1227,20 @@ test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
.accept_errors = 8,
|
||||
.connection_errors = 9,
|
||||
.idle_timeouts = 10,
|
||||
.dropped_no_permit = 11,
|
||||
},
|
||||
});
|
||||
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_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_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_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_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_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_connection_errors_total 9\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;
|
||||
udp6.stats = .{};
|
||||
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;
|
||||
udp4.stats = .{};
|
||||
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;
|
||||
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 }).?;
|
||||
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);
|
||||
|
||||
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,
|
||||
.connection_errors = 0,
|
||||
.bad_requests = 4,
|
||||
.dropped_no_permit = 7,
|
||||
},
|
||||
});
|
||||
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_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_dropped_no_permit_total 7\n"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_"));
|
||||
|
||||
const dot_only = try renderToString(testing.allocator, .{
|
||||
@@ -1274,7 +1333,6 @@ test "listener counters render only for the wired servers" {
|
||||
.tls_handshake_failures = 0,
|
||||
.idle_timeouts = 3,
|
||||
.connection_errors = 1,
|
||||
.dropped_no_permit = 4,
|
||||
},
|
||||
});
|
||||
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_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_dropped_no_permit_total 4\n"));
|
||||
// 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_doh_server_"));
|
||||
@@ -1747,7 +1804,7 @@ test "the queue families carry what a real pool recorded, through the real snaps
|
||||
.priority = 10,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = .init(slots.len) },
|
||||
.reuse_recoveries = &recoveries,
|
||||
}};
|
||||
var pool: pool_mod.Pool = .init(&entries, .{}, .{
|
||||
|
||||
@@ -47,6 +47,7 @@ const router = @import("router.zig");
|
||||
const sse = @import("sse.zig");
|
||||
const tcp_server = @import("../server/tcp_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);
|
||||
|
||||
@@ -340,6 +341,9 @@ pub const WebState = struct {
|
||||
/// (milestone-10 ruling 10).
|
||||
doh_listener: ?*doh_server.DohServer = 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
|
||||
/// `/metrics` sums each family across its slice (milestone-16 ruling 13).
|
||||
udp_listeners: []const *udp_server.UdpServer = &.{},
|
||||
|
||||
@@ -494,7 +494,7 @@ const Env = struct {
|
||||
.priority = 1,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.admission = .{ .permits = self.pool_slots.len },
|
||||
.admission = .{ .permits = .init(self.pool_slots.len) },
|
||||
.reuse_recoveries = &self.pool_recoveries,
|
||||
}};
|
||||
self.pool_owner = .{};
|
||||
|
||||
Reference in New Issue
Block a user