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

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+15 -62
View File
@@ -135,13 +135,13 @@ pub const ForwardClient = struct {
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeSocket(io, &socket);
defer transport.closeBlocked(io, &socket);
socket.send(io, &dest, query) catch |err| {
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
return mapPhase(err, error.SendFailed);
return transport.mapPhase(err, error.SendFailed);
};
// A deadline, not a duration: a discarded foreign datagram restarts the
@@ -152,7 +152,7 @@ pub const ForwardClient = struct {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
error.Timeout => return error.Timeout,
error.ConcurrencyUnavailable => return error.SystemResources,
else => return mapPhase(err, error.ReceiveFailed),
else => return transport.mapPhase(err, error.ReceiveFailed),
};
// Off-path spoofing is the reason the source address is checked at
@@ -183,35 +183,16 @@ pub const ForwardClient = struct {
}
}
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a
/// second task and the loser is canceled. `ConnectOptions.timeout` is never
/// set: the Threaded backend panics on it (Threaded.zig:12076).
/// The read budget bounds the whole TCP exchange through
/// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the
/// Threaded backend panics on it (Threaded.zig:12076).
fn exchangeTcp(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the resolver is slow.
try result;
return error.Timeout;
},
}
return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf });
}
fn tcpOnce(
@@ -224,9 +205,9 @@ pub const ForwardClient = struct {
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
defer transport.closeBlocked(io, &stream);
const split = self.frame_buf.len / 2;
var stream_writer = stream.writer(io, self.frame_buf[0..split]);
@@ -257,15 +238,6 @@ pub const ForwardClient = struct {
}
};
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
/// The local address a datagram to `dest` is sent from: same family, port
/// chosen by the kernel.
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
@@ -275,25 +247,6 @@ fn wildcardFor(dest: net.IpAddress) net.IpAddress {
};
}
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
/// the `defer` chain would then return `error.Canceled` and skip the close,
/// leaking the descriptor, so both closes run with cancellation blocked.
fn closeStream(io: std.Io, stream: *const net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
socket.close(io);
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
/// errors out of the peer fault group.
@@ -302,7 +255,7 @@ fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport
stream_writer.err.?
else
err;
return mapPhase(cause, error.SendFailed);
return transport.mapPhase(cause, error.SendFailed);
}
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
@@ -310,7 +263,7 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp
stream_reader.err.?
else
err;
return mapPhase(cause, error.ReceiveFailed);
return transport.mapPhase(cause, error.ReceiveFailed);
}
const testing = std.testing;
@@ -408,19 +361,19 @@ test "mapPhase keeps local resource and cancellation errors out of the peer faul
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.ReceiveFailed)),
transport.group(transport.mapPhase(err, error.ReceiveFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.ConnectFailed),
transport.mapPhase(error.Canceled, error.ConnectFailed),
);
// A refused connection is the resolver's side, so it stays a peer fault.
try testing.expectEqual(
transport.ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
transport.mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
}
+4 -22
View File
@@ -48,7 +48,10 @@ pub const Zones = struct {
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const zone = normalizeName(row.zone, &buf) catch return error.BadZone;
// The root is rejected with everything else `normalizeText` refuses:
// a zone that forwards everything would bypass the upstream pool
// entirely, which is not what conditional forwarding means.
const zone = name.normalizeText(row.zone, &buf) catch return error.BadZone;
const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
try spans.append(gpa, .{
.offset = names.items.len,
@@ -119,27 +122,6 @@ fn suffixMatches(zone: []const u8, domain: []const u8) bool {
return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
}
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected because query
/// names arrive ASCII-lowercased, so a high byte could never match. The root
/// zone is rejected too: a zone that forwards everything would bypass the
/// upstream pool entirely, which is not what conditional forwarding means.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+4 -23
View File
@@ -52,7 +52,9 @@ pub const Records = struct {
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const owner = normalizeName(row.name, &buf) catch return error.BadRecordName;
// A record whose owner can never be matched — a high byte, or the
// root — is a configuration error, not a record that never answers.
const owner = name.normalizeText(row.name, &buf) catch return error.BadRecordName;
const value = try parseValue(row.rtype, row.value);
try spans.append(gpa, .{
.offset = owners.items.len,
@@ -187,27 +189,6 @@ fn rankRun(records: []const Record, wanted: u2) []const Record {
return records[start..end];
}
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names
/// arrive ASCII-lowercased, so a high byte here could never be matched and a
/// record that can never answer is a configuration error worth reporting. The
/// root name is rejected for the same reason — nothing can match it.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value {
switch (rtype) {
.a => {
@@ -226,7 +207,7 @@ fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!V
},
.cname => {
var buf: [types.max_name_len]u8 = undefined;
const target = normalizeName(text, &buf) catch return error.BadRecordValue;
const target = name.normalizeText(text, &buf) catch return error.BadRecordValue;
return .{ .cname = name.fromText(target) catch return error.BadRecordValue };
},
}