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

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+68 -283
View File
@@ -1,20 +1,11 @@
//! The admin HTTP listener.
//!
//! One `std.http.Server` per connection over our own accept loop: a listener
//! task in the app's group, an inner `Io.Group` of connection tasks, and a
//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`.
//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is
//! tcp_server.zig's, for the same reason.
//!
//! Shutdown takes one of two paths:
//!
//! - `deinit` shuts the listening socket down (which unblocks `accept` with
//! `error.SocketNotListening`) and then shuts every live connection down, so
//! each one unblocks and finishes its response. `serve` drains them.
//! - A canceled `serve` cannot drain: HTTP keep-alive lets a browser hold a
//! connection open indefinitely with no request on it, so waiting would let
//! one idle tab stall the whole process's shutdown. The connection group is
//! canceled instead.
//! One `std.http.Server` per connection over the shared `listener.Core` accept
//! loop (milestone-18 ruling 1): a listener task in the app's group, an inner
//! `Io.Group` of connection tasks, and a keep-alive loop per connection that
//! ends on `error.HttpConnectionClosing`. The shape is
//! lib/std/Build/WebServer.zig:152-185; the slot pool and the shutdown split
//! come from the core, which documents both.
//!
//! Connection slots are fixed and pre-allocated, and each one owns every buffer
//! a request needs, so serving allocates only what a handler asks the
@@ -41,6 +32,7 @@ const dns_handler = @import("../server/handler.zig");
const doh_server = @import("../server/doh_server.zig");
const dot_server = @import("../server/dot_server.zig");
const http_util = @import("http_util.zig");
const listener_core = @import("../server/listener.zig");
const local_tables_mod = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const manager_mod = @import("../filter/manager.zig");
@@ -69,10 +61,6 @@ pub const default_max_connections: u16 = 64;
/// connections cost 4 MiB rather than 64.
const arena_retain_bytes = 64 * 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 };
const over_capacity_body = "{\"error\":\"too many connections\"}";
const over_capacity_response = std.fmt.comptimePrint(
"HTTP/1.1 503 Service Unavailable\r\n" ++
@@ -232,12 +220,10 @@ pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Reques
return .ok;
}
/// What the admin listener counts on top of `listener.CoreStats`. Nothing
/// exports these: there is no `nxdns_web_*` family, they exist for the
/// integration tests and for a future one.
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),
requests: std.atomic.Value(u64) = .init(0),
};
@@ -245,41 +231,15 @@ pub const Options = struct {
max_connections: u16 = default_max_connections,
};
/// 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 Server = struct {
core: listener_core.Core(Config),
state: *WebState,
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,
stats: Stats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot's fixed cost. The head copies exist because every string in
/// `request.head` dies on the first body read (http/Server.zig:594).
pub const Conn = struct {
recv_buf: [recv_buffer_len]u8,
send_buf: [send_buffer_len]u8,
/// `request.head` dies on the first body read (http/Server.zig:594). The
/// receive and send buffers belong to the core.
pub const Payload = struct {
target_buf: [http_util.max_target_len]u8,
cookie_buf: [http_util.max_cookie_len]u8,
accept_encoding_buf: [http_util.max_header_value_len]u8,
@@ -288,13 +248,31 @@ pub const Server = struct {
/// Per-request working memory, reset between requests on the same
/// connection so a keep-alive client cannot grow it without bound.
arena: std.heap.ArenaAllocator,
stream: net.Stream,
peer: net.IpAddress,
/// Guarded by `Server.mutex`.
conn_state: ConnState,
fn init(payload: *Payload, gpa: Allocator) void {
payload.arena = .init(gpa);
}
fn deinit(payload: *Payload) void {
payload.arena.deinit();
}
};
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
const Config = struct {
pub const Owner = Server;
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(.web_server);
pub const name = "web";
pub const refuse = refuseOverCapacity;
pub const initPayload = Payload.init;
pub const deinitPayload = Payload.deinit;
};
pub const Conn = listener_core.Core(Config).Conn;
pub const ListenError = listener_core.Core(Config).ListenError;
pub fn listen(
gpa: Allocator,
@@ -303,128 +281,35 @@ pub const Server = struct {
state: *WebState,
options: Options,
) ListenError!Server {
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;
conn.arena = .init(gpa);
}
const listener = try listen_address.listen(io, .{ .reuse_address = true });
return .{
.core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.state = state,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
};
}
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const Server) net.IpAddress {
return self.listener.socket.address;
return self.core.boundAddress();
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *Server, 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: *Server, 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("web listener shutdown failed: {t}", .{err});
};
// Ruling 11 of milestone 16, before `beginShutdown`: a live-query task
// parked in `Hub.wait` is waiting on an event, not on its socket, so
// shutting the connection down does not reach it. Without this the
// drain below waits out one heartbeat interval per idle stream.
pub fn deinit(self: *Server, io: std.Io) void {
// Ruling 11 of milestone 16, before the core shuts the connections
// down: a live-query task parked in `Hub.wait` is waiting on an event,
// not on its socket, so shutting the connection down does not reach it.
// Without this the drain waits out one heartbeat interval per idle
// stream.
if (self.state.hub) |hub| hub.close(io);
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.listener.deinit(io);
for (self.conns) |*conn| conn.arena.deinit();
gpa.free(self.conns);
self.core.deinit(io);
self.* = undefined;
}
fn acceptLoop(self: *Server, 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("web accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
refuse(io, stream);
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.accepted);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
/// Ruling 7: over capacity the client is told so, never silently dropped.
///
/// The response is written from the accept loop, because refusing must not
@@ -437,7 +322,7 @@ pub const Server = struct {
/// would mean a blocking read on the accept loop with no bound but the
/// client's goodwill, which is a worse failure than a lost error page on a
/// server that is already at capacity.
fn refuse(io: std.Io, stream: net.Stream) void {
fn refuseOverCapacity(io: std.Io, stream: net.Stream) void {
var buf: [over_capacity_response.len]u8 = undefined;
var writer = stream.writer(io, &buf);
writer.interface.writeAll(over_capacity_response) catch {};
@@ -445,12 +330,12 @@ pub const Server = struct {
stream.close(io);
}
fn serveConn(self: *Server, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
var reader = conn.stream.reader(io, &conn.recv_buf);
var writer = conn.stream.writer(io, &conn.send_buf);
/// One connection's keep-alive loop. The core closes the slot when this
/// returns.
fn serveOne(self: *Server, io: std.Io, index: usize) void {
const conn = &self.core.conns[index];
var reader = conn.stream.reader(io, &conn.read_buf);
var writer = conn.stream.writer(io, &conn.write_buf);
var connection: http.Server = .init(&reader.interface, &writer.interface);
while (connection.reader.state == .ready) {
@@ -461,11 +346,11 @@ pub const Server = struct {
// worth a counter.
error.ReadFailed => return,
error.HttpHeadersOversize => {
bump(&self.stats.connection_errors);
listener_core.bump(&self.core.stats.connection_errors);
return;
},
error.HttpRequestTruncated, error.HttpHeadersInvalid => {
bump(&self.stats.connection_errors);
listener_core.bump(&self.core.stats.connection_errors);
return;
},
};
@@ -484,17 +369,17 @@ pub const Server = struct {
request.head.content_length = 0;
}
bump(&self.stats.requests);
listener_core.bump(&self.stats.requests);
// Retained with a limit, not wholesale: a single 1 MiB body would
// otherwise keep a megabyte per slot alive for as long as the
// browser holds the connection.
_ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
_ = conn.payload.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
self.handleRequest(io, conn, &request) catch |err| switch (err) {
// Ruling 28: the peer went away mid-response. Normal.
error.WriteFailed => return,
error.HttpExpectationFailed, error.OutOfMemory => {
bump(&self.stats.connection_errors);
listener_core.bump(&self.core.stats.connection_errors);
return;
},
};
@@ -509,26 +394,26 @@ pub const Server = struct {
conn: *Conn,
request: *http.Server.Request,
) http_util.HandlerError!void {
const arena = conn.arena.allocator();
const arena = conn.payload.arena.allocator();
const target = request.head.target;
if (target.len > conn.target_buf.len) {
if (target.len > conn.payload.target_buf.len) {
var view = bareRequest(request, conn, arena);
return http_util.respondError(&view, .uri_too_long, "target too long");
}
@memcpy(conn.target_buf[0..target.len], target);
const copied = conn.target_buf[0..target.len];
@memcpy(conn.payload.target_buf[0..target.len], target);
const copied = conn.payload.target_buf[0..target.len];
const split = std.mem.findScalar(u8, copied, '?') orelse copied.len;
const raw_path = copied[0..split];
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
const cookie = copyCookie(request, &conn.cookie_buf);
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
const cookie = copyCookie(request, &conn.payload.cookie_buf);
const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf);
const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
.addr => |addr| addr,
.bad_forwarded_for => {
@@ -585,58 +470,6 @@ pub const Server = struct {
.arena = arena,
};
}
fn claim(self: *Server, 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: *Server, 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: *Server, 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("web connection shutdown failed: {t}", .{err});
};
}
}
};
/// Copies one header value into `buf`. A value too long for its budget reads as
@@ -758,19 +591,6 @@ fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
return buf[0..len];
}
/// The whole claim rule, without the mutex, so it is testable without a backend.
fn decideClaim(conns: []const Server.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;
}
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. The admin UI failing to come up must
@@ -786,7 +606,7 @@ pub fn serve(state: *WebState, io: std.Io) void {
log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err });
return;
};
defer server.deinit(state.gpa, io);
defer server.deinit(io);
log.info("web interface listening on {f}", .{server.boundAddress()});
server.serve(io);
@@ -794,41 +614,6 @@ pub fn serve(state: *WebState, io: std.Io) void {
const testing = std.testing;
fn testConns(count: usize) ![]Server.Conn {
const conns = try testing.allocator.alloc(Server.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 "the over-capacity response is a well formed 503" {
try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 "));
const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?;