Files
nxdns/src/server/udp_server.zig
T

209 lines
8.0 KiB
Zig

//! The UDP/53 listener.
//!
//! One receive loop hands each datagram to a task that holds a permit from the
//! shared query budget. A datagram that finds no permit is dropped and counted,
//! never queued: an unbounded queue only moves the failure from "visible drop"
//! to "invisible latency", which is the failure mode PLAN §1 names.
//!
//! The permit carries the query, the reply and the handler's scratch, so
//! answering a datagram allocates nothing.
const std = @import("std");
const address = @import("../platform/address.zig");
const concurrency = @import("../platform/concurrency.zig");
const handler = @import("handler.zig");
const query_budget = @import("query_budget.zig");
const log = std.log.scoped(.udp_server);
pub const max_datagram = query_budget.max_datagram;
/// How long a receive waits before the loop re-reads the stop flag. `deinit`
/// therefore returns within this much time, without depending on a blocked
/// `receive` being unblocked by a close — a guarantee POSIX does not make.
const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
pub const Stats = struct {
received: std.atomic.Value(u64) = .init(0),
/// The datagram arrived with `flags.trunc`: the kernel discarded its tail.
dropped_oversize: std.atomic.Value(u64) = .init(0),
/// Every permit of the shared query budget was taken.
dropped_no_slot: std.atomic.Value(u64) = .init(0),
/// The handler answered `.drop` — an upstream exchange canceled by
/// shutdown. The handler counts its own malformed drops; this counter is
/// what keeps the listener's "every dropped datagram is counted" rule true
/// for the drops it cannot see. A datagram that got a permit but no task
/// is counted under `nxdns_concurrency_unavailable_total{site="udp_query"}`.
dropped_handler: std.atomic.Value(u64) = .init(0),
receive_errors: std.atomic.Value(u64) = .init(0),
send_errors: std.atomic.Value(u64) = .init(0),
};
/// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the
/// `nxdns_udp_server_*` families. Every counter is exported: the module doc
/// promises that a dropped datagram is counted, and a count nothing can read is
/// not a count.
pub const Snapshot = struct {
received: u64,
dropped_oversize: u64,
dropped_no_slot: u64,
dropped_handler: u64,
receive_errors: u64,
send_errors: u64,
};
/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches the socket
/// after it is closed.
const State = enum(u32) { idle, serving, closing };
pub const UdpServer = struct {
socket: std.Io.net.Socket,
handler: *handler.Handler,
budget: *query_budget.QueryBudget,
stats: Stats,
state: std.atomic.Value(State),
stopped: std.Io.Event,
pub const BindError = std.Io.net.IpAddress.BindError;
pub fn bind(
io: std.Io,
bind_address: std.Io.net.IpAddress,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
) BindError!UdpServer {
const socket = try bind_address.bind(io, .{ .mode = .dgram });
return .{
.socket = socket,
.handler = h,
.budget = budget,
.stats = .{},
.state = .init(.idle),
.stopped = .unset,
};
}
/// The kernel-assigned address. A port of 0 in `bind` resolves here.
pub fn boundAddress(self: *const UdpServer) std.Io.net.IpAddress {
return self.socket.address;
}
/// The counters, read one at a time. A scrape that lands mid-datagram can
/// see a receive counted before its drop is; a lock would buy a consistency
/// no consumer needs.
pub fn snapshotStats(self: *const UdpServer) Snapshot {
return .{
.received = self.stats.received.load(.monotonic),
.dropped_oversize = self.stats.dropped_oversize.load(.monotonic),
.dropped_no_slot = self.stats.dropped_no_slot.load(.monotonic),
.dropped_handler = self.stats.dropped_handler.load(.monotonic),
.receive_errors = self.stats.receive_errors.load(.monotonic),
.send_errors = self.stats.send_errors.load(.monotonic),
};
}
/// Receive loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *UdpServer, io: std.Io) void {
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
var group: std.Io.Group = .init;
self.receiveLoop(io, &group);
// A reply that is half sent is worse than no reply, so the in-flight
// tasks are awaited even when this task is being canceled.
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
self.stopped.set(io);
}
pub fn deinit(self: *UdpServer, io: std.Io) void {
// `serve` reads this between receives, so it stops on its own and the
// socket is closed only once nothing can be reading from it.
if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io);
self.socket.close(io);
self.* = undefined;
}
fn receiveLoop(self: *UdpServer, io: std.Io, group: *std.Io.Group) void {
// The datagram is received here, without a permit, so an idle listener
// holds none; a query that gets one is copied into it.
var datagram: [max_datagram]u8 = undefined;
while (self.state.load(.acquire) == .serving) {
const msg = self.socket.receiveTimeout(io, &datagram, .{ .duration = poll_interval }) catch |err| {
switch (err) {
error.Canceled => return,
// The stop flag is re-read at the top of the loop.
error.Timeout => continue,
error.ConcurrencyUnavailable => concurrency.unavailable(.udp_receive),
else => {
bump(&self.stats.receive_errors);
log.debug("udp receive failed: {t}", .{err});
},
}
// One bad datagram must not kill the listener, and a persistent
// error must not turn it into a spin.
poll_interval.sleep(io) catch return;
continue;
};
bump(&self.stats.received);
// The kernel already threw the tail away, so the message cannot be
// parsed and any answer would be a guess.
if (msg.flags.trunc) {
bump(&self.stats.dropped_oversize);
continue;
}
const work = self.budget.tryAcquire(io) orelse {
bump(&self.stats.dropped_no_slot);
continue;
};
@memcpy(work.datagram[0..msg.data.len], msg.data);
group.concurrent(io, respondOne, .{ self, io, work, msg.from, msg.data.len }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
concurrency.unavailable(.udp_query);
self.budget.release(io, work);
},
};
}
}
fn respondOne(
self: *UdpServer,
io: std.Io,
work: *query_budget.Work,
from: std.Io.net.IpAddress,
len: usize,
) void {
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.udp,
address.NetAddress.fromIp(from),
work.datagram[0..len],
&work.reply,
&work.scratch,
);
switch (outcome) {
.drop => bump(&self.stats.dropped_handler),
.reply => |bytes| self.socket.send(io, &from, bytes) catch |err| {
bump(&self.stats.send_errors);
log.debug("udp send to a client failed: {t}", .{err});
},
}
}
};
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}