milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
+97
-381
@@ -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,
|
||||
|
||||
+88
-426
@@ -1,12 +1,14 @@
|
||||
//! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream.
|
||||
//!
|
||||
//! This file mirrors `tcp_server.zig` — same slots, same claim rule, same
|
||||
//! shutdown paths, same idle race — with three differences:
|
||||
//! The slot pool, the accept loop and the shutdown protocol are
|
||||
//! `listener.Core`'s (milestone-18 ruling 1), the same ones tcp_server uses.
|
||||
//! What this file adds over TCP/53:
|
||||
//!
|
||||
//! - After the TCP accept, the certificate generation is pinned with
|
||||
//! `CertStore.acquire` and the mbedTLS handshake runs under the same race
|
||||
//! budget as every other per-connection operation, so a client that stalls
|
||||
//! mid-handshake cannot pin a connection slot.
|
||||
//! `CertStore.acquire` and the mbedTLS handshake runs through
|
||||
//! `listener.handshakeStage` under the same race budget as every other
|
||||
//! per-connection operation, so a client that stalls mid-handshake cannot pin
|
||||
//! a connection slot.
|
||||
//! - The framed-message loop reads and writes through
|
||||
//! `tls_server.ServerStream`, and closing the stream sends close_notify
|
||||
//! before the TCP close. A transport EOF without close_notify surfaces as a
|
||||
@@ -23,20 +25,15 @@ const std = @import("std");
|
||||
const address = @import("../platform/address.zig");
|
||||
const cert_store = @import("cert_store.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const listener = @import("listener.zig");
|
||||
const tls_server = @import("../platform/tls_server.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const log = std.log.scoped(.dot_server);
|
||||
|
||||
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
|
||||
/// record tail pass through here, while whole messages go straight to
|
||||
/// `Conn.query`/`Conn.reply`.
|
||||
/// `Payload.query`/`Payload.reply`.
|
||||
const stream_buffer_len = 1024;
|
||||
|
||||
/// 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 };
|
||||
|
||||
pub const Options = struct {
|
||||
max_connections: u16 = 64,
|
||||
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the
|
||||
@@ -44,16 +41,11 @@ pub const Options = struct {
|
||||
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
|
||||
};
|
||||
|
||||
/// What DoT counts on top of `listener.CoreStats`: handshakes that failed or
|
||||
/// outran the idle budget. `connections` in the core counts TCP connections
|
||||
/// accepted, whether or not the handshake succeeded.
|
||||
pub const Stats = struct {
|
||||
/// TCP connections accepted, whether or not the handshake succeeded.
|
||||
connections: std.atomic.Value(u64) = .init(0),
|
||||
/// Handshakes that failed or outran the idle budget.
|
||||
tls_handshake_failures: std.atomic.Value(u64) = .init(0),
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: 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),
|
||||
};
|
||||
|
||||
/// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup`
|
||||
@@ -65,62 +57,19 @@ pub const StatsSnapshot = struct {
|
||||
connection_errors: u64,
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches a connection
|
||||
/// slot after it is freed.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
|
||||
/// task is about to close: the transition to `.closing` happens under the mutex
|
||||
/// before the close, and `deinit` only touches `.active` slots.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
const Stop = enum {
|
||||
/// `deinit` published `.closing`. It has already shut every live connection
|
||||
/// down, so each one is unblocked and finishing on its own.
|
||||
closing,
|
||||
/// This task is being canceled. Nothing has touched the connections.
|
||||
canceled,
|
||||
};
|
||||
|
||||
/// What the accept loop does with a stream it has just accepted.
|
||||
const Claim = union(enum) {
|
||||
/// The stream owns `conns[index]`.
|
||||
slot: usize,
|
||||
/// Every slot is taken. The stream is closed and the loop continues.
|
||||
at_capacity,
|
||||
/// `deinit` has started. The stream is closed and the loop returns.
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
pub const DotServer = struct {
|
||||
server: std.Io.net.Server,
|
||||
core: listener.Core(Config),
|
||||
handler: *handler.Handler,
|
||||
certs: *cert_store.CertStore,
|
||||
/// Kept for the per-connection ssl context `ServerStream.accept`
|
||||
/// allocates and `close` frees.
|
||||
gpa: std.mem.Allocator,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
|
||||
/// shuts the active connections down, so a stream that arrives after that
|
||||
/// scan can never claim a slot the scan will not visit again.
|
||||
shutdown_begun: bool,
|
||||
options: Options,
|
||||
stats: Stats,
|
||||
state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the
|
||||
/// `ServerStream` bookkeeping — so the default 64 connections stay inside
|
||||
/// the PLAN §18 budget.
|
||||
pub const Conn = struct {
|
||||
pub const Payload = struct {
|
||||
query: [transport.max_message_len]u8,
|
||||
reply: [transport.max_message_len]u8,
|
||||
read_buf: [stream_buffer_len]u8,
|
||||
write_buf: [stream_buffer_len]u8,
|
||||
/// The handler's per-query working memory. It belongs to the slot so
|
||||
/// that answering a message allocates nothing, and a connection is
|
||||
/// answered serially, so one query uses it at a time.
|
||||
@@ -128,16 +77,20 @@ pub const DotServer = struct {
|
||||
/// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it,
|
||||
/// and the slot never moves.
|
||||
tls: tls_server.ServerStream,
|
||||
stream: std.Io.net.Stream,
|
||||
/// The client, read off the accepted socket once at claim time: every
|
||||
/// message on this connection comes from the same peer, and the handler
|
||||
/// needs it for rate limiting, groups and the query log.
|
||||
peer: std.Io.net.IpAddress,
|
||||
/// Guarded by `DotServer.mutex`.
|
||||
state: ConnState,
|
||||
};
|
||||
|
||||
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory};
|
||||
const Config = struct {
|
||||
pub const Owner = DotServer;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const read_buffer_len = stream_buffer_len;
|
||||
pub const write_buffer_len = stream_buffer_len;
|
||||
pub const log = std.log.scoped(.dot_server);
|
||||
pub const name = "dot";
|
||||
};
|
||||
|
||||
pub const Conn = listener.Core(Config).Conn;
|
||||
pub const ListenError = listener.Core(Config).ListenError;
|
||||
|
||||
pub fn listen(
|
||||
gpa: std.mem.Allocator,
|
||||
@@ -147,148 +100,47 @@ pub const DotServer = struct {
|
||||
certs: *cert_store.CertStore,
|
||||
options: Options,
|
||||
) ListenError!DotServer {
|
||||
std.debug.assert(options.max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, options.max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
|
||||
const local = listen_address;
|
||||
const server = try local.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.server = server,
|
||||
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
|
||||
.handler = h,
|
||||
.certs = certs,
|
||||
.gpa = gpa,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.options = options,
|
||||
.stats = .{},
|
||||
.state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress {
|
||||
return self.server.socket.address;
|
||||
return self.core.boundAddress();
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *DotServer, io: std.Io) void {
|
||||
if (self.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 already unblocked and ending on its
|
||||
// own. Awaiting them means a half-written reply still goes out
|
||||
// whole, and the wait is bounded by the shutdown, not the client.
|
||||
.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: `deinit` cannot run
|
||||
// until this task returns, and RFC 7766 lets a client hold a
|
||||
// connection open forever by asking again inside the idle budget.
|
||||
// Draining here would therefore let one client stall the whole
|
||||
// process's shutdown for as long as it likes. `cancel` requests
|
||||
// cancellation and joins, so the slots are still quiet — and the
|
||||
// buffers still unreferenced — by the time `serve` returns; the
|
||||
// price is the one reply that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
self.core.serve(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DotServer, io: std.Io) void {
|
||||
const was_serving = self.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: std.Io.net.Stream = .{ .socket = self.server.socket };
|
||||
listener.shutdown(io, .both) catch |err| {
|
||||
log.debug("dot listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// A live connection is blocked in a read that only the idle budget
|
||||
// would end, which is seconds away. Shutting each one down bounds this,
|
||||
// and the same critical section closes the door on new connections.
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.server.deinit(io);
|
||||
self.gpa.free(self.conns);
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
pub fn snapshotStats(self: *const DotServer) StatsSnapshot {
|
||||
const core = &self.core.stats;
|
||||
return .{
|
||||
.connections = self.stats.connections.load(.monotonic),
|
||||
.connections = core.connections.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),
|
||||
};
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *DotServer, io: std.Io, group: *std.Io.Group) Stop {
|
||||
while (self.state.load(.acquire) == .serving) {
|
||||
const stream = self.server.accept(io) catch |err| switch (err) {
|
||||
error.Canceled => return .canceled,
|
||||
// `deinit` shuts the listening socket down to unblock exactly
|
||||
// this call, so it is the shutdown path arriving early.
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("dot accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
// Refusing now is honest; a queue would only hide the overload.
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
stream.close(io);
|
||||
continue;
|
||||
},
|
||||
// `deinit` will not see this stream in any slot, so serving it
|
||||
// would hold `deinit` for the whole idle budget.
|
||||
.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: *DotServer, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
|
||||
const conn = &self.conns[index];
|
||||
/// One connection: pin, handshake, serve, close_notify, release — the
|
||||
/// ordering `listener.handshakeStage` documents. The core closes the TCP
|
||||
/// stream after this returns.
|
||||
fn serveOne(self: *DotServer, io: std.Io, index: usize) void {
|
||||
const conn = &self.core.conns[index];
|
||||
const stats = &self.core.stats;
|
||||
const gpa = self.core.gpa;
|
||||
const budget = self.options.idle_timeout;
|
||||
|
||||
// Pins the certificate generation for the whole connection: a reload
|
||||
@@ -297,46 +149,38 @@ pub const DotServer = struct {
|
||||
const entry = self.certs.acquire(io);
|
||||
defer self.certs.release(io, entry);
|
||||
|
||||
var handshook = false;
|
||||
switch (race(io, budget, handshake, .{ conn, self.gpa, &entry.ctx, io, &handshook })) {
|
||||
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
|
||||
switch (listener.handshakeStage(io, budget, 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,
|
||||
// A stalled handshake is refused like a broken one: it must not
|
||||
// pin a connection slot for longer than the idle budget.
|
||||
.timed_out, .failed => {
|
||||
if (handshook) conn.tls.close(self.gpa);
|
||||
bump(&self.stats.tls_handshake_failures);
|
||||
listener.bump(&self.stats.tls_handshake_failures);
|
||||
return;
|
||||
},
|
||||
}
|
||||
// Sends close_notify and frees the ssl context; `finish` closes the
|
||||
// Sends close_notify and frees the ssl context; the core closes the
|
||||
// TCP stream afterwards.
|
||||
defer conn.tls.close(self.gpa);
|
||||
defer conn.payload.tls.close(gpa);
|
||||
|
||||
const reader = conn.tls.reader();
|
||||
const writer = conn.tls.writer();
|
||||
const reader = conn.payload.tls.reader();
|
||||
const writer = conn.payload.tls.writer();
|
||||
|
||||
while (true) {
|
||||
var prefix: [transport.prefix_len]u8 = undefined;
|
||||
var got: usize = 0;
|
||||
switch (race(io, budget, readPrefix, .{ reader, &prefix, &got })) {
|
||||
switch (listener.race(io, budget, listener.readPrefix, .{ reader, &prefix, &got })) {
|
||||
.ok => {},
|
||||
.timed_out => {
|
||||
bump(&self.stats.idle_timeouts);
|
||||
listener.bump(&stats.idle_timeouts);
|
||||
return;
|
||||
},
|
||||
.canceled => return,
|
||||
// A transport EOF without close_notify lands here too: the
|
||||
// stream reads it as a truncation, never as a clean end.
|
||||
.failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
@@ -345,7 +189,7 @@ pub const DotServer = struct {
|
||||
// asking, which is the normal end of a connection, not a failure.
|
||||
if (got == 0) return;
|
||||
if (got != transport.prefix_len) {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,16 +197,16 @@ pub const DotServer = struct {
|
||||
// the prefix is a u16 so it can never exceed `max_message_len`.
|
||||
const len = transport.parsePrefix(prefix);
|
||||
if (len == 0) {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (race(io, budget, readBody, .{ reader, conn.query[0..len] })) {
|
||||
switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
// A half-sent message is a broken peer, not an idle one.
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
@@ -371,9 +215,9 @@ pub const DotServer = struct {
|
||||
io,
|
||||
.tcp,
|
||||
address.NetAddress.fromIp(conn.peer),
|
||||
conn.query[0..len],
|
||||
&conn.reply,
|
||||
&conn.scratch,
|
||||
conn.payload.query[0..len],
|
||||
&conn.payload.reply,
|
||||
&conn.payload.scratch,
|
||||
);
|
||||
const bytes = switch (outcome) {
|
||||
// There is no framing for "no answer", so the connection ends.
|
||||
@@ -382,162 +226,43 @@ pub const DotServer = struct {
|
||||
};
|
||||
|
||||
const out = transport.framePrefix(@intCast(bytes.len));
|
||||
switch (race(io, budget, writeReply, .{ writer, &out, bytes })) {
|
||||
switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(self: *DotServer, io: std.Io, stream: std.Io.net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// it cannot deadlock, and losing the lock mid-update would leak a slot.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
/// The `listener.handshakeStage` stage: everything one mbedTLS handshake
|
||||
/// needs, plus the close that undoes it.
|
||||
const Handshake = struct {
|
||||
conn: *Conn,
|
||||
gpa: std.mem.Allocator,
|
||||
ctx: *tls_server.ServerContext,
|
||||
io: std.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].state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
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,
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *DotServer, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
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.state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones. Both
|
||||
/// happen under one hold of the mutex: a `claim` that runs before this
|
||||
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
|
||||
/// runs after it reads `shutdown_begun` and takes no slot at all.
|
||||
fn beginShutdown(self: *DotServer, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
log.debug("dot connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
pub fn close(self: Handshake) void {
|
||||
self.conn.payload.tls.close(self.gpa);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
fn firstFree(conns: []const DotServer.Conn) ?usize {
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
|
||||
/// slot is still refused once `deinit` has passed the connections.
|
||||
fn decideClaim(conns: []const DotServer.Conn, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
const index = firstFree(conns) orelse return .at_capacity;
|
||||
return .{ .slot = index };
|
||||
}
|
||||
|
||||
const Outcome = union(enum) {
|
||||
op: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
const Result = enum { ok, timed_out, failed, canceled };
|
||||
|
||||
/// Runs one connection operation against the idle budget and cancels the loser.
|
||||
fn race(
|
||||
io: std.Io,
|
||||
budget: std.Io.Clock.Duration,
|
||||
comptime f: anytype,
|
||||
args: std.meta.ArgsTuple(@TypeOf(f)),
|
||||
) Result {
|
||||
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
|
||||
// client 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);
|
||||
}
|
||||
|
||||
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
|
||||
/// the losing race paths whether a TLS context exists that must be closed.
|
||||
fn handshake(
|
||||
conn: *DotServer.Conn,
|
||||
gpa: std.mem.Allocator,
|
||||
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;
|
||||
}
|
||||
|
||||
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
|
||||
/// that sent close_notify between messages, and only a partial prefix is an
|
||||
/// error.
|
||||
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
out_len.* = try reader.readSliceShort(buf);
|
||||
}
|
||||
|
||||
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
|
||||
return reader.readSliceAll(buf);
|
||||
}
|
||||
|
||||
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(bytes);
|
||||
try writer.flush();
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -550,78 +275,15 @@ const packet = @import("../dns/packet.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
fn testConns(count: usize) ![]DotServer.Conn {
|
||||
const conns = try testing.allocator.alloc(DotServer.Conn, count);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once" {
|
||||
const conns = try testConns(3);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (0..conns.len) |expected| {
|
||||
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
|
||||
try testing.expectEqual(expected, index);
|
||||
conns[index].state = .active;
|
||||
}
|
||||
}
|
||||
|
||||
test "a full connection pool refuses instead of growing" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (conns) |*conn| conn.state = .active;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a closing slot is not reused until it is free" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
conns[1].state = .closing;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
|
||||
conns[1].state = .free;
|
||||
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a claim takes the first free slot before shutdown" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "a claim after shutdown is refused even with a free slot" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
|
||||
// The refusal must not consume the slot: `deinit` frees it, nothing else.
|
||||
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
}
|
||||
|
||||
test "snapshotStats reports the ruling-10 counters" {
|
||||
var server: DotServer = undefined;
|
||||
server.core.stats = .{};
|
||||
server.stats = .{};
|
||||
|
||||
bump(&server.stats.connections);
|
||||
bump(&server.stats.connections);
|
||||
bump(&server.stats.tls_handshake_failures);
|
||||
bump(&server.stats.connection_errors);
|
||||
listener.bump(&server.core.stats.connections);
|
||||
listener.bump(&server.core.stats.connections);
|
||||
listener.bump(&server.stats.tls_handshake_failures);
|
||||
listener.bump(&server.core.stats.connection_errors);
|
||||
|
||||
const snapshot = server.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 2), snapshot.connections);
|
||||
@@ -743,7 +405,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,
|
||||
@@ -953,7 +615,7 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
|
||||
try group.concurrent(io, DotServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address });
|
||||
try waitForCounter(io, &server.stats.connection_errors, 1);
|
||||
try waitForCounter(io, &server.core.stats.connection_errors, 1);
|
||||
|
||||
const stats = server.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 1), stats.connections);
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! The listener core the four stream listeners share.
|
||||
//!
|
||||
//! `tcp_server.zig`, `dot_server.zig`, `doh_server.zig` and `web/server.zig`
|
||||
//! are the same machine wearing four transports: a fixed pre-allocated slot
|
||||
//! pool, a claim rule where shutdown outranks capacity, an accept loop with one
|
||||
//! error mapping, a mutex-ordered close dance, and — for everything that waits
|
||||
//! on a peer — one select race against a budget. Milestone 18 ruling 1 puts
|
||||
//! that machine here once, so a fix to it lands once.
|
||||
//!
|
||||
//! What stays outside: the per-connection serve function, the connection
|
||||
//! payload (buffers, TLS context, arenas), and the TLS lifecycle. A TLS
|
||||
//! listener's certificate pin, handshake, close_notify and release form one
|
||||
//! ordered sequence that the Core has no business owning; what it does own is
|
||||
//! `handshakeStage`, the exactly-once `handshook` flag whose absence was a real
|
||||
//! leak in doh_server before the milestone-10 review hand-ported the fix.
|
||||
//!
|
||||
//! Shutdown is the one part worth reading twice. `deinit` publishes `.closing`,
|
||||
//! shuts the listening socket down (which unblocks `accept` with
|
||||
//! `error.SocketNotListening`) and shuts every `.active` connection down in the
|
||||
//! same critical section that closes the door on new ones; `serve` then drains
|
||||
//! its connection group so a half-written reply still goes out whole. A
|
||||
//! *canceled* `serve` cannot drain, because a keep-alive peer has no deadline
|
||||
//! of its own and one chatty client would stall the whole process's shutdown;
|
||||
//! it cancels the group instead, at the cost of the one reply mid-write.
|
||||
//! Either way `serve` returns only once no task can still touch a slot.
|
||||
|
||||
const std = @import("std");
|
||||
const net = std.Io.net;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches a connection
|
||||
/// slot after it is freed.
|
||||
pub const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
|
||||
/// task is about to close: the transition to `.closing` happens under the mutex
|
||||
/// before the close, and `deinit` only touches `.active` slots.
|
||||
pub const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
pub const Stop = enum {
|
||||
/// `deinit` published `.closing`. It has already shut every live connection
|
||||
/// down, so each one is unblocked and finishing on its own.
|
||||
closing,
|
||||
/// This task is being canceled. Nothing has touched the connections.
|
||||
canceled,
|
||||
};
|
||||
|
||||
/// What the accept loop does with a stream it has just accepted.
|
||||
pub const Claim = union(enum) {
|
||||
/// The stream owns `conns[index]`.
|
||||
slot: usize,
|
||||
/// Every slot is taken. The stream is refused and the loop continues.
|
||||
at_capacity,
|
||||
/// `deinit` has started. The stream is closed and the loop returns.
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
/// How long the accept loop waits after an unexpected accept failure, so a
|
||||
/// persistent one cannot turn the loop into a spin.
|
||||
pub const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
|
||||
|
||||
pub fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
/// The counters every listener keeps. Listener-specific ones —
|
||||
/// `tls_handshake_failures`, `bad_requests`, `requests` — live beside this in
|
||||
/// the owning listener, and each listener's exported `Snapshot` stays a flat
|
||||
/// hand-written struct so `/metrics` output does not depend on this layout.
|
||||
///
|
||||
/// `idle_timeouts` is bumped by the three DNS listeners; the web listener has
|
||||
/// no idle race (its port is LAN-facing and the cancel path bounds shutdown),
|
||||
/// so its copy stays zero and it exports no family at all.
|
||||
pub const CoreStats = 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),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the race harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const Outcome = union(enum) {
|
||||
op: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
pub const Result = enum { ok, timed_out, failed, canceled };
|
||||
|
||||
/// Runs one connection operation against a budget and cancels the loser. No
|
||||
/// stream read or write in 0.16.0 accepts a timeout, so every wait on a peer
|
||||
/// that owes nxdns bytes goes through here.
|
||||
pub fn race(
|
||||
io: std.Io,
|
||||
budget: std.Io.Clock.Duration,
|
||||
comptime f: anytype,
|
||||
args: std.meta.ArgsTuple(@TypeOf(f)),
|
||||
) Result {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return budget.sleep(io);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the TLS handshake stage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs a TLS handshake under `budget` and owns the exactly-once cleanup of the
|
||||
/// context it may have created.
|
||||
///
|
||||
/// `stage` is anything with `accept(self) anyerror!void` and `close(self) void`.
|
||||
/// The select can report the expiry or the cancellation *after* `accept` has in
|
||||
/// fact succeeded, so a context that exists must be closed on every losing path
|
||||
/// and on no other: the flag below is written before the race joins its tasks,
|
||||
/// which is what makes "exactly once" true. Getting this wrong leaks one
|
||||
/// mbedTLS ssl context per stalled handshake, which is what doh_server did
|
||||
/// until the milestone-10 review hand-ported dot_server's fix — the duplication
|
||||
/// this helper exists to end.
|
||||
///
|
||||
/// The caller's required ordering, which stays in the caller because the
|
||||
/// certificate pin and the plaintext close are the listener's own business:
|
||||
///
|
||||
/// 1. pin the certificate generation (`CertStore.acquire`, released on exit),
|
||||
/// 2. call `handshakeStage`,
|
||||
/// 3. on `.ok` only: serve the connection,
|
||||
/// 4. close the TLS stream (close_notify + free the context),
|
||||
/// 5. release the pin, then let the slot's `finish` close the TCP stream.
|
||||
///
|
||||
/// On any result other than `.ok` this function has already done step 4 for the
|
||||
/// caller, and the caller must not repeat it.
|
||||
pub fn handshakeStage(io: std.Io, budget: std.Io.Clock.Duration, stage: anytype) Result {
|
||||
const Stage = @TypeOf(stage);
|
||||
const run = struct {
|
||||
fn accept(s: Stage, handshook: *bool) anyerror!void {
|
||||
try s.accept();
|
||||
handshook.* = true;
|
||||
}
|
||||
}.accept;
|
||||
|
||||
var handshook = false;
|
||||
const result = race(io, budget, run, .{ stage, &handshook });
|
||||
if (result != .ok and handshook) stage.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the framed-message helpers (RFC 1035 §4.2.2; tcp and dot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
|
||||
/// that closed cleanly between messages, and only a partial prefix is an error.
|
||||
pub fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
out_len.* = try reader.readSliceShort(buf);
|
||||
}
|
||||
|
||||
pub fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
|
||||
return reader.readSliceAll(buf);
|
||||
}
|
||||
|
||||
pub fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(bytes);
|
||||
try writer.flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the claim rule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
/// `conns` is any slice whose element has a `state: ConnState`.
|
||||
pub fn firstFree(conns: anytype) ?usize {
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
|
||||
/// slot is still refused once `deinit` has passed the connections.
|
||||
pub fn decideClaim(conns: anytype, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
const index = firstFree(conns) orelse return .at_capacity;
|
||||
return .{ .slot = index };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The slot pool, the accept loop and the shutdown protocol, parameterized over
|
||||
/// the four things that genuinely differ between listeners.
|
||||
///
|
||||
/// `Cfg` declares:
|
||||
///
|
||||
/// - `Owner: type` — the listener struct that embeds this core in a field
|
||||
/// named `core`. The accept loop recovers it with `@fieldParentPtr`, so an
|
||||
/// owner must not move after `listen`.
|
||||
/// - `ConnPayload: type` — the rest of one slot: message buffers, a TLS
|
||||
/// context, a per-request arena. Never touched here.
|
||||
/// - `serveConn: fn (*Owner, std.Io, usize) void` — one whole connection. The
|
||||
/// core spawns it, and closes the slot when it returns.
|
||||
/// - `read_buffer_len` / `write_buffer_len` — the stream staging buffers, which
|
||||
/// every listener has and sizes differently.
|
||||
/// - `log` — the owner's `std.log` scope, and `name` — the two or three letters
|
||||
/// its messages already start with, so the log text does not change.
|
||||
///
|
||||
/// Optional, absent for most listeners:
|
||||
///
|
||||
/// - `refuse: fn (std.Io, net.Stream) void` — what an over-capacity accept does
|
||||
/// with the stream. The default closes it, which is the only honest answer a
|
||||
/// DNS listener can give; the web listener answers 503 first.
|
||||
/// - `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.
|
||||
pub fn Core(comptime Cfg: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
gpa: Allocator,
|
||||
listener: net.Server,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`. `deinit` sets it in the same critical section
|
||||
/// that shuts the active connections down, so a stream that arrives
|
||||
/// after that scan can never claim a slot the scan will not visit again.
|
||||
shutdown_begun: bool,
|
||||
stats: CoreStats,
|
||||
run_state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
pub const Conn = struct {
|
||||
/// The stream staging buffers. For the plaintext listeners these
|
||||
/// feed the socket reader and writer; for the TLS ones they are the
|
||||
/// `ServerStream` plaintext buffers.
|
||||
read_buf: [Cfg.read_buffer_len]u8,
|
||||
write_buf: [Cfg.write_buffer_len]u8,
|
||||
payload: Cfg.ConnPayload,
|
||||
stream: net.Stream,
|
||||
/// The client, read off the accepted socket once at claim time:
|
||||
/// every message on this connection comes from the same peer.
|
||||
peer: net.IpAddress,
|
||||
/// Guarded by `Core.mutex`.
|
||||
state: ConnState,
|
||||
};
|
||||
|
||||
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
|
||||
|
||||
pub fn listen(
|
||||
gpa: Allocator,
|
||||
io: std.Io,
|
||||
listen_address: net.IpAddress,
|
||||
max_connections: u16,
|
||||
) ListenError!Self {
|
||||
std.debug.assert(max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| {
|
||||
conn.state = .free;
|
||||
if (@hasDecl(Cfg, "initPayload")) Cfg.initPayload(&conn.payload, gpa);
|
||||
}
|
||||
|
||||
const listener = try listen_address.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.gpa = gpa,
|
||||
.listener = listener,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.stats = .{},
|
||||
.run_state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self, 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 stream: net.Stream = .{ .socket = self.listener.socket };
|
||||
stream.shutdown(io, .both) catch |err| {
|
||||
Cfg.log.debug(Cfg.name ++ " listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// A live connection is blocked in a read that only its own budget
|
||||
// would end, which is seconds away. Shutting each one down bounds
|
||||
// this, and the same critical section closes the door on new ones.
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.listener.deinit(io);
|
||||
if (@hasDecl(Cfg, "deinitPayload")) {
|
||||
for (self.conns) |*conn| Cfg.deinitPayload(&conn.payload);
|
||||
}
|
||||
self.gpa.free(self.conns);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const Self) net.IpAddress {
|
||||
return self.listener.socket.address;
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *Self, 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 already unblocked and ending on
|
||||
// its own. Awaiting them means a half-written reply still goes
|
||||
// out whole, and the wait is bounded by the shutdown, not the
|
||||
// peer.
|
||||
.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: `deinit` cannot run
|
||||
// until this task returns, and a peer may hold a connection
|
||||
// open indefinitely, so draining would let one client stall the
|
||||
// whole process's shutdown. `cancel` requests cancellation and
|
||||
// joins, so the slots are quiet — and the buffers still
|
||||
// unreferenced — by the time `serve` returns; the price is the
|
||||
// one reply that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
}
|
||||
|
||||
/// The listener that embeds this core. Valid because the core is a
|
||||
/// field of it and neither may move after `listen`.
|
||||
pub fn owner(self: *Self) *Cfg.Owner {
|
||||
return @alignCast(@fieldParentPtr("core", self));
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *Self, 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,
|
||||
// `deinit` shuts the listening socket down to unblock
|
||||
// exactly this call, so it is the shutdown path arriving
|
||||
// early.
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
Cfg.log.debug(Cfg.name ++ " accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
// Refusing now is honest; a queue would only hide the
|
||||
// overload.
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
if (@hasDecl(Cfg, "refuse")) Cfg.refuse(io, stream) else stream.close(io);
|
||||
continue;
|
||||
},
|
||||
// `deinit` will not see this stream in any slot, so serving
|
||||
// it would hold `deinit` for a whole idle budget.
|
||||
.shutting_down => {
|
||||
bump(&self.stats.rejected_at_shutdown);
|
||||
stream.close(io);
|
||||
return .closing;
|
||||
},
|
||||
};
|
||||
|
||||
group.concurrent(io, runConn, .{ 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;
|
||||
}
|
||||
|
||||
/// One connection task: the listener's own logic, then the slot close.
|
||||
/// Every early return inside `Cfg.serveConn` — and its own defers, such
|
||||
/// as a TLS close_notify — runs before the TCP stream is closed here.
|
||||
fn runConn(self: *Self, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
Cfg.serveConn(self.owner(), io, index);
|
||||
}
|
||||
|
||||
fn claim(self: *Self, io: std.Io, stream: net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a
|
||||
// peer, so it cannot deadlock, and losing the lock mid-update would
|
||||
// leak a slot.
|
||||
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].state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *Self, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
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.state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones. Both
|
||||
/// happen under one hold of the mutex: a `claim` that runs before this
|
||||
/// leaves an `.active` slot the loop below shuts down, and a `claim`
|
||||
/// that runs after it reads `shutdown_begun` and takes no slot at all.
|
||||
fn beginShutdown(self: *Self, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
Cfg.log.debug(Cfg.name ++ " connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The claim rule is the whole of the shared state machine that can be tested
|
||||
// without a backend, and it reads nothing but `state`, so these tests use a
|
||||
// bare slot instead of instantiating a `Core`. They replace six copies that
|
||||
// lived in tcp_server.zig and dot_server.zig and three more between
|
||||
// doh_server.zig and web/server.zig.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const TestSlot = struct { state: ConnState };
|
||||
|
||||
fn testConns(count: usize) ![]TestSlot {
|
||||
const conns = try testing.allocator.alloc(TestSlot, count);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once" {
|
||||
const conns = try testConns(3);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (0..conns.len) |expected| {
|
||||
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
|
||||
try testing.expectEqual(expected, index);
|
||||
conns[index].state = .active;
|
||||
}
|
||||
}
|
||||
|
||||
test "a full connection pool refuses instead of growing" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (conns) |*conn| conn.state = .active;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
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(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
conns[1].state = .closing;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
|
||||
conns[1].state = .free;
|
||||
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "a claim takes the first free slot before shutdown" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "a claim after shutdown is refused even with a free slot" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
|
||||
// The refusal must not consume the slot: `deinit` frees it, nothing else.
|
||||
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
}
|
||||
@@ -310,6 +310,6 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
|
||||
try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic));
|
||||
|
||||
udp.deinit(gpa, io);
|
||||
tcp.deinit(gpa, io);
|
||||
tcp.deinit(io);
|
||||
group.cancel(io);
|
||||
}
|
||||
|
||||
+56
-415
@@ -5,67 +5,43 @@
|
||||
//! implemented here: a connection is answered serially until the client closes
|
||||
//! it or the idle budget runs out.
|
||||
//!
|
||||
//! Connection slots are fixed and pre-allocated. Over capacity the listener
|
||||
//! closes the new stream immediately and counts it; it never queues, and it
|
||||
//! never allocates per connection.
|
||||
//! The slot pool, the accept loop and the shutdown protocol are
|
||||
//! `listener.Core`'s (milestone-18 ruling 1); this file is the per-connection
|
||||
//! loop and nothing else. Connection slots are fixed and pre-allocated. Over
|
||||
//! capacity the listener closes the new stream immediately and counts it; it
|
||||
//! never queues, and it never allocates per connection.
|
||||
//!
|
||||
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
|
||||
//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and
|
||||
//! the loser is canceled.
|
||||
//!
|
||||
//! Shutdown takes one of two paths, and they end the live connections
|
||||
//! differently on purpose:
|
||||
//!
|
||||
//! - `deinit` shuts every active stream down first, so the connections unblock
|
||||
//! and finish by themselves. `serve` then drains them, and a reply that was
|
||||
//! half written still goes out whole.
|
||||
//! - A canceled `serve` cannot drain. `deinit` is what would shut the streams
|
||||
//! down, and it cannot run until `serve` returns — the composition root
|
||||
//! cancels its task group before it releases anything (app.zig). Meanwhile
|
||||
//! RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by
|
||||
//! asking again inside the idle budget, so draining would let one chatty
|
||||
//! client stall the whole process's shutdown. The connections are canceled
|
||||
//! instead, at the cost of the one reply that was mid-write.
|
||||
//!
|
||||
//! Either way `serve` returns only once no task can still touch a slot.
|
||||
//! operation is raced against `Options.idle_timeout` through `listener.race`
|
||||
//! and the loser is canceled.
|
||||
|
||||
const std = @import("std");
|
||||
const address = @import("../platform/address.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const listener = @import("listener.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const log = std.log.scoped(.tcp_server);
|
||||
|
||||
/// The stream buffers only stage the framing bytes. A message longer than this
|
||||
/// is read straight into `Conn.query` and written straight from `Conn.reply`,
|
||||
/// so making them larger would buy nothing.
|
||||
const stream_buffer_len = 1024;
|
||||
|
||||
/// 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 };
|
||||
|
||||
pub const Options = struct {
|
||||
max_connections: u16 = 64,
|
||||
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance.
|
||||
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
|
||||
};
|
||||
|
||||
pub const Stats = struct {
|
||||
accepted: 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),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
/// TCP/53 keeps no counter of its own: the shared six are exactly what it
|
||||
/// counts.
|
||||
pub const Stats = listener.CoreStats;
|
||||
|
||||
/// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the
|
||||
/// `nxdns_tcp_server_*` families. Every counter is exported, including the
|
||||
/// two refusals: a listener that turns clients away at capacity is the thing an
|
||||
/// operator most needs to see, and the module doc promises it is counted.
|
||||
pub const Snapshot = struct {
|
||||
accepted: u64,
|
||||
connections: u64,
|
||||
rejected_at_capacity: u64,
|
||||
rejected_at_shutdown: u64,
|
||||
accept_errors: u64,
|
||||
@@ -73,73 +49,36 @@ pub const Snapshot = struct {
|
||||
idle_timeouts: u64,
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches a connection
|
||||
/// slot after it is freed.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
|
||||
/// task is about to close: the transition to `.closing` happens under the mutex
|
||||
/// before the close, and `deinit` only touches `.active` slots.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
const Stop = enum {
|
||||
/// `deinit` published `.closing`. It has already shut every live connection
|
||||
/// down, so each one is unblocked and finishing on its own.
|
||||
closing,
|
||||
/// This task is being canceled. Nothing has touched the connections.
|
||||
canceled,
|
||||
};
|
||||
|
||||
/// What the accept loop does with a stream it has just accepted.
|
||||
const Claim = union(enum) {
|
||||
/// The stream owns `conns[index]`.
|
||||
slot: usize,
|
||||
/// Every slot is taken. The stream is closed and the loop continues.
|
||||
at_capacity,
|
||||
/// `deinit` has started. The stream is closed and the loop returns.
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
pub const TcpServer = struct {
|
||||
server: std.Io.net.Server,
|
||||
core: listener.Core(Config),
|
||||
handler: *handler.Handler,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
|
||||
/// shuts the active connections down, so a stream that arrives after that
|
||||
/// scan can never claim a slot the scan will not visit again.
|
||||
shutdown_begun: bool,
|
||||
options: Options,
|
||||
stats: Stats,
|
||||
state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which
|
||||
/// is inside the PLAN §18 budget. The two message buffers cannot be shared
|
||||
/// or shrunk: the handler holds the query while the reply is built, and
|
||||
/// both ceilings are the 65535 bytes the length prefix can express.
|
||||
pub const Conn = struct {
|
||||
pub const Payload = struct {
|
||||
query: [transport.max_message_len]u8,
|
||||
reply: [transport.max_message_len]u8,
|
||||
read_buf: [stream_buffer_len]u8,
|
||||
write_buf: [stream_buffer_len]u8,
|
||||
/// The handler's per-query working memory. It belongs to the slot so
|
||||
/// that answering a message allocates nothing, and a connection is
|
||||
/// answered serially, so one query uses it at a time.
|
||||
scratch: handler.Scratch,
|
||||
stream: std.Io.net.Stream,
|
||||
/// The client, read off the accepted socket once at claim time: every
|
||||
/// message on this connection comes from the same peer, and the handler
|
||||
/// needs it for rate limiting, groups and the query log.
|
||||
peer: std.Io.net.IpAddress,
|
||||
/// Guarded by `TcpServer.mutex`.
|
||||
state: ConnState,
|
||||
};
|
||||
|
||||
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory};
|
||||
const Config = struct {
|
||||
pub const Owner = TcpServer;
|
||||
pub const ConnPayload = Payload;
|
||||
pub const serveConn = serveOne;
|
||||
pub const read_buffer_len = stream_buffer_len;
|
||||
pub const write_buffer_len = stream_buffer_len;
|
||||
pub const log = std.log.scoped(.tcp_server);
|
||||
pub const name = "tcp";
|
||||
};
|
||||
|
||||
pub const Conn = listener.Core(Config).Conn;
|
||||
pub const ListenError = listener.Core(Config).ListenError;
|
||||
|
||||
pub fn listen(
|
||||
gpa: std.mem.Allocator,
|
||||
@@ -148,151 +87,49 @@ pub const TcpServer = struct {
|
||||
h: *handler.Handler,
|
||||
options: Options,
|
||||
) ListenError!TcpServer {
|
||||
std.debug.assert(options.max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, options.max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
|
||||
const local = listen_address;
|
||||
const server = try local.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.server = server,
|
||||
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
|
||||
.handler = h,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.options = options,
|
||||
.stats = .{},
|
||||
.state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress {
|
||||
return self.server.socket.address;
|
||||
return self.core.boundAddress();
|
||||
}
|
||||
|
||||
/// The counters, read one at a time. A scrape that lands mid-accept can see
|
||||
/// a connection counted before its outcome is; a lock would buy a
|
||||
/// consistency no consumer needs.
|
||||
pub fn snapshotStats(self: *const TcpServer) Snapshot {
|
||||
const stats = &self.core.stats;
|
||||
return .{
|
||||
.accepted = self.stats.accepted.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),
|
||||
.connection_errors = self.stats.connection_errors.load(.monotonic),
|
||||
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic),
|
||||
.connections = stats.connections.load(.monotonic),
|
||||
.rejected_at_capacity = stats.rejected_at_capacity.load(.monotonic),
|
||||
.rejected_at_shutdown = stats.rejected_at_shutdown.load(.monotonic),
|
||||
.accept_errors = stats.accept_errors.load(.monotonic),
|
||||
.connection_errors = stats.connection_errors.load(.monotonic),
|
||||
.idle_timeouts = stats.idle_timeouts.load(.monotonic),
|
||||
};
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *TcpServer, io: std.Io) void {
|
||||
if (self.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 already unblocked and ending on its
|
||||
// own. Awaiting them means a half-written reply still goes out
|
||||
// whole, and the wait is bounded by the shutdown, not the client.
|
||||
.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: `deinit` cannot run
|
||||
// until this task returns, and RFC 7766 lets a client hold a
|
||||
// connection open forever by asking again inside the idle budget.
|
||||
// Draining here would therefore let one client stall the whole
|
||||
// process's shutdown for as long as it likes. `cancel` requests
|
||||
// cancellation and joins, so the slots are still quiet — and the
|
||||
// buffers still unreferenced — by the time `serve` returns; the
|
||||
// price is the one reply that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
self.core.serve(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void {
|
||||
const was_serving = self.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: std.Io.net.Stream = .{ .socket = self.server.socket };
|
||||
listener.shutdown(io, .both) catch |err| {
|
||||
log.debug("tcp listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// A live connection is blocked in a read that only the idle budget
|
||||
// would end, which is seconds away. Shutting each one down bounds this,
|
||||
// and the same critical section closes the door on new connections.
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.server.deinit(io);
|
||||
gpa.free(self.conns);
|
||||
pub fn deinit(self: *TcpServer, io: std.Io) void {
|
||||
self.core.deinit(io);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop {
|
||||
while (self.state.load(.acquire) == .serving) {
|
||||
const stream = self.server.accept(io) catch |err| switch (err) {
|
||||
error.Canceled => return .canceled,
|
||||
// `deinit` shuts the listening socket down to unblock exactly
|
||||
// this call, so it is the shutdown path arriving early.
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("tcp accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
// Refusing now is honest; a queue would only hide the overload.
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
stream.close(io);
|
||||
continue;
|
||||
},
|
||||
// `deinit` will not see this stream in any slot, so serving it
|
||||
// would hold `deinit` for the whole idle budget.
|
||||
.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.accepted);
|
||||
}
|
||||
|
||||
// The loop condition failed, which only `deinit` can cause.
|
||||
return .closing;
|
||||
}
|
||||
|
||||
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
|
||||
const conn = &self.conns[index];
|
||||
/// One connection, answered serially until the client closes it, the idle
|
||||
/// budget runs out, or a framing error ends it. The core closes the slot
|
||||
/// when this returns.
|
||||
fn serveOne(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
const conn = &self.core.conns[index];
|
||||
const stats = &self.core.stats;
|
||||
var reader = conn.stream.reader(io, &conn.read_buf);
|
||||
var writer = conn.stream.writer(io, &conn.write_buf);
|
||||
const budget = self.options.idle_timeout;
|
||||
@@ -300,15 +137,15 @@ pub const TcpServer = struct {
|
||||
while (true) {
|
||||
var prefix: [transport.prefix_len]u8 = undefined;
|
||||
var got: usize = 0;
|
||||
switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) {
|
||||
switch (listener.race(io, budget, listener.readPrefix, .{ &reader.interface, &prefix, &got })) {
|
||||
.ok => {},
|
||||
.timed_out => {
|
||||
bump(&self.stats.idle_timeouts);
|
||||
listener.bump(&stats.idle_timeouts);
|
||||
return;
|
||||
},
|
||||
.canceled => return,
|
||||
.failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
@@ -317,7 +154,7 @@ pub const TcpServer = struct {
|
||||
// is the normal end of a connection, not a failure.
|
||||
if (got == 0) return;
|
||||
if (got != transport.prefix_len) {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -325,16 +162,16 @@ pub const TcpServer = struct {
|
||||
// the prefix is a u16 so it can never exceed `max_message_len`.
|
||||
const len = transport.parsePrefix(prefix);
|
||||
if (len == 0) {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (race(io, budget, readBody, .{ &reader.interface, conn.query[0..len] })) {
|
||||
switch (listener.race(io, budget, listener.readBody, .{ &reader.interface, conn.payload.query[0..len] })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
// A half-sent message is a broken peer, not an idle one.
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
@@ -343,9 +180,9 @@ pub const TcpServer = struct {
|
||||
io,
|
||||
.tcp,
|
||||
address.NetAddress.fromIp(conn.peer),
|
||||
conn.query[0..len],
|
||||
&conn.reply,
|
||||
&conn.scratch,
|
||||
conn.payload.query[0..len],
|
||||
&conn.payload.reply,
|
||||
&conn.payload.scratch,
|
||||
);
|
||||
const bytes = switch (outcome) {
|
||||
// There is no framing for "no answer", so the connection ends.
|
||||
@@ -354,210 +191,14 @@ pub const TcpServer = struct {
|
||||
};
|
||||
|
||||
const out = transport.framePrefix(@intCast(bytes.len));
|
||||
switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) {
|
||||
switch (listener.race(io, budget, listener.writeReply, .{ &writer.interface, &out, bytes })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
listener.bump(&stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(self: *TcpServer, io: std.Io, stream: std.Io.net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// it cannot deadlock, and losing the lock mid-update would leak a slot.
|
||||
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].state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
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.state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones. Both
|
||||
/// happen under one hold of the mutex: a `claim` that runs before this
|
||||
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
|
||||
/// runs after it reads `shutdown_begun` and takes no slot at all.
|
||||
fn beginShutdown(self: *TcpServer, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
log.debug("tcp connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
fn firstFree(conns: []const TcpServer.Conn) ?usize {
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
|
||||
/// slot is still refused once `deinit` has passed the connections.
|
||||
fn decideClaim(conns: []const TcpServer.Conn, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
const index = firstFree(conns) orelse return .at_capacity;
|
||||
return .{ .slot = index };
|
||||
}
|
||||
|
||||
const Outcome = union(enum) {
|
||||
op: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
const Result = enum { ok, timed_out, failed, canceled };
|
||||
|
||||
/// Runs one connection operation against the idle budget and cancels the loser.
|
||||
fn race(
|
||||
io: std.Io,
|
||||
budget: std.Io.Clock.Duration,
|
||||
comptime f: anytype,
|
||||
args: std.meta.ArgsTuple(@TypeOf(f)),
|
||||
) Result {
|
||||
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
|
||||
// client 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);
|
||||
}
|
||||
|
||||
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
|
||||
/// that closed cleanly between messages, and only a partial prefix is an error.
|
||||
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
out_len.* = try reader.readSliceShort(buf);
|
||||
}
|
||||
|
||||
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
|
||||
return reader.readSliceAll(buf);
|
||||
}
|
||||
|
||||
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(bytes);
|
||||
try writer.flush();
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testConns(count: usize) ![]TcpServer.Conn {
|
||||
const conns = try testing.allocator.alloc(TcpServer.Conn, count);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once" {
|
||||
const conns = try testConns(3);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (0..conns.len) |expected| {
|
||||
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
|
||||
try testing.expectEqual(expected, index);
|
||||
conns[index].state = .active;
|
||||
}
|
||||
}
|
||||
|
||||
test "a full connection pool refuses instead of growing" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (conns) |*conn| conn.state = .active;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a closing slot is not reused until it is free" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
conns[1].state = .closing;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
|
||||
conns[1].state = .free;
|
||||
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a claim takes the first free slot before shutdown" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "a claim after shutdown is refused even with a free slot" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
|
||||
// The refusal must not consume the slot: `deinit` frees it, nothing else.
|
||||
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
}
|
||||
|
||||
@@ -184,11 +184,11 @@ test "two length-prefixed queries share one connection" {
|
||||
|
||||
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.rejected_at_capacity.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
@@ -218,12 +218,12 @@ test "the claimed slot records the connecting client" {
|
||||
// was written after `claim` filled the slot in, so this read races nothing.
|
||||
// Without a real peer the handler would rate-limit, group and log every TCP
|
||||
// client under whatever the uninitialized slot happened to hold.
|
||||
const peer = server.conns[0].peer;
|
||||
const peer = server.core.conns[0].peer;
|
||||
try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer));
|
||||
try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes);
|
||||
try testing.expect(peer.ip4.port != 0);
|
||||
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
@@ -317,7 +317,7 @@ test "a canceled serve does not wait for a live connection" {
|
||||
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
|
||||
|
||||
client_group.cancel(io);
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
|
||||
// Checked last: the connection had to be answered for the test to mean
|
||||
// anything, and the server is torn down before a failure is reported.
|
||||
@@ -327,7 +327,7 @@ test "a canceled serve does not wait for a live connection" {
|
||||
/// The first slot the server would hand out, read after `serve` has returned so
|
||||
/// nothing can be writing it.
|
||||
fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize {
|
||||
for (server.conns, 0..) |*conn, index| {
|
||||
for (server.core.conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
@@ -356,11 +356,11 @@ test "an idle connection is closed and counted" {
|
||||
|
||||
try bounded(io, waitForServerClose, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.idle_timeouts.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.core.stats.idle_timeouts.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
@@ -389,10 +389,10 @@ test "a zero-length message is a connection error" {
|
||||
|
||||
try bounded(io, sendZeroLength, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.idle_timeouts.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.core.stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.core.stats.idle_timeouts.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
@@ -436,7 +436,7 @@ test "deinit ends a serve loop that is blocked on accept" {
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
// No client ever connects, so `serve` is inside an accept when this runs.
|
||||
server.deinit(gpa, io);
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user