milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+97 -381
View File
@@ -1,14 +1,13 @@
//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2).
//!
//! The shape is web/server.zig's: one `std.http.Server` per connection over our
//! own accept loop, fixed pre-allocated connection slots, a keep-alive loop per
//! connection that ends on `error.HttpConnectionClosing`, and the same shutdown
//! split — `deinit` shuts live connections down and drains, a canceled `serve`
//! cancels the connection group because HTTP keep-alive has no deadline of its
//! own. The difference is the transport: after the TCP accept, a certificate
//! generation is pinned (`CertStore.acquire`) and `ServerStream.accept` runs the
//! TLS handshake, and `std.http.Server` sits on the stream's plaintext
//! reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//! The shape is web/server.zig's: one `std.http.Server` per connection over the
//! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and
//! a keep-alive loop per connection that ends on
//! `error.HttpConnectionClosing`. The difference is the transport: after the TCP
//! accept, a certificate generation is pinned (`CertStore.acquire`) and
//! `ServerStream.accept` runs the TLS handshake through
//! `listener.handshakeStage`, and `std.http.Server` sits on the stream's
//! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//!
//! The handshake runs under the same race budget tcp_server applies to its
//! reads (ruling 3's rationale): a client that connects and never handshakes
@@ -33,12 +32,11 @@ const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const doh_client = @import("../upstream/doh_client.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const model = @import("../config/model.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.doh_server);
pub const dns_query_path = "/dns-query";
/// Ruling 5. Mbed TLS records the pointer, so the list must outlive every
@@ -55,10 +53,6 @@ const send_buffer_len = 4 * 1024;
pub const default_max_connections: u16 = 64;
/// How long the accept loop waits after an unexpected accept failure, so a
/// persistent one cannot turn the loop into a spin.
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };
pub const Options = struct {
@@ -71,18 +65,9 @@ pub const Options = struct {
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};
/// What DoH counts on top of `listener.CoreStats`.
pub const Stats = struct {
connections: std.atomic.Value(u64) = .init(0),
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
accept_errors: std.atomic.Value(u64) = .init(0),
tls_handshake_failures: std.atomic.Value(u64) = .init(0),
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
/// Every 4xx answered on `/dns-query` and every miss beside it: the
/// visibility counter for clients that speak, but speak wrongly.
bad_requests: std.atomic.Value(u64) = .init(0),
@@ -94,53 +79,28 @@ pub const Snapshot = struct {
rejected_at_shutdown: u64,
accept_errors: u64,
tls_handshake_failures: u64,
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: u64,
connection_errors: u64,
bad_requests: u64,
};
/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims
/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`.
const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor its own task is
/// about to close.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum { closing, canceled };
const Claim = union(enum) {
slot: usize,
at_capacity,
shutting_down,
};
pub const DohServer = struct {
/// Allocates the per-connection Mbed TLS context in `ServerStream.accept`.
gpa: Allocator,
core: listener.Core(Config),
handler: *handler.Handler,
certs: *cert_store.CertStore,
listener: net.Server,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`, set in the same critical section that shuts the live
/// connections down.
shutdown_begun: bool,
options: Options,
stats: Stats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The
/// two message buffers cannot shrink: a POST body and the reply both go up
/// to the 65535 bytes a DNS message can be.
pub const Conn = struct {
/// `ServerStream` plaintext buffers; `read_buf` doubles as the HTTP
/// head cap (see `recv_buffer_len`).
read_buf: [recv_buffer_len]u8,
write_buf: [send_buffer_len]u8,
/// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext
/// buffers belong to the core; its `read_buf` doubles as the HTTP head cap
/// (see `recv_buffer_len`).
pub const Payload = struct {
/// The decoded query: a POST body or a GET `dns` parameter.
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
@@ -148,15 +108,22 @@ pub const DohServer = struct {
/// serially, so one query uses it at a time.
scratch: handler.Scratch,
/// Valid between a successful `ServerStream.accept` and the
/// `close(gpa)` in `serveConn`'s defer.
/// `close(gpa)` in `serveOne`'s defer.
tls: tls_server.ServerStream,
stream: net.Stream,
peer: net.IpAddress,
/// Guarded by `DohServer.mutex`.
conn_state: ConnState,
};
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
const Config = struct {
pub const Owner = DohServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.doh_server);
pub const name = "doh";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen(
gpa: Allocator,
@@ -166,172 +133,75 @@ pub const DohServer = struct {
certs: *cert_store.CertStore,
options: Options,
) ListenError!DohServer {
std.debug.assert(options.max_connections > 0);
const conns = try gpa.alloc(Conn, options.max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| conn.conn_state = .free;
const listener = try listen_address.listen(io, .{ .reuse_address = true });
return .{
.gpa = gpa,
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.certs = certs,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options,
.stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
};
}
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const DohServer) net.IpAddress {
return self.listener.socket.address;
return self.core.boundAddress();
}
pub fn snapshotStats(self: *const DohServer) Snapshot {
const core = &self.core.stats;
return .{
.connections = self.stats.connections.load(.monotonic),
.rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = self.stats.accept_errors.load(.monotonic),
.connections = core.connections.load(.monotonic),
.rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic),
.accept_errors = core.accept_errors.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic),
.bad_requests = self.stats.bad_requests.load(.monotonic),
};
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *DohServer, io: std.Io) void {
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
var group: std.Io.Group = .init;
switch (self.acceptLoop(io, &group)) {
// `deinit` shut every live connection down before it published
// `.closing`, so each one is unblocked and finishing on its own.
// Awaiting them means a half-written response still goes out whole.
.closing => {
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
},
// Nothing has shut these connections down, and an idle keep-alive
// connection has no deadline of its own, so draining could wait
// forever. Cancel joins, so the slots are quiet by the time `serve`
// returns; the price is the one response that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
self.core.serve(io);
}
pub fn deinit(self: *DohServer, gpa: Allocator, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
// Shutting the listening socket down is the documented way to unblock a
// pending `accept`: it fails with `error.SocketNotListening`.
const listener: net.Stream = .{ .socket = self.listener.socket };
listener.shutdown(io, .both) catch |err| {
log.debug("doh listener shutdown failed: {t}", .{err});
};
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.listener.deinit(io);
gpa.free(self.conns);
pub fn deinit(self: *DohServer, io: std.Io) void {
self.core.deinit(io);
self.* = undefined;
}
fn acceptLoop(self: *DohServer, io: std.Io, group: *std.Io.Group) Stop {
while (self.run_state.load(.acquire) == .serving) {
const stream = self.listener.accept(io) catch |err| switch (err) {
error.Canceled => return .canceled,
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
log.debug("doh accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// See the module comment: no 503 without a handshake, so over
// capacity the stream is closed raw and the refusal counted.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
continue;
},
.shutting_down => {
bump(&self.stats.rejected_at_shutdown);
stream.close(io);
return .closing;
},
};
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.rejected_at_capacity);
self.finish(io, index);
continue;
},
};
bump(&self.stats.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *DohServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
/// One connection: pin, handshake, keep-alive loop, close_notify, release —
/// the ordering `listener.handshakeStage` documents. The core closes the
/// TCP stream after this returns.
fn serveOne(self: *DohServer, io: std.Io, index: usize) void {
const conn = &self.core.conns[index];
const stats = &self.core.stats;
const gpa = self.core.gpa;
// Pinned for the whole connection (ruling 6): a reload never frees the
// generation this stream handshook against.
const entry = self.certs.acquire(io);
defer self.certs.release(io, entry);
var handshook = false;
switch (race(io, self.options.idle_timeout, handshake, .{ self.gpa, conn, &entry.ctx, io, &handshook })) {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) {
.ok => {},
// The select can report the expiry or the cancellation after the
// handshake has in fact succeeded. The flag is written before the
// race joins its tasks, so a TLS context that exists is closed on
// every path, exactly once.
.canceled => {
if (handshook) conn.tls.close(self.gpa);
return;
},
.canceled => return,
// Milestone-16 ruling 9: a stalled handshake is refused like a broken
// one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
// wait below, so the two listeners export the same names for the
// same events.
.timed_out, .failed => {
if (handshook) conn.tls.close(self.gpa);
bump(&self.stats.tls_handshake_failures);
listener.bump(&self.stats.tls_handshake_failures);
return;
},
}
// Flushes, sends close_notify and frees the TLS context on every exit
// path below; `finish` closes the TCP stream afterwards.
defer conn.tls.close(self.gpa);
// path below; the core closes the TCP stream afterwards.
defer conn.payload.tls.close(gpa);
var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer());
var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer());
while (connection.reader.state == .ready) {
// Milestone-16 ruling 10: the wait for the next request head is the
@@ -339,10 +209,10 @@ pub const DohServer = struct {
// it runs under the same budget as the handshake. The body read and
// `handleRequest` below stay untimed.
var head: ReceiveHeadResult = error.ReadFailed;
switch (race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
.ok => {},
.timed_out => {
bump(&self.stats.idle_timeouts);
listener.bump(&stats.idle_timeouts);
return;
},
// Cancellation is shutdown; `.failed` here is only the wrapper
@@ -359,7 +229,7 @@ pub const DohServer = struct {
error.HttpRequestTruncated,
error.HttpHeadersInvalid,
=> {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
};
@@ -380,7 +250,7 @@ pub const DohServer = struct {
// The peer went away mid-response. Normal.
error.WriteFailed => return,
error.HttpExpectationFailed, error.ReadFailed => {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
};
@@ -392,6 +262,31 @@ pub const DohServer = struct {
}
}
/// The `listener.handshakeStage` stage: everything one mbedTLS handshake
/// needs, plus the close that undoes it.
const Handshake = struct {
conn: *Conn,
gpa: Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
pub fn accept(self: Handshake) anyerror!void {
const conn = self.conn;
try conn.payload.tls.accept(
self.gpa,
self.ctx,
self.io,
&conn.stream,
&conn.read_buf,
&conn.write_buf,
);
}
pub fn close(self: Handshake) void {
self.conn.payload.tls.close(self.gpa);
}
};
const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed };
/// What `serveConn`'s keep-alive loop does after the response went out.
@@ -444,7 +339,7 @@ pub const DohServer = struct {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
},
};
const query = decodeDnsValue(value, &conn.query) catch {
const query = decodeDnsValue(value, &conn.payload.query) catch {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
};
return self.answer(io, conn, request, query);
@@ -459,17 +354,17 @@ pub const DohServer = struct {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
};
const reader = try request.readerExpectContinue(&.{});
const got = reader.readSliceShort(&conn.query) catch return error.ReadFailed;
const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed;
// A full buffer is either a message of exactly the DNS maximum
// or a chunked body that keeps going; one probe byte decides.
if (got == conn.query.len) {
if (got == conn.payload.query.len) {
var probe: [1]u8 = undefined;
const extra = reader.readSliceShort(&probe) catch return error.ReadFailed;
if (extra != 0) {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
}
}
return self.answer(io, conn, request, conn.query[0..got]);
return self.answer(io, conn, request, conn.payload.query[0..got]);
},
else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep),
}
@@ -490,8 +385,8 @@ pub const DohServer = struct {
.tcp,
address.NetAddress.fromIp(conn.peer),
query,
&conn.reply,
&conn.scratch,
&conn.payload.reply,
&conn.payload.scratch,
);
switch (outcome) {
.drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false),
@@ -514,7 +409,7 @@ pub const DohServer = struct {
extra_headers: []const http.Header,
keep_alive: bool,
) error{ WriteFailed, HttpExpectationFailed }!Next {
bump(&self.stats.bad_requests);
listener.bump(&self.stats.bad_requests);
try request.respond(body, .{
.status = status,
.keep_alive = keep_alive,
@@ -525,73 +420,8 @@ pub const DohServer = struct {
// `connection: close` either way, and the loop must agree.
return if (keep_alive and request.head.keep_alive) .keep_open else .close;
}
fn claim(self: *DohServer, io: std.Io, stream: net.Stream) Claim {
// Uncancelable: this section takes no Io and never blocks on a peer, so
// losing the lock mid-update would leak a slot for nothing.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const outcome = decideClaim(self.conns, self.shutdown_begun);
switch (outcome) {
.slot => |index| {
self.conns[index].stream = stream;
self.conns[index].peer = stream.socket.address;
self.conns[index].conn_state = .active;
},
.at_capacity, .shutting_down => {},
}
return outcome;
}
fn finish(self: *DohServer, io: std.Io, index: usize) void {
const conn = &self.conns[index];
self.mutex.lockUncancelable(io);
conn.conn_state = .closing;
self.mutex.unlock(io);
// The socket is released even when this task is being torn down: the
// next cancelable call would otherwise skip the close.
const prev = io.swapCancelProtection(.blocked);
conn.stream.close(io);
_ = io.swapCancelProtection(prev);
self.mutex.lockUncancelable(io);
conn.conn_state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones under one
/// hold of the mutex, so no `claim` can slip between the two.
fn beginShutdown(self: *DohServer, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.conn_state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
log.debug("doh connection shutdown failed: {t}", .{err});
};
}
}
};
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
/// every race outcome whether `conn.tls` holds a context that must be closed.
fn handshake(
gpa: Allocator,
conn: *DohServer.Conn,
ctx: *tls_server.ServerContext,
io: std.Io,
handshook: *bool,
) anyerror!void {
try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf);
handshook.* = true;
}
const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request;
/// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an
@@ -655,85 +485,6 @@ fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 {
return dest[0..len];
}
/// The whole claim rule, without the mutex, so it is testable without a backend.
fn decideClaim(conns: []const DohServer.Conn, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
for (conns, 0..) |*conn, index| {
if (conn.conn_state == .free) return .{ .slot = index };
}
return .at_capacity;
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const RaceResult = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the budget and cancels the loser
/// (tcp_server's arrangement: no stream operation in 0.16.0 takes a timeout).
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) RaceResult {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// peer went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
/// The composition root's entry point: bind, serve, release. A bind failure is
/// warned and swallowed (ruling 1, the web precedent): DoH failing to come up
/// must not stop nxdns answering plain DNS.
pub fn serve(
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
certs: *cert_store.CertStore,
) void {
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
return;
};
var server: DohServer = DohServer.listen(gpa, io, bind_address, h, certs, .{}) catch |err| {
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return;
};
defer server.deinit(gpa, io);
log.info("doh listener on {f}", .{server.boundAddress()});
server.serve(io);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -749,41 +500,6 @@ const local_tables_mod = @import("local_tables.zig");
const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig");
fn testConns(count: usize) ![]DohServer.Conn {
const conns = try testing.allocator.alloc(DohServer.Conn, count);
for (conns) |*conn| conn.conn_state = .free;
return conns;
}
test "the connection pool hands out every slot once, then refuses" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
conns[0].conn_state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
conns[1].conn_state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
}
test "a closing slot is not reused until it is free" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].conn_state = .closing;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
conns[0].conn_state = .free;
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
}
test "shutdown outranks capacity and does not consume the slot" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
}
test "framesBody sees framing in either header and none in content-length: 0" {
try testing.expect(framesBody(.chunked, null));
try testing.expect(framesBody(.none, 4));
@@ -937,7 +653,7 @@ const Harness = struct {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
errdefer hx.server.deinit(testing.allocator, hio);
errdefer hx.server.deinit(hio);
hx.group = .init;
try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio });
@@ -945,7 +661,7 @@ const Harness = struct {
fn stop(hx: *Harness) void {
const hio = hx.threaded.io();
hx.server.deinit(testing.allocator, hio);
hx.server.deinit(hio);
hx.group.await(hio) catch |err| switch (err) {
error.Canceled => unreachable,
};
@@ -977,7 +693,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))
defer select.cancelDiscard();
try select.concurrent(.work, f, args);
try select.concurrent(.expiry, expire, .{ io, test_budget });
try select.concurrent(.expiry, listener.expire, .{ io, test_budget });
switch (try select.await()) {
.work => |result| return result,