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
+7 -7
View File
@@ -84,10 +84,10 @@ const maintenance_interval_s = 60;
/// takes longer than this is not going to finish at all.
const download_budget_s = 300;
/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and
/// keeps a maximum-length name with a large OPT record comfortable.
const doh_request_buf_len = 1024;
const doh_transfer_buf_len = 4096;
/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
/// probes the buffers `nxdns run` serves with.
const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: {
@@ -451,7 +451,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS
// failing to come up must not stop the plain-DNS side this box exists for.
var doh: ?doh_server.DohServer = null;
defer if (doh) |*server| server.deinit(gpa, io);
defer if (doh) |*server| server.deinit(io);
if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store);
var dot: ?dot_server.DotServer = null;
@@ -526,7 +526,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
break :bound null;
};
defer if (tcp6) |*s| s.deinit(gpa, io);
defer if (tcp6) |*s| s.deinit(io);
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
@@ -535,7 +535,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
break :bound null;
};
defer if (tcp4) |*s| s.deinit(gpa, io);
defer if (tcp4) |*s| s.deinit(io);
// Ruling 13: `/metrics` sums each transport's listeners into one family, so
// the web state carries pointers to whichever of the four came up. The
+4
View File
@@ -45,6 +45,10 @@ pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len;
///
/// The length bounds are assertions, not errors: both values reach here from
/// the packet parser, which has already rejected anything longer.
///
/// The lowercase-and-strip-the-dot below is deliberately not
/// `dns.name.normalizeText`: that one validates, and this input is already
/// asserted valid, so a key build must never fail.
pub fn buildKey(
buf: *[max_key_len]u8,
qname: []const u8,
+49 -4
View File
@@ -757,9 +757,15 @@ fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited.
// running `check` against a file the operator just edited. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => {
try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag });
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
try diags.writeAll(r.out);
return exit_check;
},
};
@@ -917,8 +923,10 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const tls_buffers = try r.gpa.alloc(u8, 4 * chunk);
defer r.gpa.free(tls_buffers);
var request_buf: [1024]u8 = undefined;
var transfer_buf: [4096]u8 = undefined;
// The same sizes `nxdns run` serves with, so the probe reports on the
// buffers the server will actually use.
var request_buf: [doh_client.default_request_buf_len]u8 = undefined;
var transfer_buf: [doh_client.default_transfer_buf_len]u8 = undefined;
const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
defer r.gpa.free(response_buf);
@@ -1461,6 +1469,43 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written());
}
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
// `check` keeps.
var env: CheckEnv = undefined;
try env.init();
defer env.deinit();
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const r = captured.runner();
// An unexpected field renders as an "error:" line and a "note:" line.
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data = ".{ .grops = .{} }\n" });
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
// The first line names what was checked; every line after it is a problem.
try testing.expect(std.mem.startsWith(u8, lines.next().?, "checking configuration file "));
var failures: usize = 0;
while (lines.next()) |line| {
try testing.expect(std.mem.startsWith(u8, line, "FAIL config: "));
failures += 1;
}
try testing.expectEqual(@as(usize, 2), failures);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "error: unexpected field 'grops'"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "note: supported: "));
}
test "check reads config.db without writing to it" {
// D6: the database branch opened read/write, chmod'ed 0600, turned WAL on —
// which is what creates the two sidecars — and committed migration steps,
+30 -1
View File
@@ -164,7 +164,11 @@ pub fn importSource(
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order.
fn reportParseFailure(
///
/// `pub` because `nxdns check` parses the same file and owes the operator the
/// same one-line-per-problem output; rendering the ZON diagnostics inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
@@ -910,6 +914,31 @@ test "importSource reports a ZON syntax error and writes nothing" {
try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null);
}
test "importSource splits a multi-line ZON failure into one problem per message" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
// An unexpected field renders as an "error:" line plus a "note:" line, so
// the rendering the CLI prints has to be split rather than embedded whole.
try testing.expectError(
error.ParseZon,
importSource(io, testing.allocator, &database, ".{ .grops = .{} }", .{}, &diags),
);
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
for (diags.problems.items) |problem| {
try testing.expectEqual(@as(?usize, null), std.mem.findScalar(u8, problem.message, '\n'));
}
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "error: ") != null);
try testing.expect(std.mem.indexOf(u8, diags.problems.items[1].message, "note: ") != null);
}
test "a config omitting every optional field parses into an arena and leaks nothing" {
// The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena
// is the only release, and `std.testing.allocator` fails the test if a
+47
View File
@@ -134,6 +134,34 @@ pub fn fromText(text: []const u8) FromTextError!Name {
return name;
}
pub const NormalizeError = error{BadName};
/// Lowercases over ASCII into `buf`, strips one trailing dot, and checks the
/// result is a name `fromText` accepts. Returns the normalized text, borrowed
/// from `buf`.
///
/// A byte ≥ 0x80 is rejected: query names arrive ASCII-lowercased, so a high
/// byte here could never be matched, and configuration that can never match is
/// worth reporting rather than storing. The root name is rejected for the same
/// reason — a configured entry that matches everything, or nothing, is not what
/// either caller means.
///
/// `filter/rules.zig`, `filter/compiler.zig` and `cache/dns_cache.zig` keep
/// their own variants on purpose; each says why beside its copy.
pub fn normalizeText(text: []const u8, buf: *[types.max_name_len]u8) NormalizeError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = fromText(normalized) catch return error.BadName;
return normalized;
}
/// Writes presentation form: labels joined by dots, no trailing dot. The root
/// name writes as ".".
pub fn formatText(name: Name, w: *Writer) Writer.Error!void {
@@ -392,3 +420,22 @@ test "labelCount" {
try testing.expectEqual(@as(usize, 1), (try fromText("com")).labelCount());
try testing.expectEqual(@as(usize, 3), (try fromText("www.example.com")).labelCount());
}
test "normalizeText lowercases and strips one trailing dot" {
var buf: [types.max_name_len]u8 = undefined;
try testing.expectEqualStrings("example.com", try normalizeText("Example.COM", &buf));
try testing.expectEqualStrings("example.com", try normalizeText("example.com.", &buf));
try testing.expectEqualStrings("a", try normalizeText("A", &buf));
}
test "normalizeText rejects the root, high bytes and names fromText refuses" {
var buf: [types.max_name_len]u8 = undefined;
try testing.expectError(error.BadName, normalizeText("", &buf));
try testing.expectError(error.BadName, normalizeText(".", &buf));
try testing.expectError(error.BadName, normalizeText("caf\xc3\xa9.example.com", &buf));
try testing.expectError(error.BadName, normalizeText("a..b", &buf));
try testing.expectError(error.BadName, normalizeText("a." ** 200 ++ "com", &buf));
// A label of 64 bytes is one over the wire limit.
try testing.expectError(error.BadName, normalizeText("a" ** 64 ++ ".com", &buf));
}
+9 -18
View File
@@ -61,30 +61,17 @@ pub fn compile(
var wild: Entries = .{};
defer wild.deinit(gpa);
while (true) {
const raw = r.takeDelimiter('\n') catch |err| switch (err) {
error.ReadFailed => return error.ReadFailed,
// `takeDelimiter` leaves the stream unmodified on `StreamTooLong`
// (Reader.zig:885). Without this discard the loop re-reads the same
// bytes forever.
error.StreamTooLong => {
while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
const raw = switch (event) {
.long_line => {
counts.long_lines += 1;
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
error.EndOfStream => break,
error.ReadFailed => return error.ReadFailed,
};
continue;
},
} orelse break;
.line => |line| line,
};
var line = raw;
if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
// A reader whose buffer is larger than `max_line_len` reports the
// over-long line here instead of through `error.StreamTooLong`.
if (line.len > max_line_len) {
counts.long_lines += 1;
continue;
}
const parsed = parsers.parseLine(format, line);
switch (parsed.kind) {
@@ -120,6 +107,10 @@ pub fn compile(
/// Normalizes one whitespace-separated candidate and files it under `.list`,
/// `.wild`, or neither.
///
/// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every
/// rejection through `counts.invalid` rather than an error.
fn addCandidate(
gpa: std.mem.Allocator,
field: []const u8,
+5 -13
View File
@@ -1530,20 +1530,12 @@ fn destroySnapshot(gpa: Allocator, snapshot: *matcher.Snapshot) void {
fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteFailed }!void {
var considered: usize = 0;
while (considered < parsers.sample_lines) {
const raw = r.takeDelimiter('\n') catch |err| switch (err) {
// The stream is left unmodified here, so the line has to be stepped
// over or this loop never advances.
error.StreamTooLong => {
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
error.EndOfStream => return,
error.ReadFailed => return error.ReadFailed,
};
continue;
},
error.ReadFailed => return error.ReadFailed,
} orelse return;
const event = (try parsers.nextBoundedLine(r, compiler.max_line_len)) orelse return;
const raw = switch (event) {
.long_line => continue,
.line => |line| line,
};
if (raw.len > compiler.max_line_len) continue;
const line = std.mem.trim(u8, raw, &std.ascii.whitespace);
if (line.len == 0) continue;
if (parsers.isComment(line)) continue;
+96
View File
@@ -48,6 +48,50 @@ pub fn parseLine(format: Format, line: []const u8) Line {
};
}
pub const LineEvent = union(enum) {
/// One line without its delimiter, borrowed from the reader's buffer and
/// valid only until the next call. A trailing '\r' is left on: whether it
/// belongs to the line is the caller's decision.
line: []const u8,
/// A line longer than `max_len`. It has already been stepped over.
long_line,
};
/// One line, or `null` at end of stream. `max_len` bounds a line; anything
/// longer comes back as `.long_line` with the stream positioned on the line
/// after it, so a caller that keeps calling always advances.
///
/// The bound is a parameter because this file may not import `compiler.zig`:
/// the compiler imports this one, and this file is the root of a separate fuzz
/// module. Both callers pass `compiler.max_line_len`.
///
/// The two over-long paths exist because a `Reader` reports an over-long line
/// two different ways. A reader whose buffer is smaller than `max_len` reports
/// `error.StreamTooLong` and — this is the hazard — leaves the stream
/// unmodified (Reader.zig:895-919), so without the discard a caller re-reads
/// the same bytes forever. A reader whose buffer is larger hands the whole line
/// over and the length check catches it.
///
/// An over-long final line with no delimiter ends the stream inside the
/// discard. That still counts as a line, so it comes back as `.long_line`; the
/// discard drained the stream (Reader.zig:1042), so the next call returns
/// `null`.
pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent {
const raw = r.takeDelimiter('\n') catch |err| switch (err) {
error.ReadFailed => return error.ReadFailed,
error.StreamTooLong => {
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
error.EndOfStream => return .long_line,
error.ReadFailed => return error.ReadFailed,
};
return .long_line;
},
} orelse return null;
if (raw.len > max_len) return .long_line;
return .{ .line = raw };
}
pub const sample_lines = 64;
/// Picks a format from the first `sample_lines` lines that are not blank and
@@ -309,6 +353,58 @@ test "parseLine dispatches to the abp parser" {
try testing.expect(line.covers_apex);
}
fn expectLine(expected: []const u8, event: ?LineEvent) !void {
const got = event orelse return error.TestExpectedLine;
switch (got) {
.line => |line| try testing.expectEqualStrings(expected, line),
.long_line => return error.TestExpectedLine,
}
}
test "nextBoundedLine walks lines and ends at the stream" {
var r: std.Io.Reader = .fixed("a\nbb\n\nccc");
try expectLine("a", try nextBoundedLine(&r, 16));
try expectLine("bb", try nextBoundedLine(&r, 16));
try expectLine("", try nextBoundedLine(&r, 16));
// A final line with no delimiter is still a line.
try expectLine("ccc", try nextBoundedLine(&r, 16));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 16));
}
test "nextBoundedLine reports an over-long line when the reader buffer is large" {
var r: std.Io.Reader = .fixed("a\nxxxxxxxx\nb\n");
try expectLine("a", try nextBoundedLine(&r, 4));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(&r, 4)).?);
try expectLine("b", try nextBoundedLine(&r, 4));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 4));
}
test "nextBoundedLine steps over a line that does not fit the reader buffer" {
// A buffer smaller than the long line makes `takeDelimiter` report
// `error.StreamTooLong` and leave the stream where it was, which is the
// path that loops forever without the discard.
var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64 ++ "\nb\n");
var buf: [16]u8 = undefined;
var limited = backing.limited(.unlimited, &buf);
const r = &limited.interface;
try expectLine("a", try nextBoundedLine(r, 16));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
try expectLine("b", try nextBoundedLine(r, 16));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}
test "nextBoundedLine reports an over-long final line that ends inside the discard" {
var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64);
var buf: [16]u8 = undefined;
var limited = backing.limited(.unlimited, &buf);
const r = &limited.interface;
try expectLine("a", try nextBoundedLine(r, 16));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}
test "looksLikeIpLiteral separates addresses from names" {
try testing.expect(looksLikeIpLiteral("0.0.0.0"));
try testing.expect(looksLikeIpLiteral("127.0.0.1"));
+4
View File
@@ -192,6 +192,10 @@ const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything.
///
/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which
/// `name.fromText` would reject, so this variant skips that check and rejects
/// control bytes and space instead.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
+15 -62
View File
@@ -135,13 +135,13 @@ pub const ForwardClient = struct {
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeSocket(io, &socket);
defer transport.closeBlocked(io, &socket);
socket.send(io, &dest, query) catch |err| {
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
return mapPhase(err, error.SendFailed);
return transport.mapPhase(err, error.SendFailed);
};
// A deadline, not a duration: a discarded foreign datagram restarts the
@@ -152,7 +152,7 @@ pub const ForwardClient = struct {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
error.Timeout => return error.Timeout,
error.ConcurrencyUnavailable => return error.SystemResources,
else => return mapPhase(err, error.ReceiveFailed),
else => return transport.mapPhase(err, error.ReceiveFailed),
};
// Off-path spoofing is the reason the source address is checked at
@@ -183,35 +183,16 @@ pub const ForwardClient = struct {
}
}
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a
/// second task and the loser is canceled. `ConnectOptions.timeout` is never
/// set: the Threaded backend panics on it (Threaded.zig:12076).
/// The read budget bounds the whole TCP exchange through
/// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the
/// Threaded backend panics on it (Threaded.zig:12076).
fn exchangeTcp(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the resolver is slow.
try result;
return error.Timeout;
},
}
return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf });
}
fn tcpOnce(
@@ -224,9 +205,9 @@ pub const ForwardClient = struct {
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
defer transport.closeBlocked(io, &stream);
const split = self.frame_buf.len / 2;
var stream_writer = stream.writer(io, self.frame_buf[0..split]);
@@ -257,15 +238,6 @@ pub const ForwardClient = struct {
}
};
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
/// The local address a datagram to `dest` is sent from: same family, port
/// chosen by the kernel.
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
@@ -275,25 +247,6 @@ fn wildcardFor(dest: net.IpAddress) net.IpAddress {
};
}
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
/// the `defer` chain would then return `error.Canceled` and skip the close,
/// leaking the descriptor, so both closes run with cancellation blocked.
fn closeStream(io: std.Io, stream: *const net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
socket.close(io);
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
/// errors out of the peer fault group.
@@ -302,7 +255,7 @@ fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport
stream_writer.err.?
else
err;
return mapPhase(cause, error.SendFailed);
return transport.mapPhase(cause, error.SendFailed);
}
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
@@ -310,7 +263,7 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp
stream_reader.err.?
else
err;
return mapPhase(cause, error.ReceiveFailed);
return transport.mapPhase(cause, error.ReceiveFailed);
}
const testing = std.testing;
@@ -408,19 +361,19 @@ test "mapPhase keeps local resource and cancellation errors out of the peer faul
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.ReceiveFailed)),
transport.group(transport.mapPhase(err, error.ReceiveFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.ConnectFailed),
transport.mapPhase(error.Canceled, error.ConnectFailed),
);
// A refused connection is the resolver's side, so it stays a peer fault.
try testing.expectEqual(
transport.ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
transport.mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
}
+4 -22
View File
@@ -48,7 +48,10 @@ pub const Zones = struct {
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const zone = normalizeName(row.zone, &buf) catch return error.BadZone;
// The root is rejected with everything else `normalizeText` refuses:
// a zone that forwards everything would bypass the upstream pool
// entirely, which is not what conditional forwarding means.
const zone = name.normalizeText(row.zone, &buf) catch return error.BadZone;
const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
try spans.append(gpa, .{
.offset = names.items.len,
@@ -119,27 +122,6 @@ fn suffixMatches(zone: []const u8, domain: []const u8) bool {
return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
}
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected because query
/// names arrive ASCII-lowercased, so a high byte could never match. The root
/// zone is rejected too: a zone that forwards everything would bypass the
/// upstream pool entirely, which is not what conditional forwarding means.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+4 -23
View File
@@ -52,7 +52,9 @@ pub const Records = struct {
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const owner = normalizeName(row.name, &buf) catch return error.BadRecordName;
// A record whose owner can never be matched — a high byte, or the
// root — is a configuration error, not a record that never answers.
const owner = name.normalizeText(row.name, &buf) catch return error.BadRecordName;
const value = try parseValue(row.rtype, row.value);
try spans.append(gpa, .{
.offset = owners.items.len,
@@ -187,27 +189,6 @@ fn rankRun(records: []const Record, wanted: u2) []const Record {
return records[start..end];
}
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names
/// arrive ASCII-lowercased, so a high byte here could never be matched and a
/// record that can never answer is a configuration error worth reporting. The
/// root name is rejected for the same reason — nothing can match it.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value {
switch (rtype) {
.a => {
@@ -226,7 +207,7 @@ fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!V
},
.cname => {
var buf: [types.max_name_len]u8 = undefined;
const target = normalizeName(text, &buf) catch return error.BadRecordValue;
const target = name.normalizeText(text, &buf) catch return error.BadRecordValue;
return .{ .cname = name.fromText(target) catch return error.BadRecordValue };
},
}
+97 -381
View File
@@ -1,14 +1,13 @@
//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2).
//!
//! The shape is web/server.zig's: one `std.http.Server` per connection over our
//! own accept loop, fixed pre-allocated connection slots, a keep-alive loop per
//! connection that ends on `error.HttpConnectionClosing`, and the same shutdown
//! split — `deinit` shuts live connections down and drains, a canceled `serve`
//! cancels the connection group because HTTP keep-alive has no deadline of its
//! own. The difference is the transport: after the TCP accept, a certificate
//! generation is pinned (`CertStore.acquire`) and `ServerStream.accept` runs the
//! TLS handshake, and `std.http.Server` sits on the stream's plaintext
//! reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//! The shape is web/server.zig's: one `std.http.Server` per connection over the
//! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and
//! a keep-alive loop per connection that ends on
//! `error.HttpConnectionClosing`. The difference is the transport: after the TCP
//! accept, a certificate generation is pinned (`CertStore.acquire`) and
//! `ServerStream.accept` runs the TLS handshake through
//! `listener.handshakeStage`, and `std.http.Server` sits on the stream's
//! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//!
//! The handshake runs under the same race budget tcp_server applies to its
//! reads (ruling 3's rationale): a client that connects and never handshakes
@@ -33,12 +32,11 @@ const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const doh_client = @import("../upstream/doh_client.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const model = @import("../config/model.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.doh_server);
pub const dns_query_path = "/dns-query";
/// Ruling 5. Mbed TLS records the pointer, so the list must outlive every
@@ -55,10 +53,6 @@ const send_buffer_len = 4 * 1024;
pub const default_max_connections: u16 = 64;
/// How long the accept loop waits after an unexpected accept failure, so a
/// persistent one cannot turn the loop into a spin.
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };
pub const Options = struct {
@@ -71,18 +65,9 @@ pub const Options = struct {
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};
/// What DoH counts on top of `listener.CoreStats`.
pub const Stats = struct {
connections: std.atomic.Value(u64) = .init(0),
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
accept_errors: std.atomic.Value(u64) = .init(0),
tls_handshake_failures: std.atomic.Value(u64) = .init(0),
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
/// Every 4xx answered on `/dns-query` and every miss beside it: the
/// visibility counter for clients that speak, but speak wrongly.
bad_requests: std.atomic.Value(u64) = .init(0),
@@ -94,53 +79,28 @@ pub const Snapshot = struct {
rejected_at_shutdown: u64,
accept_errors: u64,
tls_handshake_failures: u64,
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: u64,
connection_errors: u64,
bad_requests: u64,
};
/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims
/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`.
const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor its own task is
/// about to close.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum { closing, canceled };
const Claim = union(enum) {
slot: usize,
at_capacity,
shutting_down,
};
pub const DohServer = struct {
/// Allocates the per-connection Mbed TLS context in `ServerStream.accept`.
gpa: Allocator,
core: listener.Core(Config),
handler: *handler.Handler,
certs: *cert_store.CertStore,
listener: net.Server,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`, set in the same critical section that shuts the live
/// connections down.
shutdown_begun: bool,
options: Options,
stats: Stats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The
/// two message buffers cannot shrink: a POST body and the reply both go up
/// to the 65535 bytes a DNS message can be.
pub const Conn = struct {
/// `ServerStream` plaintext buffers; `read_buf` doubles as the HTTP
/// head cap (see `recv_buffer_len`).
read_buf: [recv_buffer_len]u8,
write_buf: [send_buffer_len]u8,
/// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext
/// buffers belong to the core; its `read_buf` doubles as the HTTP head cap
/// (see `recv_buffer_len`).
pub const Payload = struct {
/// The decoded query: a POST body or a GET `dns` parameter.
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
@@ -148,15 +108,22 @@ pub const DohServer = struct {
/// serially, so one query uses it at a time.
scratch: handler.Scratch,
/// Valid between a successful `ServerStream.accept` and the
/// `close(gpa)` in `serveConn`'s defer.
/// `close(gpa)` in `serveOne`'s defer.
tls: tls_server.ServerStream,
stream: net.Stream,
peer: net.IpAddress,
/// Guarded by `DohServer.mutex`.
conn_state: ConnState,
};
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
const Config = struct {
pub const Owner = DohServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.doh_server);
pub const name = "doh";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen(
gpa: Allocator,
@@ -166,172 +133,75 @@ pub const DohServer = struct {
certs: *cert_store.CertStore,
options: Options,
) ListenError!DohServer {
std.debug.assert(options.max_connections > 0);
const conns = try gpa.alloc(Conn, options.max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| conn.conn_state = .free;
const listener = try listen_address.listen(io, .{ .reuse_address = true });
return .{
.gpa = gpa,
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.certs = certs,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options,
.stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
};
}
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const DohServer) net.IpAddress {
return self.listener.socket.address;
return self.core.boundAddress();
}
pub fn snapshotStats(self: *const DohServer) Snapshot {
const core = &self.core.stats;
return .{
.connections = self.stats.connections.load(.monotonic),
.rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = self.stats.accept_errors.load(.monotonic),
.connections = core.connections.load(.monotonic),
.rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic),
.accept_errors = core.accept_errors.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic),
.bad_requests = self.stats.bad_requests.load(.monotonic),
};
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *DohServer, io: std.Io) void {
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
var group: std.Io.Group = .init;
switch (self.acceptLoop(io, &group)) {
// `deinit` shut every live connection down before it published
// `.closing`, so each one is unblocked and finishing on its own.
// Awaiting them means a half-written response still goes out whole.
.closing => {
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
},
// Nothing has shut these connections down, and an idle keep-alive
// connection has no deadline of its own, so draining could wait
// forever. Cancel joins, so the slots are quiet by the time `serve`
// returns; the price is the one response that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
self.core.serve(io);
}
pub fn deinit(self: *DohServer, gpa: Allocator, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
// Shutting the listening socket down is the documented way to unblock a
// pending `accept`: it fails with `error.SocketNotListening`.
const listener: net.Stream = .{ .socket = self.listener.socket };
listener.shutdown(io, .both) catch |err| {
log.debug("doh listener shutdown failed: {t}", .{err});
};
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.listener.deinit(io);
gpa.free(self.conns);
pub fn deinit(self: *DohServer, io: std.Io) void {
self.core.deinit(io);
self.* = undefined;
}
fn acceptLoop(self: *DohServer, io: std.Io, group: *std.Io.Group) Stop {
while (self.run_state.load(.acquire) == .serving) {
const stream = self.listener.accept(io) catch |err| switch (err) {
error.Canceled => return .canceled,
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
log.debug("doh accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// See the module comment: no 503 without a handshake, so over
// capacity the stream is closed raw and the refusal counted.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
continue;
},
.shutting_down => {
bump(&self.stats.rejected_at_shutdown);
stream.close(io);
return .closing;
},
};
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.rejected_at_capacity);
self.finish(io, index);
continue;
},
};
bump(&self.stats.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *DohServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
/// One connection: pin, handshake, keep-alive loop, close_notify, release —
/// the ordering `listener.handshakeStage` documents. The core closes the
/// TCP stream after this returns.
fn serveOne(self: *DohServer, io: std.Io, index: usize) void {
const conn = &self.core.conns[index];
const stats = &self.core.stats;
const gpa = self.core.gpa;
// Pinned for the whole connection (ruling 6): a reload never frees the
// generation this stream handshook against.
const entry = self.certs.acquire(io);
defer self.certs.release(io, entry);
var handshook = false;
switch (race(io, self.options.idle_timeout, handshake, .{ self.gpa, conn, &entry.ctx, io, &handshook })) {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) {
.ok => {},
// The select can report the expiry or the cancellation after the
// handshake has in fact succeeded. The flag is written before the
// race joins its tasks, so a TLS context that exists is closed on
// every path, exactly once.
.canceled => {
if (handshook) conn.tls.close(self.gpa);
return;
},
.canceled => return,
// Milestone-16 ruling 9: a stalled handshake is refused like a broken
// one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
// wait below, so the two listeners export the same names for the
// same events.
.timed_out, .failed => {
if (handshook) conn.tls.close(self.gpa);
bump(&self.stats.tls_handshake_failures);
listener.bump(&self.stats.tls_handshake_failures);
return;
},
}
// Flushes, sends close_notify and frees the TLS context on every exit
// path below; `finish` closes the TCP stream afterwards.
defer conn.tls.close(self.gpa);
// path below; the core closes the TCP stream afterwards.
defer conn.payload.tls.close(gpa);
var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer());
var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer());
while (connection.reader.state == .ready) {
// Milestone-16 ruling 10: the wait for the next request head is the
@@ -339,10 +209,10 @@ pub const DohServer = struct {
// it runs under the same budget as the handshake. The body read and
// `handleRequest` below stay untimed.
var head: ReceiveHeadResult = error.ReadFailed;
switch (race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
.ok => {},
.timed_out => {
bump(&self.stats.idle_timeouts);
listener.bump(&stats.idle_timeouts);
return;
},
// Cancellation is shutdown; `.failed` here is only the wrapper
@@ -359,7 +229,7 @@ pub const DohServer = struct {
error.HttpRequestTruncated,
error.HttpHeadersInvalid,
=> {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
};
@@ -380,7 +250,7 @@ pub const DohServer = struct {
// The peer went away mid-response. Normal.
error.WriteFailed => return,
error.HttpExpectationFailed, error.ReadFailed => {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
};
@@ -392,6 +262,31 @@ pub const DohServer = struct {
}
}
/// The `listener.handshakeStage` stage: everything one mbedTLS handshake
/// needs, plus the close that undoes it.
const Handshake = struct {
conn: *Conn,
gpa: Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
pub fn accept(self: Handshake) anyerror!void {
const conn = self.conn;
try conn.payload.tls.accept(
self.gpa,
self.ctx,
self.io,
&conn.stream,
&conn.read_buf,
&conn.write_buf,
);
}
pub fn close(self: Handshake) void {
self.conn.payload.tls.close(self.gpa);
}
};
const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed };
/// What `serveConn`'s keep-alive loop does after the response went out.
@@ -444,7 +339,7 @@ pub const DohServer = struct {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
},
};
const query = decodeDnsValue(value, &conn.query) catch {
const query = decodeDnsValue(value, &conn.payload.query) catch {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
};
return self.answer(io, conn, request, query);
@@ -459,17 +354,17 @@ pub const DohServer = struct {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
};
const reader = try request.readerExpectContinue(&.{});
const got = reader.readSliceShort(&conn.query) catch return error.ReadFailed;
const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed;
// A full buffer is either a message of exactly the DNS maximum
// or a chunked body that keeps going; one probe byte decides.
if (got == conn.query.len) {
if (got == conn.payload.query.len) {
var probe: [1]u8 = undefined;
const extra = reader.readSliceShort(&probe) catch return error.ReadFailed;
if (extra != 0) {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
}
}
return self.answer(io, conn, request, conn.query[0..got]);
return self.answer(io, conn, request, conn.payload.query[0..got]);
},
else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep),
}
@@ -490,8 +385,8 @@ pub const DohServer = struct {
.tcp,
address.NetAddress.fromIp(conn.peer),
query,
&conn.reply,
&conn.scratch,
&conn.payload.reply,
&conn.payload.scratch,
);
switch (outcome) {
.drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false),
@@ -514,7 +409,7 @@ pub const DohServer = struct {
extra_headers: []const http.Header,
keep_alive: bool,
) error{ WriteFailed, HttpExpectationFailed }!Next {
bump(&self.stats.bad_requests);
listener.bump(&self.stats.bad_requests);
try request.respond(body, .{
.status = status,
.keep_alive = keep_alive,
@@ -525,73 +420,8 @@ pub const DohServer = struct {
// `connection: close` either way, and the loop must agree.
return if (keep_alive and request.head.keep_alive) .keep_open else .close;
}
fn claim(self: *DohServer, io: std.Io, stream: net.Stream) Claim {
// Uncancelable: this section takes no Io and never blocks on a peer, so
// losing the lock mid-update would leak a slot for nothing.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const outcome = decideClaim(self.conns, self.shutdown_begun);
switch (outcome) {
.slot => |index| {
self.conns[index].stream = stream;
self.conns[index].peer = stream.socket.address;
self.conns[index].conn_state = .active;
},
.at_capacity, .shutting_down => {},
}
return outcome;
}
fn finish(self: *DohServer, io: std.Io, index: usize) void {
const conn = &self.conns[index];
self.mutex.lockUncancelable(io);
conn.conn_state = .closing;
self.mutex.unlock(io);
// The socket is released even when this task is being torn down: the
// next cancelable call would otherwise skip the close.
const prev = io.swapCancelProtection(.blocked);
conn.stream.close(io);
_ = io.swapCancelProtection(prev);
self.mutex.lockUncancelable(io);
conn.conn_state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones under one
/// hold of the mutex, so no `claim` can slip between the two.
fn beginShutdown(self: *DohServer, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.conn_state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
log.debug("doh connection shutdown failed: {t}", .{err});
};
}
}
};
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
/// every race outcome whether `conn.tls` holds a context that must be closed.
fn handshake(
gpa: Allocator,
conn: *DohServer.Conn,
ctx: *tls_server.ServerContext,
io: std.Io,
handshook: *bool,
) anyerror!void {
try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf);
handshook.* = true;
}
const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request;
/// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an
@@ -655,85 +485,6 @@ fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 {
return dest[0..len];
}
/// The whole claim rule, without the mutex, so it is testable without a backend.
fn decideClaim(conns: []const DohServer.Conn, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
for (conns, 0..) |*conn, index| {
if (conn.conn_state == .free) return .{ .slot = index };
}
return .at_capacity;
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const RaceResult = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the budget and cancels the loser
/// (tcp_server's arrangement: no stream operation in 0.16.0 takes a timeout).
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) RaceResult {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// peer went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
/// The composition root's entry point: bind, serve, release. A bind failure is
/// warned and swallowed (ruling 1, the web precedent): DoH failing to come up
/// must not stop nxdns answering plain DNS.
pub fn serve(
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
certs: *cert_store.CertStore,
) void {
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
return;
};
var server: DohServer = DohServer.listen(gpa, io, bind_address, h, certs, .{}) catch |err| {
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return;
};
defer server.deinit(gpa, io);
log.info("doh listener on {f}", .{server.boundAddress()});
server.serve(io);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -749,41 +500,6 @@ const local_tables_mod = @import("local_tables.zig");
const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig");
fn testConns(count: usize) ![]DohServer.Conn {
const conns = try testing.allocator.alloc(DohServer.Conn, count);
for (conns) |*conn| conn.conn_state = .free;
return conns;
}
test "the connection pool hands out every slot once, then refuses" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
conns[0].conn_state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
conns[1].conn_state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
}
test "a closing slot is not reused until it is free" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].conn_state = .closing;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
conns[0].conn_state = .free;
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
}
test "shutdown outranks capacity and does not consume the slot" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
}
test "framesBody sees framing in either header and none in content-length: 0" {
try testing.expect(framesBody(.chunked, null));
try testing.expect(framesBody(.none, 4));
@@ -937,7 +653,7 @@ const Harness = struct {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
errdefer hx.server.deinit(testing.allocator, hio);
errdefer hx.server.deinit(hio);
hx.group = .init;
try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio });
@@ -945,7 +661,7 @@ const Harness = struct {
fn stop(hx: *Harness) void {
const hio = hx.threaded.io();
hx.server.deinit(testing.allocator, hio);
hx.server.deinit(hio);
hx.group.await(hio) catch |err| switch (err) {
error.Canceled => unreachable,
};
@@ -977,7 +693,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))
defer select.cancelDiscard();
try select.concurrent(.work, f, args);
try select.concurrent(.expiry, expire, .{ io, test_budget });
try select.concurrent(.expiry, listener.expire, .{ io, test_budget });
switch (try select.await()) {
.work => |result| return result,
+88 -426
View File
@@ -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);
+568
View File
@@ -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)));
}
+1 -1
View File
@@ -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
View File
@@ -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)));
}
+15 -15
View File
@@ -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,
};
+47 -89
View File
@@ -38,35 +38,23 @@ const list_clients_sql =
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
var stmt = try database.prepare(list_clients_sql);
defer stmt.deinit();
return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
}
var out: std.ArrayList(model.Client) = .empty;
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeClients(gpa, out.items);
while (try stmt.step()) {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which
// is exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is
// exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
return .{ .ip = ip, .name = name, .group = group };
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| {
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
crud.freeRows(model.Client, gpa, items);
}
const insert_client_sql =
@@ -154,31 +142,22 @@ const list_client_prefixes_sql =
;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
var stmt = try database.prepare(list_client_prefixes_sql);
defer stmt.deinit();
return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
}
var out: std.ArrayList(model.ClientPrefix) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixes(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
return .{ .prefix = prefix, .group = group, .priority = priority };
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
crud.freeRows(model.ClientPrefix, gpa, items);
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
@@ -257,29 +236,15 @@ const get_client_sql =
/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
var stmt = try database.prepare(list_client_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientRows(gpa, out.items);
while (try stmt.step()) {
const row = try readClientRow(&stmt, gpa);
errdefer freeClientRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
}
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
gpa.free(row.ip);
gpa.free(row.name);
gpa.free(row.group);
crud.freeRow(ClientRow, gpa, row);
}
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
for (items) |item| freeClientRow(gpa, item);
crud.freeRows(ClientRow, gpa, items);
}
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
@@ -383,39 +348,32 @@ const list_client_prefix_rows_sql =
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
var stmt = try database.prepare(list_client_prefix_rows_sql);
defer stmt.deinit();
return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
}
var out: std.ArrayList(ClientPrefixRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixRows(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
try out.append(gpa, .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
});
}
return out;
fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
return .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
};
}
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
gpa.free(row.prefix);
gpa.free(row.group);
crud.freeRow(ClientPrefixRow, gpa, row);
}
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
for (items) |item| freeClientPrefixRow(gpa, item);
crud.freeRows(ClientPrefixRow, gpa, items);
}
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
+190
View File
@@ -8,8 +8,14 @@
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
//! handler layer maps it to 409. Each mutation documents which constraint of
//! `config_schema.ddl_v1` can fire.
//!
//! `listRows` and `freeRows` are the read half. Every `list*` function in this
//! directory reads rows into a `std.ArrayList` under the same unwind rules, and
//! the errdefer ordering those rules need is easy to write backwards. It is
//! written once here.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
@@ -25,6 +31,102 @@ pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
if (database.changes() == 0) return error.NotFound;
}
/// Reads every row `sql` produces into a list, with the memory-safety
/// choreography every `list*` function in this directory shares.
///
/// `readRow` allocates the row's owning fields from `gpa` and carries its own
/// per-column `errdefer`s, so a row that fails halfway releases the columns it
/// already read. This function owns everything around that: a failure after the
/// first append releases the rows already in the list and then the list itself.
///
/// The result is the caller's: free the rows with `freeRows` (or the repository
/// shim over it) and then `deinit` the list.
pub fn listRows(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
) db.Error!std.ArrayList(Row) {
return listRowsBound(Row, database, gpa, sql, readRow, .{});
}
/// `listRows` for a statement with parameters. `args` is a tuple bound to
/// positions 1..n in order.
pub fn listRowsBound(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
args: anytype,
) db.Error!std.ArrayList(Row) {
var stmt = try database.prepare(sql);
defer stmt.deinit();
inline for (args, 0..) |arg, i| try bindArg(&stmt, i + 1, arg);
var out: std.ArrayList(Row) = .empty;
// Order matters: `errdefer`s run in reverse, so the free pass is declared
// *after* `deinit` to run *before* it. The other order reads `out.items`
// after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeRows(Row, gpa, out.items);
while (try stmt.step()) {
const row = try readRow(&stmt, gpa);
errdefer freeRow(Row, gpa, row);
try out.append(gpa, row);
}
return out;
}
fn bindArg(stmt: *db.Stmt, idx: c_int, arg: anytype) db.Error!void {
const Arg = @TypeOf(arg);
if (Arg == []const u8 or Arg == []u8) return stmt.bindText(idx, arg);
return switch (@typeInfo(Arg)) {
.bool => stmt.bindInt(idx, @intFromBool(arg)),
.int, .comptime_int => stmt.bindInt(idx, arg),
else => @compileError("crud.listRowsBound: cannot bind a " ++ @typeName(Arg)),
};
}
/// Releases every owning field of every row `listRows` produced.
pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void {
for (items) |item| freeRow(Row, gpa, item);
}
/// Releases the owning fields of one row.
///
/// A repository row owns its heap memory in exactly two shapes: `[]const u8`
/// and `?[]const u8` (`SourceRow.checksum` is the optional one — a shallow
/// slice-only reflection would leak its payload). Every other field must be a
/// plain value the row does not own. A field of any other shape is a
/// `@compileError`, so a row that grows a nested allocation cannot start
/// leaking silently: whoever adds it has to teach this function first.
pub fn freeRow(comptime Row: type, gpa: Allocator, row: Row) void {
switch (@typeInfo(Row)) {
.@"struct" => |info| inline for (info.fields) |field| {
freeField(field.type, @typeName(Row) ++ "." ++ field.name, gpa, @field(row, field.name));
},
else => freeField(Row, @typeName(Row), gpa, row),
}
}
fn freeField(comptime Field: type, comptime where: []const u8, gpa: Allocator, value: Field) void {
if (Field == []const u8 or Field == []u8) return gpa.free(value);
if (Field == ?[]const u8 or Field == ?[]u8) return if (value) |owned| gpa.free(owned);
comptime assertUnowning(Field, where);
}
fn assertUnowning(comptime Field: type, comptime where: []const u8) void {
switch (@typeInfo(Field)) {
.bool, .int, .float, .@"enum", .void => {},
.optional => |info| assertUnowning(info.child, where),
else => @compileError("crud.freeRow: " ++ where ++ " is a " ++ @typeName(Field) ++
", which is neither a plain value nor an owning slice; teach freeRow how to release it"),
}
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -78,3 +180,91 @@ test "execStrict reports NotFound for an id no row holds" {
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
}
/// Carries both owning shapes a repository row may hold: a `[]const u8` that is
/// always there, and the `?[]const u8` of `SourceRow.checksum`.
const TestRow = struct {
id: i64,
url: []const u8,
checksum: ?[]const u8,
};
const test_rows_sql = "SELECT id, url, checksum FROM blocklist_sources ORDER BY id";
fn readTestRow(stmt: *db.Stmt, gpa: Allocator) db.Error!TestRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const checksum = try stmt.columnTextAllocOrNull(gpa, 2);
errdefer if (checksum) |value| gpa.free(value);
return .{ .id = stmt.columnInt(0), .url = url, .checksum = checksum };
}
/// Two rows: the first carries a checksum, the second leaves it NULL, so one
/// read exercises both arms of the optional.
fn seedTestRows(database: *db.Db) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name, checksum) VALUES
\\ (1, 'https://a.example/list.txt', 'A', 'aaaa'),
\\ (2, 'https://b.example/list.txt', 'B', NULL);
);
}
test "listRows reads every row and freeRows releases both owning shapes" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, testing.allocator, test_rows_sql, readTestRow);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
// The leak detector is what proves this payload is released.
try testing.expectEqualStrings("aaaa", rows.items[0].checksum.?);
try testing.expectEqual(@as(?[]const u8, null), rows.items[1].checksum);
}
fn listRowsUnderFailure(gpa: Allocator) !void {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, gpa, test_rows_sql, readTestRow);
defer rows.deinit(gpa);
defer freeRows(TestRow, gpa, rows.items);
// A row with a non-null checksum must be in the result, or the failure
// injection never reaches the optional's allocation.
try testing.expect(rows.items[0].checksum != null);
}
test "listRows is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listRowsUnderFailure, .{});
}
test "listRowsBound binds its arguments in tuple order" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRowsBound(
TestRow,
&database,
testing.allocator,
"SELECT id, url, checksum FROM blocklist_sources WHERE id > ?1 AND name = ?2",
readTestRow,
.{ @as(i64, 1), @as([]const u8, "B") },
);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(@as(i64, 2), rows.items[0].id);
}
test "freeRows over a row type with no owning field is a no-op" {
// `listGroupSourceIds` reads a bare `i64`; the reflection must accept a Row
// that is not a struct at all.
freeRows(i64, testing.allocator, &.{ 1, 2, 3 });
}
+49 -64
View File
@@ -27,26 +27,23 @@ const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
return crud.listRows(
model.Group,
database,
gpa,
"SELECT name, safe_search FROM groups ORDER BY name",
readGroup,
);
}
var out: std.ArrayList(model.Group) = .empty;
// Order matters: `errdefer`s run in reverse, so `freeGroups` must be
// declared *after* `deinit` to run *before* it. The other order reads
// `out.items` after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
}
return out;
fn readGroup(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Group {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
return .{ .name = name, .safe_search = stmt.columnBool(1) };
}
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name);
crud.freeRows(model.Group, gpa, items);
}
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
@@ -91,28 +88,19 @@ const list_group_sources_sql =
/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
var stmt = try database.prepare(list_group_sources_sql);
defer stmt.deinit();
return crud.listRows(model.GroupSource, database, gpa, list_group_sources_sql, readGroupSource);
}
var out: std.ArrayList(model.GroupSource) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupSources(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
try out.append(gpa, .{ .group = group, .source_url = source_url });
}
return out;
fn readGroupSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.GroupSource {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
return .{ .group = group, .source_url = source_url };
}
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.source_url);
}
crud.freeRows(model.GroupSource, gpa, items);
}
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
@@ -145,31 +133,27 @@ pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool };
/// Same order as `listGroups`; every string is a heap copy owned by `gpa`.
pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) {
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
return crud.listRows(
GroupRow,
database,
gpa,
"SELECT id, name, safe_search FROM groups ORDER BY name",
readGroupRow,
);
}
var out: std.ArrayList(GroupRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupRows(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.id = stmt.columnInt(0),
.name = name,
.safe_search = stmt.columnBool(2),
});
}
return out;
fn readGroupRow(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupRow {
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
return .{ .id = stmt.columnInt(0), .name = name, .safe_search = stmt.columnBool(2) };
}
pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
gpa.free(row.name);
crud.freeRow(GroupRow, gpa, row);
}
pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
for (items) |item| freeGroupRow(gpa, item);
crud.freeRows(GroupRow, gpa, items);
}
pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
@@ -177,11 +161,7 @@ pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return .{
.id = stmt.columnInt(0),
.name = try stmt.columnTextAlloc(gpa, 1),
.safe_search = stmt.columnBool(2),
};
return try readGroupRow(&stmt, gpa);
}
/// `error.Constraint`: `groups.name` is UNIQUE.
@@ -220,14 +200,19 @@ pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
/// `group_id` yields an empty list, not an error: the caller that needs the
/// distinction reads the group itself.
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) {
var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
return crud.listRowsBound(
i64,
database,
gpa,
"SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id",
readSourceId,
.{group_id},
);
}
var out: std.ArrayList(i64) = .empty;
errdefer out.deinit(gpa);
while (try stmt.step()) try out.append(gpa, stmt.columnInt(0));
return out;
fn readSourceId(stmt: *db.Stmt, gpa: Allocator) db.Error!i64 {
_ = gpa;
return stmt.columnInt(0);
}
/// Replaces one group's whole source assignment inside a transaction, so a
+40 -74
View File
@@ -25,34 +25,23 @@ const list_local_records_sql =
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) {
var stmt = try database.prepare(list_local_records_sql);
defer stmt.deinit();
return crud.listRows(model.LocalRecord, database, gpa, list_local_records_sql, readLocalRecord);
}
var out: std.ArrayList(model.LocalRecord) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeLocalRecords(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl });
}
return out;
fn readLocalRecord(stmt: *db.Stmt, gpa: Allocator) db.Error!model.LocalRecord {
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
return .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl };
}
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| {
gpa.free(item.name);
gpa.free(item.value);
}
crud.freeRows(model.LocalRecord, gpa, items);
}
const insert_local_record_sql =
@@ -83,28 +72,25 @@ pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
// ---------------------------------------------------------------------------
pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) {
var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
return crud.listRows(
model.ForwardZone,
database,
gpa,
"SELECT zone, resolver FROM forward_zones ORDER BY zone",
readForwardZone,
);
}
var out: std.ArrayList(model.ForwardZone) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZones(gpa, out.items);
while (try stmt.step()) {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
try out.append(gpa, .{ .zone = zone, .resolver = resolver });
}
return out;
fn readForwardZone(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ForwardZone {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
return .{ .zone = zone, .resolver = resolver };
}
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| {
gpa.free(item.zone);
gpa.free(item.resolver);
}
crud.freeRows(model.ForwardZone, gpa, items);
}
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
@@ -145,28 +131,15 @@ const list_local_record_rows_sql =
/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`.
pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) {
var stmt = try database.prepare(list_local_record_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(LocalRecordRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeLocalRecordRows(gpa, out.items);
while (try stmt.step()) {
const row = try readLocalRecordRow(&stmt, gpa);
errdefer freeLocalRecordRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(LocalRecordRow, database, gpa, list_local_record_rows_sql, readLocalRecordRow);
}
pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void {
gpa.free(row.name);
gpa.free(row.value);
crud.freeRow(LocalRecordRow, gpa, row);
}
pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void {
for (items) |item| freeLocalRecordRow(gpa, item);
crud.freeRows(LocalRecordRow, gpa, items);
}
pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow {
@@ -232,28 +205,21 @@ pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const
/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`.
pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) {
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(ForwardZoneRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZoneRows(gpa, out.items);
while (try stmt.step()) {
const row = try readForwardZoneRow(&stmt, gpa);
errdefer freeForwardZoneRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(
ForwardZoneRow,
database,
gpa,
"SELECT id, zone, resolver FROM forward_zones ORDER BY zone",
readForwardZoneRow,
);
}
pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void {
gpa.free(row.zone);
gpa.free(row.resolver);
crud.freeRow(ForwardZoneRow, gpa, row);
}
pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void {
for (items) |item| freeForwardZoneRow(gpa, item);
crud.freeRows(ForwardZoneRow, gpa, items);
}
pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow {
+16 -40
View File
@@ -37,34 +37,23 @@ const list_sql =
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
return crud.listRows(model.Rule, database, gpa, list_sql, readRule);
}
var out: std.ArrayList(model.Rule) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeRules(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action });
}
return out;
fn readRule(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Rule {
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
return .{ .group = group, .pattern = pattern, .kind = kind, .action = action };
}
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.pattern);
}
crud.freeRows(model.Rule, gpa, items);
}
const insert_sql =
@@ -132,28 +121,15 @@ const get_rule_sql =
/// Same order as `listRules`; every string is a heap copy owned by `gpa`.
pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
var stmt = try database.prepare(list_rule_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(RuleRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeRuleRows(gpa, out.items);
while (try stmt.step()) {
const row = try readRuleRow(&stmt, gpa);
errdefer freeRuleRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(RuleRow, database, gpa, list_rule_rows_sql, readRuleRow);
}
pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
gpa.free(row.group);
gpa.free(row.pattern);
crud.freeRow(RuleRow, gpa, row);
}
pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void {
for (items) |item| freeRuleRow(gpa, item);
crud.freeRows(RuleRow, gpa, items);
}
pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
+16 -20
View File
@@ -15,38 +15,34 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const InsertContext = context.InsertContext;
/// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key");
defer stmt.deinit();
return crud.listRows(
model.SettingPair,
database,
gpa,
"SELECT key, value FROM settings ORDER BY key",
readSetting,
);
}
var out: std.ArrayList(model.SettingPair) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSettings(gpa, out.items);
while (try stmt.step()) {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = key, .value = value });
}
return out;
fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
return .{ .key = key, .value = value };
}
/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
for (items) |item| {
gpa.free(item.key);
gpa.free(item.value);
}
crud.freeRows(model.SettingPair, gpa, items);
}
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
+24 -44
View File
@@ -25,35 +25,24 @@ const list_sql =
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource);
}
var out: std.ArrayList(model.BlocklistSource) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeBlocklistSources(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
});
}
return out;
fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
return .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
};
}
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
}
crud.freeRows(model.BlocklistSource, gpa, items);
}
const insert_sql =
@@ -125,21 +114,7 @@ const list_rows_sql = row_columns_sql ++ " ORDER BY url";
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
var stmt = try database.prepare(list_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(SourceRow) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSourceRows(gpa, out.items);
while (try stmt.step()) {
const row = try readSourceRow(&stmt, gpa);
errdefer freeSourceRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow);
}
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
@@ -163,14 +138,13 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
};
}
/// Frees `url`, `name` and the `checksum` payload when it is not null.
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
gpa.free(row.url);
gpa.free(row.name);
if (row.checksum) |value| gpa.free(value);
crud.freeRow(SourceRow, gpa, row);
}
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
for (items) |item| freeSourceRow(gpa, item);
crud.freeRows(SourceRow, gpa, items);
}
const update_stats_sql =
@@ -438,6 +412,12 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
var rows = try listSourceRows(&database, gpa);
defer rows.deinit(gpa);
defer freeSourceRows(gpa, rows.items);
// `checksum` is the one allocated optional in this directory. Without a
// non-null one in the result the injection never reaches its allocation and
// this test stops covering the shape it exists for. Row id 1 sorts last:
// `seedSources` inserts `c.example` first and the list orders by url.
try testing.expect(rows.items[2].checksum != null);
}
test "listSourceRows is leak-safe under allocation failure" {
+22 -42
View File
@@ -21,38 +21,31 @@ const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare(
return crud.listRows(
model.UpstreamServer,
database,
gpa,
"SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url",
readUpstream,
);
defer stmt.deinit();
}
var out: std.ArrayList(model.UpstreamServer) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeUpstreams(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
try out.append(gpa, .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
});
}
return out;
fn readUpstream(stmt: *db.Stmt, gpa: Allocator) db.Error!model.UpstreamServer {
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
return .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
};
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.tls_name);
}
crud.freeRows(model.UpstreamServer, gpa, items);
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
@@ -101,28 +94,15 @@ const get_upstream_sql =
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`.
pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
var stmt = try database.prepare(list_upstream_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(UpstreamRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeUpstreamRows(gpa, out.items);
while (try stmt.step()) {
const row = try readUpstreamRow(&stmt, gpa);
errdefer freeUpstreamRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(UpstreamRow, database, gpa, list_upstream_rows_sql, readUpstreamRow);
}
pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
gpa.free(row.url);
gpa.free(row.tls_name);
crud.freeRow(UpstreamRow, gpa, row);
}
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
for (items) |item| freeUpstreamRow(gpa, item);
crud.freeRows(UpstreamRow, gpa, items);
}
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {
+1
View File
@@ -24,6 +24,7 @@ comptime {
_ = @import("upstream/pool.zig");
_ = @import("upstream/dot_client.zig");
_ = @import("upstream/dot_client_live_test.zig");
_ = @import("server/listener.zig");
_ = @import("server/handler.zig");
_ = @import("server/udp_server.zig");
_ = @import("server/tcp_server.zig");
+187 -4
View File
@@ -20,6 +20,13 @@ pub const media_type = "application/dns-message";
pub const min_request_buf = 512;
pub const min_transfer_buf = 1024;
/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an
/// upstream with. They live here rather than beside either caller because a
/// probe that used a different buffer than the running server would answer a
/// question nobody asked.
pub const default_request_buf_len = 1024;
pub const default_transfer_buf_len = 4096;
pub const DohClient = struct {
/// Caller-owned; shared across endpoints, pools connections.
http: *std.http.Client,
@@ -110,11 +117,11 @@ pub const DohClient = struct {
defer req.deinit();
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
return mapError(err, .send);
return mapError(sendCause(&req, err), .send);
// An empty redirect buffer is legal under `.not_allowed`: a redirect
// is an error before the location is ever read.
var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive);
var resp = req.receiveHead(&.{}) catch |err| return mapError(headCause(&req, err), .receive);
if (resp.head.status != .ok) return error.HttpStatus;
// `head.content_type` points into memory that `resp.reader` invalidates,
@@ -129,7 +136,7 @@ pub const DohClient = struct {
var ended = false;
while (len < response_buf.len) {
const n = body.readSliceShort(response_buf[len..]) catch |err|
return mapError(err, .receive);
return mapError(bodyCause(&resp, err), .receive);
len += n;
if (n == 0) {
ended = true;
@@ -140,7 +147,8 @@ pub const DohClient = struct {
// `response_buf` filled exactly. One more read separates a message
// that fits from one that was cut off.
var probe: [1]u8 = undefined;
const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive);
const n = body.readSliceShort(&probe) catch |err|
return mapError(bodyCause(&resp, err), .receive);
if (n != 0) return error.ResponseTooLarge;
}
@@ -166,6 +174,48 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
};
}
const Connection = std.http.Client.Connection;
const Request = std.http.Client.Request;
const Response = std.http.Client.Response;
/// `std.Io.Writer` collapses every send failure to `error.WriteFailed` and
/// stashes the cause on the connection's socket writer. Unwrapping it is what
/// keeps `error.Canceled` and the local resource errors out of the peer fault
/// group, exactly as `concreteWrite` does for DoT.
fn sendCause(req: *const Request, err: anyerror) anyerror {
if (err != error.WriteFailed) return err;
const connection = req.connection orelse return err;
return connection.stream_writer.err orelse err;
}
/// `receiveHead` collapses a transport failure to `error.ReadFailed` and names
/// `Connection.getReadError` as the accessor for the concrete cause.
fn headCause(req: *const Request, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
const connection = req.connection orelse return err;
return readCause(connection, err);
}
/// A body read reports two different kinds of failure through the same
/// `error.ReadFailed`. An HTTP framing fault lands on the response, and only a
/// read that never reached the framing leaves the connection's cause, so the
/// response is consulted first.
fn bodyCause(resp: *const Response, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
if (resp.bodyErr()) |cause| return cause;
const connection = resp.request.connection orelse return err;
return readCause(connection, err);
}
/// `Connection.getReadError` reads the socket reader's stashed cause with `.?`.
/// On a plain connection that is its only source, so calling it with nothing
/// stashed would panic rather than return null; the guard keeps this unwrap
/// total on the path this client can reach without TLS.
fn readCause(connection: *const Connection, err: anyerror) anyerror {
if (connection.protocol == .plain and connection.stream_reader.err == null) return err;
return connection.getReadError() orelse err;
}
/// RFC 8484 §6: the response media type is `application/dns-message`. The
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
/// per RFC 9110 §8.3.1.
@@ -269,3 +319,136 @@ test "mapError maps remaining errors by phase" {
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
}
/// Only the fields the unwrap helpers read are set. The rest of a `Connection`
/// is two buffered streams, a host name and a pool node, none of which the
/// helpers touch.
///
/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's
/// stashed cause through `@fieldParentPtr`, which on a stub would read memory
/// that was never a `Tls`. What the test is about — that the accessor is
/// consulted at all — is the same on both protocols.
fn stubConnection(
read_err: ?std.Io.net.Stream.Reader.Error,
write_err: ?std.Io.net.Stream.Writer.Error,
) Connection {
var connection: Connection = undefined;
connection.protocol = .plain;
connection.stream_reader.err = read_err;
connection.stream_writer.err = write_err;
return connection;
}
fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request {
var req: Request = undefined;
req.connection = connection;
req.reader.body_err = body_err;
return req;
}
test "the send unwrap keeps a cancelled write out of the peer fault group" {
var connection = stubConnection(null, error.Canceled);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the send unwrap keeps a local resource write failure out of the peer fault group" {
var connection = stubConnection(null, error.SystemResources);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
test "the send unwrap reports a peer side cause as a send fault" {
var connection = stubConnection(null, error.ConnectionResetByPeer);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
}
test "the head unwrap keeps a local resource read failure out of the peer fault group" {
var connection = stubConnection(error.SystemResources, null);
var req = stubRequest(&connection, null);
const mapped = mapError(headCause(&req, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
var canceled = stubConnection(error.Canceled, null);
var canceled_req = stubRequest(&canceled, null);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapError(headCause(&canceled_req, error.ReadFailed), .receive),
);
}
test "the head unwrap reports a peer side cause as a receive fault" {
var connection = stubConnection(error.ConnectionResetByPeer, null);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.ReadFailed), .receive),
);
}
test "the body unwrap keeps a cancelled read out of the peer fault group" {
var connection = stubConnection(error.Canceled, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
const mapped = mapError(bodyCause(&resp, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the body unwrap prefers an http framing fault over the connection" {
// A truncated chunk is the peer's doing and the connection carries no
// cause at all, so reading the connection first would report the wrong
// thing on the one path where both could be set.
var connection = stubConnection(null, null);
var req = stubRequest(&connection, error.HttpChunkTruncated);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps report the collapsed error when no cause was stored" {
var connection = stubConnection(null, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed));
try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed));
try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps pass a non-collapsed error through untouched" {
// A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any
// other error already names itself, so the stash must not be read over it.
var connection = stubConnection(error.Canceled, error.Canceled);
var req = stubRequest(&connection, error.HttpChunkInvalid);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream));
try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid));
try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.HttpHeadersInvalid), .receive),
);
}
+19 -39
View File
@@ -179,9 +179,9 @@ pub const DotClient = struct {
var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
defer transport.closeBlocked(io, &stream);
// `TlsStream` is pinned: it holds its reader and writer by value and the
// TLS client points at them, so it must not move after `init`.
@@ -205,9 +205,9 @@ pub const DotClient = struct {
.verify_name = self.verify_name,
.cause = cause,
} })});
return mapPhase(cause, error.TlsFailed);
return transport.mapPhase(cause, error.TlsFailed);
};
defer closeTls(io, &tls_stream);
defer transport.closeBlocked(io, &tls_stream);
const writer = tls_stream.writer();
const prefix = transport.framePrefix(@intCast(query.len));
@@ -241,7 +241,7 @@ pub const DotClient = struct {
/// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
/// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
/// local resource failure that must not count against the upstream's
/// health. Scanning here keeps the concrete error for `mapPhase`.
/// health. Scanning here keeps the concrete error for `transport.mapPhase`.
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
{
try self.bundle_lock.lockShared(io);
@@ -259,31 +259,11 @@ pub const DotClient = struct {
self.bundle.deinit(self.gpa);
self.bundle.* = .empty;
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return mapPhase(err, error.TlsFailed);
return transport.mapPhase(err, error.TlsFailed);
};
}
};
/// The pool cancels this task when the attempt budget expires. The next
/// cancelable `Io` call in the `defer` chain would then return `error.Canceled`
/// and skip the close, leaking the socket, so the close runs with cancellation
/// blocked.
fn closeStream(io: std.Io, stream: *net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close();
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// The handshake reads and writes through the socket reader and writer, so a
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
/// `error.WriteFailed` with the cause stashed on those two. Without this,
@@ -318,11 +298,11 @@ fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
}
fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteWrite(stream, err), error.SendFailed);
return transport.mapPhase(concreteWrite(stream, err), error.SendFailed);
}
fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteRead(stream, err), error.ReceiveFailed);
return transport.mapPhase(concreteRead(stream, err), error.ReceiveFailed);
}
const testing = std.testing;
@@ -460,14 +440,14 @@ fn stubStream(
test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
var stream = stubStream(error.Canceled, null, null);
const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
const mapped = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
var stream = stubStream(null, error.SystemResources, null);
const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
const mapped = transport.mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
@@ -476,13 +456,13 @@ test "the handshake unwrap reports a peer side cause as a TLS fault" {
var reset = stubStream(error.ConnectionResetByPeer, null, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
);
var refused = stubStream(null, error.ConnectionRefused, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
);
}
@@ -492,7 +472,7 @@ test "the handshake unwrap reports a TLS fault when no cause was stored" {
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
);
}
@@ -505,11 +485,11 @@ test "the handshake unwrap passes other errors through untouched" {
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
);
}
@@ -547,24 +527,24 @@ test "a CA bundle scan failure keeps local resource errors out of the peer fault
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.TlsFailed)),
transport.group(transport.mapPhase(err, error.TlsFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.TlsFailed),
transport.mapPhase(error.Canceled, error.TlsFailed),
);
// A missing or corrupt bundle is not this process running out of anything,
// so it stays a TLS fault.
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.FileNotFound, error.TlsFailed),
transport.mapPhase(error.FileNotFound, error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
);
}
+6 -59
View File
@@ -181,28 +181,10 @@ pub const Pool = struct {
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]LoopOutcome = undefined;
var race: std.Io.Select(LoopOutcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.loop, exchangeLoopLen, .{
const len = try transport.raceWithin(io, self.timeouts.total, exchangeLoopLen, .{
self, io, query, response_buf,
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.loop => |result| return response_buf[0..try result],
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the budget ran out.
try result;
return error.Timeout;
},
}
});
return response_buf[0..len];
}
/// The two-pass failover loop, as a raceable task. It returns the reply's
@@ -300,9 +282,7 @@ pub const Pool = struct {
return count;
}
/// One exchange raced against the per-attempt budget. No stream read or
/// write in 0.16.0 takes a timeout, so the budget is a second task and the
/// loser is canceled.
/// One exchange raced against the per-attempt budget.
fn attempt(
self: *Pool,
io: std.Io,
@@ -310,28 +290,9 @@ pub const Pool = struct {
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, transport.Client.exchange, .{
return transport.raceWithin(io, self.timeouts.attempt, transport.Client.exchange, .{
entry_client, io, query, response_buf,
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.attempt }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the upstream is slow.
try result;
return error.Timeout;
},
}
});
}
fn entryAvailable(
@@ -367,20 +328,6 @@ pub const Pool = struct {
}
};
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
const LoopOutcome = union(enum) {
loop: transport.ExchangeError!usize,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
const testing = std.testing;
/// A query for example.com A: id 0x1234, RD set, one question.
+202
View File
@@ -229,6 +229,97 @@ pub fn mapLocal(err: anyerror) ?ExchangeError {
};
}
/// Names the peer fault of the phase the call site is in, unless the error is
/// one `mapLocal` claims for this process. Every transport classifies its
/// failures through this one function.
pub fn mapPhase(err: anyerror, phase: PeerFault) ExchangeError {
return mapLocal(err) orelse phase;
}
/// Closes `target` with cancellation blocked.
///
/// A transport's close runs from a `defer` chain that a lost timeout race is
/// unwinding. The next cancelable `Io` call in that chain returns
/// `error.Canceled` and skips the close, leaking the descriptor, so the close
/// swaps cancellation protection for the duration.
///
/// `net.Stream` and `net.Socket` close through an `Io`; `tls_client.TlsStream`
/// owns the one it was built with and takes none. Both shapes are accepted so
/// that one helper covers every close in the transports.
pub fn closeBlocked(io: std.Io, target: anytype) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
const Target = @typeInfo(@TypeOf(target)).pointer.child;
if (@typeInfo(@TypeOf(Target.close)).@"fn".params.len == 2) {
target.close(io);
} else {
target.close();
}
}
/// The payload of `f`'s return type, which `raceWithin` requires to be
/// `ExchangeError!T`. A raced function with any other error set would let a
/// failure reach the pool without passing through `group`.
fn RacedPayload(comptime f: anytype) type {
const info = @typeInfo(@TypeOf(f));
if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f)));
const Return = info.@"fn".return_type orelse
@compileError("raceWithin needs a function with a concrete return type");
const union_info = switch (@typeInfo(Return)) {
.error_union => |u| u,
else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)),
};
if (union_info.error_set != ExchangeError)
@compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return));
return union_info.payload;
}
/// Runs `f(args...)` raced against `budget`, and cancels the loser.
///
/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second
/// task rather than a socket option. This is the one copy of that harness: the
/// pool races an attempt and its whole failover loop through it, and the
/// forward client races its TCP exchange.
///
/// `error.Timeout` means the budget won. A canceled sleep means the whole task
/// is being torn down rather than the budget running out, so it stays
/// `error.Canceled`. A backend that cannot start a second task is
/// `error.SystemResources`, which `group` keeps off the peer's health.
pub fn raceWithin(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f) {
const Outcome = union(enum) {
raced: ExchangeError!RacedPayload(f),
expiry: std.Io.Cancelable!void,
};
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.raced, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.raced => |result| return result,
.expiry => |result| {
try result;
return error.Timeout;
},
}
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// A thing that sends one DNS message and returns one validated DNS message.
/// Implemented by DohClient, DotClient, Pool, and test fakes.
pub const Client = struct {
@@ -430,6 +521,117 @@ test "mapLocal folds only local and cancellation errors" {
try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed));
}
test "mapPhase names the phase unless the error is this process's own" {
try testing.expectEqual(ExchangeError.Canceled, mapPhase(error.Canceled, error.ReceiveFailed));
try testing.expectEqual(
ExchangeError.SystemResources,
mapPhase(error.SystemResources, error.ConnectFailed),
);
try testing.expectEqual(
ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
try testing.expectEqual(
Group.local_resource,
group(mapPhase(error.OutOfMemory, error.SendFailed)),
);
}
/// Stands in for whatever the pool or the forward client races. The variants
/// are the three ways such a task ends: in time, too late, or with a failure of
/// its own.
fn racedReply(io: std.Io, delay_ms: i64, result: ExchangeError!usize) ExchangeError!usize {
if (delay_ms != 0) {
const duration: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(delay_ms), .clock = .awake };
try duration.sleep(io);
}
return result;
}
test "raceWithin returns the raced value when it finishes inside the budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
const len = try raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, 7) });
try testing.expectEqual(@as(usize, 7), len);
}
test "raceWithin returns Timeout when the budget wins" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
const started = std.Io.Clock.awake.now(io);
try testing.expectError(
error.Timeout,
raceWithin(io, budget, racedReply, .{ io, 30_000, @as(ExchangeError!usize, 7) }),
);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Far under the raced task's own sleep, so the budget is provably what
// ended the call rather than the task finishing on its own.
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
}
test "raceWithin passes the raced task's own failure through" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
// A local resource error keeps its group: the race must not turn it into
// the peer fault the budget would have produced.
const failed = raceWithin(io, budget, racedReply, .{
io, 0, @as(ExchangeError!usize, error.OutOfMemory),
});
try testing.expectError(error.OutOfMemory, failed);
try testing.expectError(
error.ConnectFailed,
raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, error.ConnectFailed) }),
);
}
test "closeBlocked closes a target of either close shape" {
// The two shapes the transports use: a socket or a plain stream, which
// closes through the `Io`, and a `TlsStream`, which owns the one it was
// built with. Dispatching on the wrong one is a compile error, so
// instantiating both is the check.
//
// That the close runs with cancellation blocked is not asserted here: the
// Threaded backend's `swapCancelProtection` is a no-op off one of its own
// task threads, so a unit test cannot observe the state it sets.
const WithIo = struct {
closed: bool = false,
fn close(self: *@This(), io: std.Io) void {
_ = io;
self.closed = true;
}
};
const WithoutIo = struct {
closed: bool = false,
fn close(self: *@This()) void {
self.closed = true;
}
};
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var with_io: WithIo = .{};
closeBlocked(io, &with_io);
try testing.expect(with_io.closed);
var without_io: WithoutIo = .{};
closeBlocked(io, &without_io);
try testing.expect(without_io.closed);
}
test "a fake client satisfies the Client interface" {
const Fake = struct {
calls: usize = 0,
+15 -44
View File
@@ -87,10 +87,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.BlocklistSource,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -109,10 +106,7 @@ pub fn applyUpdate(
id: i64,
item: model.BlocklistSource,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -124,10 +118,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = sources_repo.deleteSource(database, id);
@@ -189,32 +180,19 @@ pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.Sour
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"),
};
const resource = mutations.Resource(.{
.Row = sources_repo.SourceRow,
.list = sources_repo.listSourceRows,
.get = sources_repo.getSource,
.remove = applyDelete,
.label = "a blocklist",
.plural = "blocklists",
.envelope = "blocklists",
});
const rows = sources_repo.listSourceRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists");
return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"),
};
const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -251,13 +229,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a blocklist");
}
return http_util.respondEmpty(request, .no_content);
}
/// `POST /api/blocklists/update`.
pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses);
+27 -55
View File
@@ -61,10 +61,7 @@ pub fn applyUpdate(
id: i64,
edit: clients_repo.ClientEdit,
) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = clients_repo.updateClient(database, id, edit);
@@ -75,10 +72,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = clients_repo.deleteClient(database, id);
@@ -94,10 +88,7 @@ pub fn applyReplacePrefixes(
arena: Allocator,
items: []const clients_repo.ClientPrefixInput,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
// Canonical duplicates are the same UNIQUE collision the database would
// report for identical text, so they answer 409 (ruling 9) before the
@@ -152,32 +143,33 @@ fn checkPrefixSet(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"),
};
const resource = mutations.Resource(.{
.Row = clients_repo.ClientRow,
.list = clients_repo.listClientRows,
.get = clients_repo.getClient,
.remove = applyDelete,
.label = "a client",
.plural = "clients",
.envelope = "clients",
});
const rows = clients_repo.listClientRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing clients");
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{});
}
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
const prefixes_resource = mutations.Resource(.{
.Row = clients_repo.ClientPrefixRow,
.list = clients_repo.listClientPrefixRows,
.get = null,
.remove = null,
.label = "a client prefix",
.plural = "client prefixes",
.envelope = "client_prefixes",
});
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a client"),
};
const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const listPrefixes = prefixes_resource.list;
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ClientBody, request) catch |err|
@@ -199,26 +191,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
return http_util.respondJson(request, .ok, found, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a client");
}
return http_util.respondEmpty(request, .no_content);
}
pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"),
};
const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");
return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
}
pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(PrefixesBody, request) catch |err|
return mutations.respondBadBody(request, err);
+18 -52
View File
@@ -54,10 +54,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.Group,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .fail = .{ .invalid = problem } };
}
@@ -78,10 +75,7 @@ pub fn applyUpdate(
id: i64,
item: model.Group,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .invalid = problem };
}
@@ -112,10 +106,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
}
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const outcome = deleteLocked(database, arena, id);
@@ -148,10 +139,7 @@ pub fn applySetSources(
id: i64,
source_ids: []const i64,
) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const outcome = groups_repo.setGroupSources(database, id, source_ids);
@@ -165,32 +153,19 @@ pub fn applySetSources(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing groups"),
};
const resource = mutations.Resource(.{
.Row = groups_repo.GroupRow,
.list = groups_repo.listGroupRows,
.get = groups_repo.getGroup,
.remove = applyDelete,
.label = "a group",
.plural = "groups",
.envelope = "groups",
});
const rows = groups_repo.listGroupRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing groups");
return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
};
const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -223,20 +198,11 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a group");
}
return http_util.respondEmpty(request, .no_content);
}
/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading a group");
const id = request.id.?;
const row = groups_repo.getGroup(database, request.arena, id) catch |err|
+31 -91
View File
@@ -94,10 +94,7 @@ pub fn applyCreateRecord(
arena: Allocator,
item: model.LocalRecord,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -116,10 +113,7 @@ pub fn applyUpdateRecord(
id: i64,
item: model.LocalRecord,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -131,10 +125,7 @@ pub fn applyUpdateRecord(
}
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -154,10 +145,7 @@ pub fn applyCreateZone(
arena: Allocator,
item: model.ForwardZone,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -176,10 +164,7 @@ pub fn applyUpdateZone(
id: i64,
item: model.ForwardZone,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -191,10 +176,7 @@ pub fn applyUpdateZone(
}
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -208,35 +190,20 @@ pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id
// local records: routes
// ---------------------------------------------------------------------------
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"),
};
const records_resource = mutations.Resource(.{
.Row = local_repo.LocalRecordRow,
.list = local_repo.listLocalRecordRows,
.get = local_repo.getLocalRecord,
.remove = applyDeleteRecord,
.label = "a local record",
.plural = "local records",
.envelope = "local_records",
.view = RecordView.from,
});
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing local records");
const views = try request.arena.alloc(RecordView, rows.items.len);
for (views, rows.items) |*view, row| view.* = .from(row);
return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{});
}
pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"),
};
const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a local record");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, RecordView.from(found), &.{});
}
pub const listRecords = records_resource.list;
pub const getRecord = records_resource.get;
pub const removeRecord = records_resource.remove;
pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(RecordBody, request) catch |err|
@@ -279,43 +246,23 @@ pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) Hand
}, &.{});
}
pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a local record");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// forward zones: routes
// ---------------------------------------------------------------------------
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"),
};
const zones_resource = mutations.Resource(.{
.Row = local_repo.ForwardZoneRow,
.list = local_repo.listForwardZoneRows,
.get = local_repo.getForwardZone,
.remove = applyDeleteZone,
.label = "a forward zone",
.plural = "forward zones",
.envelope = "forward_zones",
});
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones");
return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{});
}
pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"),
};
const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const listZones = zones_resource.list;
pub const getZone = zones_resource.get;
pub const removeZone = zones_resource.remove;
pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
@@ -348,13 +295,6 @@ pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) Handle
}, &.{});
}
pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a forward zone");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+156 -4
View File
@@ -101,16 +101,156 @@ pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
};
}
/// The config connection, or the 503 a state without one earns.
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } {
if (state.config_db) |database| return .{ .database = database };
return .{ .fail = .{ .unavailable = "no configuration database" } };
/// The 503 a state with no config connection earns. One constant, because every
/// caller reports the same missing collaborator in the same words.
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
/// The config connection, or `error.NoConfigDb` for the caller to turn into
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
/// or a written response.
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
return state.config_db orelse error.NoConfigDb;
}
pub fn nowSeconds(io: std.Io) i64 {
return std.Io.Clock.real.now(io).toSeconds();
}
// ---------------------------------------------------------------------------
// the identical half of an id-addressed resource
// ---------------------------------------------------------------------------
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
/// directory writes the same way: take the config connection or answer 503, call
/// one repository function, and turn what comes back into the response. Nothing
/// a resource decides for itself is here — the create and update bodies, the
/// constraint texts, and the four reload flavors stay hand-written beside the
/// descriptor that names these three.
///
/// `desc` is an anonymous struct literal rather than a typed struct because
/// `anytype` is not legal as a struct *field* type and the members are functions
/// of five signatures. Every member is checked below, so a descriptor that is
/// missing one or spells one wrong is a compile error that names it.
///
/// Members:
///
/// - `Row: type` — what the repository returns for one row.
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
/// with no `/{id}` route.
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
/// their object in the log context a 500 carries.
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
/// - `envelope: []const u8` — the JSON key the list arrives under.
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
/// row spells the difference here; without it the row is serialised as it is.
pub fn Resource(comptime desc: anytype) type {
const Desc = @TypeOf(desc);
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
if (!@hasField(Desc, name)) {
@compileError("resource descriptor has no `" ++ name ++ "`");
}
}
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
const Row = desc.Row;
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
if (!isNull(@TypeOf(desc.get))) {
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
}
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
}
_ = @as([]const u8, desc.label);
_ = @as([]const u8, desc.plural);
_ = @as([]const u8, desc.envelope);
const has_view = @hasField(Desc, "view");
if (has_view) {
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
if (info.params.len != 1 or info.params[0].type.? != Row) {
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
}
}
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
const names: [1][:0]const u8 = .{desc.envelope};
const types: [1]type = .{[]const View};
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
const list_what = "listing " ++ desc.plural;
const get_what = "reading " ++ desc.label;
const remove_what = "deleting " ++ desc.label;
return struct {
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, list_what);
const rows = desc.list(database, request.arena) catch |err|
return respondFailure(request, .{ .internal = err }, list_what);
const items: []const View = if (has_view) views: {
const views = try request.arena.alloc(View, rows.items.len);
for (views, rows.items) |*view, row| view.* = desc.view(row);
break :views views;
} else rows.items;
var payload: Envelope = undefined;
@field(payload, desc.envelope) = items;
return http_util.respondJson(request, .ok, payload, &.{});
}
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, get_what);
const row = desc.get(database, request.arena, request.id.?) catch |err|
return respondFailure(request, .{ .internal = err }, get_what);
const found = row orelse return respondFailure(request, .not_found, "");
const body: View = if (has_view) desc.view(found) else found;
return http_util.respondJson(request, .ok, body, &.{});
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
}
};
}
fn isNull(comptime T: type) bool {
return T == @TypeOf(null);
}
fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
}
// ---------------------------------------------------------------------------
// applying a change to the running server (ruling 12)
// ---------------------------------------------------------------------------
@@ -439,6 +579,18 @@ test "the schema the bench opens already holds the default group" {
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
}
test "a state with no configuration database is a 503, not a crash" {
var bench: Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
var bare: server.WebState = .{ .gpa = testing.allocator };
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
}
test "a database error maps to the status its cause deserves" {
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
+16 -47
View File
@@ -60,10 +60,7 @@ pub fn applyCreate(
arena: Allocator,
item: rules_repo.RuleInput,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .fail = .{ .invalid = problem } };
}
@@ -84,10 +81,7 @@ pub fn applyUpdate(
id: i64,
item: rules_repo.RuleInput,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .invalid = problem };
}
@@ -101,10 +95,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = rules_repo.deleteRule(database, id);
@@ -142,35 +133,20 @@ const RuleView = struct {
}
};
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing rules"),
};
const resource = mutations.Resource(.{
.Row = rules_repo.RuleRow,
.list = rules_repo.listRuleRows,
.get = rules_repo.getRule,
.remove = applyDelete,
.label = "a rule",
.plural = "rules",
.envelope = "rules",
.view = RuleView.from,
});
const rows = rules_repo.listRuleRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing rules");
const views = try request.arena.alloc(RuleView, rows.items.len);
for (views, rows.items) |*view, row| view.* = .from(row);
return http_util.respondJson(request, .ok, .{ .rules = views }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"),
};
const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a rule");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, RuleView.from(found), &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -213,13 +189,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a rule");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+3 -8
View File
@@ -281,10 +281,7 @@ pub fn applyPut(
arena: Allocator,
patch: Patch,
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
// handler does, and its input is the parsed patch alone — nothing under the
@@ -449,10 +446,8 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// ---------------------------------------------------------------------------
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"),
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
// Under the same lock the mutation handlers hold: a PUT rewrites every
// settings row in one transaction on this shared connection, and SQLite's
+15 -44
View File
@@ -44,10 +44,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.UpstreamServer,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -65,10 +62,7 @@ pub fn applyUpdate(
id: i64,
item: model.UpstreamServer,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -96,10 +90,7 @@ pub fn applyUpdate(
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -142,32 +133,19 @@ fn countEnabledExcept(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"),
};
const resource = mutations.Resource(.{
.Row = upstreams_repo.UpstreamRow,
.list = upstreams_repo.listUpstreamRows,
.get = upstreams_repo.getUpstream,
.remove = applyDelete,
.label = "an upstream",
.plural = "upstreams",
.envelope = "upstreams",
});
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams");
return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"),
};
const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -206,13 +184,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting an upstream");
}
return http_util.respondEmpty(request, .no_content);
}
fn toModel(body: Body) model.UpstreamServer {
return .{
.url = body.url,
+8 -8
View File
@@ -776,7 +776,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
.send_errors = 5,
},
.tcp_listener = .{
.accepted = 12,
.connections = 12,
.rejected_at_capacity = 6,
.rejected_at_shutdown = 7,
.accept_errors = 8,
@@ -793,7 +793,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
@@ -822,13 +822,13 @@ test "one family covers all four listeners, summed" {
udp4.stats.dropped_no_slot.store(2, .monotonic);
var tcp6: tcp_server.TcpServer = undefined;
tcp6.stats = .{};
tcp6.stats.accepted.store(4, .monotonic);
tcp6.core.stats = .{};
tcp6.core.stats.connections.store(4, .monotonic);
var tcp4: tcp_server.TcpServer = undefined;
tcp4.stats = .{};
tcp4.stats.accepted.store(5, .monotonic);
tcp4.stats.idle_timeouts.store(3, .monotonic);
tcp4.core.stats = .{};
tcp4.core.stats.connections.store(5, .monotonic);
tcp4.core.stats.idle_timeouts.store(3, .monotonic);
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
try testing.expectEqual(@as(u64, 17), udp.received);
@@ -836,7 +836,7 @@ test "one family covers all four listeners, summed" {
try testing.expectEqual(@as(u64, 0), udp.send_errors);
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
try testing.expectEqual(@as(u64, 9), tcp.accepted);
try testing.expectEqual(@as(u64, 9), tcp.connections);
try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);
// No listener at all is a missing family, not a family of zeros.
+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").?;
+30 -17
View File
@@ -238,6 +238,19 @@ fn get(path: []const u8, buf: []u8) []const u8 {
return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable;
}
/// The listener's counters, read once after `f` finished and before the
/// listener is torn down. Plain values, because they are a report of a run that
/// is over: the shared core's counters and the web listener's own one land in
/// the same struct here.
const Counters = struct {
connections: u64,
rejected_at_capacity: u64,
rejected_at_shutdown: u64,
accept_errors: u64,
connection_errors: u64,
requests: u64,
};
/// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under
/// the budget, then shuts the listener down through the drain path.
fn withServer(
@@ -247,7 +260,7 @@ fn withServer(
max_connections: u16,
comptime f: anytype,
extra: anytype,
) !server.Stats {
) !Counters {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections });
const address = web.boundAddress();
@@ -257,16 +270,16 @@ fn withServer(
const result = bounded(io, f, .{ io, address } ++ extra);
const stats: server.Stats = .{
.accepted = .init(web.stats.accepted.load(.monotonic)),
.rejected_at_capacity = .init(web.stats.rejected_at_capacity.load(.monotonic)),
.rejected_at_shutdown = .init(web.stats.rejected_at_shutdown.load(.monotonic)),
.accept_errors = .init(web.stats.accept_errors.load(.monotonic)),
.connection_errors = .init(web.stats.connection_errors.load(.monotonic)),
.requests = .init(web.stats.requests.load(.monotonic)),
const stats: Counters = .{
.connections = web.core.stats.connections.load(.monotonic),
.rejected_at_capacity = web.core.stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = web.core.stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = web.core.stats.accept_errors.load(.monotonic),
.connection_errors = web.core.stats.connection_errors.load(.monotonic),
.requests = web.stats.requests.load(.monotonic),
};
web.deinit(gpa, io);
web.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
@@ -302,9 +315,9 @@ test "one connection carries two requests" {
const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{});
// One accept for two requests is the whole point of keep-alive.
try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic));
try testing.expectEqual(@as(u64, 2), stats.requests.load(.monotonic));
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 1), stats.connections);
try testing.expectEqual(@as(u64, 2), stats.requests);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}
fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -352,7 +365,7 @@ test "routing answers 404, 405 with allow, and rejects malformed targets" {
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{});
try testing.expectEqual(@as(u64, 5), stats.requests.load(.monotonic));
try testing.expectEqual(@as(u64, 5), stats.requests);
}
fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void {
@@ -448,7 +461,7 @@ test "a POST with no content-length and no transfer-encoding is an empty body, n
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{});
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}
fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -509,8 +522,8 @@ test "a connection over the cap is told 503, not silently dropped" {
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{});
try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic));
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity.load(.monotonic));
try testing.expectEqual(@as(u64, 1), stats.connections);
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity);
}
fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -642,7 +655,7 @@ test "cancellation returns promptly with an idle keep-alive connection open" {
const elapsed = start.durationTo(std.Io.Clock.awake.now(io));
client.cancel(io);
web.deinit(gpa, io);
web.deinit(io);
try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds());
}
+1 -1
View File
@@ -402,7 +402,7 @@ const Env = struct {
const gpa = self.gpa;
const ioh = self.threaded.io();
self.web.deinit(gpa, ioh);
self.web.deinit(ioh);
self.group.await(ioh) catch |err| switch (err) {
error.Canceled => unreachable,
};