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