milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+172 -4
View File
@@ -15,6 +15,10 @@ const net = std.Io.net;
const handler = @import("handler.zig");
const tcp_server = @import("tcp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
@@ -22,6 +26,31 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
.ttl = blocking_defaults.ttl,
};
const forward_timeout: std.Io.Clock.Duration = .{
.raw = model.readTimeout(.{}),
.clock = .awake,
};
/// An upstream and nothing else optional: no filtering, no cache, no log. The
/// listener is what these tests exercise, so the handler is the same bare one
/// its own tests use.
fn bareHandler(client: transport.Client) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
/// Short enough to keep the idle-timeout test quick, long enough that a
@@ -150,7 +179,7 @@ test "two length-prefixed queries share one connection" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
@@ -171,6 +200,145 @@ test "two length-prefixed queries share one connection" {
};
}
test "the claimed slot records the connecting client" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
// The only connection took slot 0, and the reply the client already read
// 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;
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);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out
/// of band. `answered` is set either way — a client that fails early must not
/// leave the test blocked waiting for a reply that will never come, and `set`
/// is documented to have no effect the second time.
fn holdConnection(
io: std.Io,
address: net.IpAddress,
answered: *std.Io.Event,
result: *anyerror!void,
) void {
result.* = holdConnectionOpen(io, address, answered);
answered.set(io);
}
/// Holds a connection open the way RFC 7766 lets a real client hold one: one
/// query answered, then nothing, so the server sits blocked on the read for the
/// next message. Returns once the server ends the connection, however it ends
/// it — a canceled server closes the socket, which the client sees either as
/// end of stream or as a reset.
fn holdConnectionOpen(io: std.Io, address: net.IpAddress, answered: *std.Io.Event) anyerror!void {
const remote = address;
var stream = try remote.connect(io, .{ .mode = .stream });
defer stream.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
try writer.interface.writeAll(query_bytes);
try writer.interface.flush();
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
try expectAnswersQuery(try reader.interface.take(len));
answered.set(io);
var sink: [64]u8 = undefined;
_ = reader.interface.readSliceShort(&sink) catch {};
}
test "a canceled serve does not wait for a live connection" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
// The idle budget is the whole time a drain would have to wait out, so it
// is set far beyond any patience this test run has: if `serve` waits for
// the connection instead of canceling it, the wait never ends.
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
.max_connections = 2,
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
});
const server_address = server.boundAddress();
var serving: std.Io.Group = .init;
try serving.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
var answered: std.Io.Event = .unset;
var client_result: anyerror!void = {};
var client_group: std.Io.Group = .init;
try client_group.concurrent(io, holdConnection, .{ io, server_address, &answered, &client_result });
// The reply proves the connection is claimed and served, so the connection
// task is now blocked reading the message that never comes. That is the
// state a drain hangs in.
answered.waitUncancelable(io);
// This is the composition root's shutdown: cancel the task group before
// anything it borrows is released, so no `deinit` has shut the connection
// down. It must still return. A regression here does not fail the test, it
// hangs the run — there is no way to bound a join that does not finish.
serving.cancel(io);
// Cancellation must not skip the per-connection cleanup: the slot is
// released and the socket closed by `serveConn`'s defer, which runs on the
// canceled path like any other.
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
client_group.cancel(io);
server.deinit(gpa, 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.
try client_result;
}
/// 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| {
if (conn.state == .free) return index;
}
return null;
}
test "an idle connection is closed and counted" {
if (!build_options.integration) return error.SkipZigTest;
@@ -180,7 +348,7 @@ test "an idle connection is closed and counted" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
@@ -213,7 +381,7 @@ test "a zero-length message is a connection error" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
@@ -265,7 +433,7 @@ test "deinit ends a serve loop that is blocked on accept" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });