209 lines
8.0 KiB
Zig
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);
|
|
}
|