milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+137
View File
@@ -36,6 +36,8 @@ const rate_limiter = @import("../server/rate_limiter.zig");
const retention_mod = @import("../storage/retention.zig");
const safe_url = @import("../safe_url.zig");
const server = @import("server.zig");
const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig");
/// The exposition format version, as the 0.0.4 specification writes it.
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
@@ -133,6 +135,13 @@ pub const Sample = struct {
/// disabled or its bind failed, like every other unwired collaborator.
doh_listener: ?DohListenerSample = null,
dot_listener: ?dot_server.StatsSnapshot = null,
/// The plain-DNS listener families (ruling 13). The app binds one listener
/// per address family, and both answer the same port for the same reason,
/// so their counters are summed into one family rather than labelled: an
/// operator asks how much UDP/53 dropped, not how much of it arrived over
/// IPv6. Absent when no listener is wired, like every other collaborator.
udp_listener: ?udp_server.Snapshot = null,
tcp_listener: ?tcp_server.Snapshot = null,
upstreams: []const UpstreamSample = &.{},
};
@@ -214,11 +223,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
}
if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
return sample;
}
/// Adds one snapshot per listener field by field. Null for an empty slice, so
/// an unbound listener omits its family rather than reporting zeros.
///
/// A `u64` counter cannot realistically overflow the sum of four of them, and
/// wrapping addition would be a worse answer than a wrong-looking large one, so
/// the addition is the ordinary checked one.
fn sumListeners(comptime Snapshot: type, comptime Server: type, listeners: []const *Server) ?Snapshot {
if (listeners.len == 0) return null;
var total: Snapshot = undefined;
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
@field(total, field.name) = 0;
}
for (listeners) |listener| {
const one = listener.snapshotStats();
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
@field(total, field.name) += @field(one, field.name);
}
}
return total;
}
fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters {
var out: DnsCounters = undefined;
inline for (dns_stat_fields, 0..) |field, i| {
@@ -339,6 +373,12 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
);
}
if (sample.udp_listener) |listener| {
try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
}
if (sample.tcp_listener) |listener| {
try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
}
if (sample.doh_listener) |listener| {
try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener);
}
@@ -705,6 +745,103 @@ test "an unwired collaborator omits its family rather than reporting zeros" {
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_"));
}
test "the plain-DNS listener families carry every counter of both listeners" {
const text = try renderToString(testing.allocator, .{
.udp_listener = .{
.received = 90,
.dropped_oversize = 1,
.dropped_no_slot = 2,
.dropped_handler = 3,
.receive_errors = 4,
.send_errors = 5,
},
.tcp_listener = .{
.accepted = 12,
.rejected_at_capacity = 6,
.rejected_at_shutdown = 7,
.accept_errors = 8,
.connection_errors = 9,
.idle_timeouts = 10,
},
});
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
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_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"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n"));
// One family per listener kind, whatever the number of listeners behind it:
// the counters are summed, not labelled.
try testing.expectEqual(
@as(usize, 1),
std.mem.count(u8, text, "# TYPE nxdns_udp_server_received_total counter\n"),
);
}
test "one family covers all four listeners, summed" {
// Only `stats` is read, so the listeners need no socket: `snapshotStats`
// loads counters and touches nothing else.
var udp6: udp_server.UdpServer = undefined;
udp6.stats = .{};
udp6.stats.received.store(10, .monotonic);
udp6.stats.dropped_no_slot.store(1, .monotonic);
var udp4: udp_server.UdpServer = undefined;
udp4.stats = .{};
udp4.stats.received.store(7, .monotonic);
udp4.stats.dropped_no_slot.store(2, .monotonic);
var tcp6: tcp_server.TcpServer = undefined;
tcp6.stats = .{};
tcp6.stats.accepted.store(4, .monotonic);
var tcp4: tcp_server.TcpServer = undefined;
tcp4.stats = .{};
tcp4.stats.accepted.store(5, .monotonic);
tcp4.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);
try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot);
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, 3), tcp.idle_timeouts);
// No listener at all is a missing family, not a family of zeros.
try testing.expectEqual(
@as(?udp_server.Snapshot, null),
sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{}),
);
}
test "the three forward-client counters reach the DNS families" {
var dns: DnsCounters = @splat(0);
dns[fieldIndex("forward_udp_truncated")] = 2;
dns[fieldIndex("forward_foreign_datagrams")] = 3;
dns[fieldIndex("forward_failures")] = 4;
const text = try renderToString(testing.allocator, .{ .dns = dns });
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_udp_truncated_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_foreign_datagrams_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_failures_total 4\n"));
}
test "listener counters render only for the wired servers" {