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
+88 -426
View File
@@ -1,12 +1,14 @@
//! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream.
//!
//! This file mirrors `tcp_server.zig` — same slots, same claim rule, same
//! shutdown paths, same idle race — with three differences:
//! The slot pool, the accept loop and the shutdown protocol are
//! `listener.Core`'s (milestone-18 ruling 1), the same ones tcp_server uses.
//! What this file adds over TCP/53:
//!
//! - After the TCP accept, the certificate generation is pinned with
//! `CertStore.acquire` and the mbedTLS handshake runs under the same race
//! budget as every other per-connection operation, so a client that stalls
//! mid-handshake cannot pin a connection slot.
//! `CertStore.acquire` and the mbedTLS handshake runs through
//! `listener.handshakeStage` under the same race budget as every other
//! per-connection operation, so a client that stalls mid-handshake cannot pin
//! a connection slot.
//! - The framed-message loop reads and writes through
//! `tls_server.ServerStream`, and closing the stream sends close_notify
//! before the TCP close. A transport EOF without close_notify surfaces as a
@@ -23,20 +25,15 @@ const std = @import("std");
const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.dot_server);
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
/// record tail pass through here, while whole messages go straight to
/// `Conn.query`/`Conn.reply`.
/// `Payload.query`/`Payload.reply`.
const stream_buffer_len = 1024;
/// How long the accept loop waits after an unexpected accept failure, so a
/// persistent one cannot turn the loop into a spin.
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
pub const Options = struct {
max_connections: u16 = 64,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the
@@ -44,16 +41,11 @@ pub const Options = struct {
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};
/// What DoT counts on top of `listener.CoreStats`: handshakes that failed or
/// outran the idle budget. `connections` in the core counts TCP connections
/// accepted, whether or not the handshake succeeded.
pub const Stats = struct {
/// TCP connections accepted, whether or not the handshake succeeded.
connections: std.atomic.Value(u64) = .init(0),
/// Handshakes that failed or outran the idle budget.
tls_handshake_failures: std.atomic.Value(u64) = .init(0),
idle_timeouts: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
accept_errors: std.atomic.Value(u64) = .init(0),
};
/// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup`
@@ -65,62 +57,19 @@ pub const StatsSnapshot = struct {
connection_errors: u64,
};
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches a connection
/// slot after it is freed.
const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
/// task is about to close: the transition to `.closing` happens under the mutex
/// before the close, and `deinit` only touches `.active` slots.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum {
/// `deinit` published `.closing`. It has already shut every live connection
/// down, so each one is unblocked and finishing on its own.
closing,
/// This task is being canceled. Nothing has touched the connections.
canceled,
};
/// What the accept loop does with a stream it has just accepted.
const Claim = union(enum) {
/// The stream owns `conns[index]`.
slot: usize,
/// Every slot is taken. The stream is closed and the loop continues.
at_capacity,
/// `deinit` has started. The stream is closed and the loop returns.
shutting_down,
};
pub const DotServer = struct {
server: std.Io.net.Server,
core: listener.Core(Config),
handler: *handler.Handler,
certs: *cert_store.CertStore,
/// Kept for the per-connection ssl context `ServerStream.accept`
/// allocates and `close` frees.
gpa: std.mem.Allocator,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
/// shuts the active connections down, so a stream that arrives after that
/// scan can never claim a slot the scan will not visit again.
shutdown_begun: bool,
options: Options,
stats: Stats,
state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the
/// `ServerStream` bookkeeping — so the default 64 connections stay inside
/// the PLAN §18 budget.
pub const Conn = struct {
pub const Payload = struct {
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
read_buf: [stream_buffer_len]u8,
write_buf: [stream_buffer_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time.
@@ -128,16 +77,20 @@ pub const DotServer = struct {
/// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it,
/// and the slot never moves.
tls: tls_server.ServerStream,
stream: std.Io.net.Stream,
/// The client, read off the accepted socket once at claim time: every
/// message on this connection comes from the same peer, and the handler
/// needs it for rate limiting, groups and the query log.
peer: std.Io.net.IpAddress,
/// Guarded by `DotServer.mutex`.
state: ConnState,
};
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory};
const Config = struct {
pub const Owner = DotServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.dot_server);
pub const name = "dot";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen(
gpa: std.mem.Allocator,
@@ -147,148 +100,47 @@ pub const DotServer = struct {
certs: *cert_store.CertStore,
options: Options,
) ListenError!DotServer {
std.debug.assert(options.max_connections > 0);
const conns = try gpa.alloc(Conn, options.max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| conn.state = .free;
const local = listen_address;
const server = try local.listen(io, .{ .reuse_address = true });
return .{
.server = server,
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.certs = certs,
.gpa = gpa,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options,
.stats = .{},
.state = .init(.idle),
.stopped = .unset,
};
}
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress {
return self.server.socket.address;
return self.core.boundAddress();
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *DotServer, io: std.Io) void {
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
var group: std.Io.Group = .init;
switch (self.acceptLoop(io, &group)) {
// `deinit` shut every live connection down before it published
// `.closing`, so each one is already unblocked and ending on its
// own. Awaiting them means a half-written reply still goes out
// whole, and the wait is bounded by the shutdown, not the client.
.closing => {
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
},
// Nothing has shut these connections down: `deinit` cannot run
// until this task returns, and RFC 7766 lets a client hold a
// connection open forever by asking again inside the idle budget.
// Draining here would therefore let one client stall the whole
// process's shutdown for as long as it likes. `cancel` requests
// cancellation and joins, so the slots are still quiet — and the
// buffers still unreferenced — by the time `serve` returns; the
// price is the one reply that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
self.core.serve(io);
}
pub fn deinit(self: *DotServer, io: std.Io) void {
const was_serving = self.state.swap(.closing, .acq_rel) == .serving;
// Shutting the listening socket down is the documented way to unblock a
// pending `accept`: it fails with `error.SocketNotListening`.
const listener: std.Io.net.Stream = .{ .socket = self.server.socket };
listener.shutdown(io, .both) catch |err| {
log.debug("dot listener shutdown failed: {t}", .{err});
};
// A live connection is blocked in a read that only the idle budget
// would end, which is seconds away. Shutting each one down bounds this,
// and the same critical section closes the door on new connections.
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.server.deinit(io);
self.gpa.free(self.conns);
self.core.deinit(io);
self.* = undefined;
}
pub fn snapshotStats(self: *const DotServer) StatsSnapshot {
const core = &self.core.stats;
return .{
.connections = self.stats.connections.load(.monotonic),
.connections = core.connections.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic),
};
}
fn acceptLoop(self: *DotServer, io: std.Io, group: *std.Io.Group) Stop {
while (self.state.load(.acquire) == .serving) {
const stream = self.server.accept(io) catch |err| switch (err) {
error.Canceled => return .canceled,
// `deinit` shuts the listening socket down to unblock exactly
// this call, so it is the shutdown path arriving early.
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
log.debug("dot accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// Refusing now is honest; a queue would only hide the overload.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
continue;
},
// `deinit` will not see this stream in any slot, so serving it
// would hold `deinit` for the whole idle budget.
.shutting_down => {
bump(&self.stats.rejected_at_shutdown);
stream.close(io);
return .closing;
},
};
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.rejected_at_capacity);
self.finish(io, index);
continue;
},
};
bump(&self.stats.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *DotServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
/// One connection: pin, handshake, serve, close_notify, release — the
/// ordering `listener.handshakeStage` documents. The core closes the TCP
/// stream after this returns.
fn serveOne(self: *DotServer, io: std.Io, index: usize) void {
const conn = &self.core.conns[index];
const stats = &self.core.stats;
const gpa = self.core.gpa;
const budget = self.options.idle_timeout;
// Pins the certificate generation for the whole connection: a reload
@@ -297,46 +149,38 @@ pub const DotServer = struct {
const entry = self.certs.acquire(io);
defer self.certs.release(io, entry);
var handshook = false;
switch (race(io, budget, handshake, .{ conn, self.gpa, &entry.ctx, io, &handshook })) {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, budget, stage)) {
.ok => {},
// The select can report the expiry or the cancellation after the
// handshake has in fact succeeded. The flag is written before the
// race joins its tasks, so a TLS context that exists is closed on
// every path, exactly once.
.canceled => {
if (handshook) conn.tls.close(self.gpa);
return;
},
.canceled => return,
// A stalled handshake is refused like a broken one: it must not
// pin a connection slot for longer than the idle budget.
.timed_out, .failed => {
if (handshook) conn.tls.close(self.gpa);
bump(&self.stats.tls_handshake_failures);
listener.bump(&self.stats.tls_handshake_failures);
return;
},
}
// Sends close_notify and frees the ssl context; `finish` closes the
// Sends close_notify and frees the ssl context; the core closes the
// TCP stream afterwards.
defer conn.tls.close(self.gpa);
defer conn.payload.tls.close(gpa);
const reader = conn.tls.reader();
const writer = conn.tls.writer();
const reader = conn.payload.tls.reader();
const writer = conn.payload.tls.writer();
while (true) {
var prefix: [transport.prefix_len]u8 = undefined;
var got: usize = 0;
switch (race(io, budget, readPrefix, .{ reader, &prefix, &got })) {
switch (listener.race(io, budget, listener.readPrefix, .{ reader, &prefix, &got })) {
.ok => {},
.timed_out => {
bump(&self.stats.idle_timeouts);
listener.bump(&stats.idle_timeouts);
return;
},
.canceled => return,
// A transport EOF without close_notify lands here too: the
// stream reads it as a truncation, never as a clean end.
.failed => {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
}
@@ -345,7 +189,7 @@ pub const DotServer = struct {
// asking, which is the normal end of a connection, not a failure.
if (got == 0) return;
if (got != transport.prefix_len) {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
}
@@ -353,16 +197,16 @@ pub const DotServer = struct {
// the prefix is a u16 so it can never exceed `max_message_len`.
const len = transport.parsePrefix(prefix);
if (len == 0) {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
}
switch (race(io, budget, readBody, .{ reader, conn.query[0..len] })) {
switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) {
.ok => {},
.canceled => return,
// A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
}
@@ -371,9 +215,9 @@ pub const DotServer = struct {
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
conn.query[0..len],
&conn.reply,
&conn.scratch,
conn.payload.query[0..len],
&conn.payload.reply,
&conn.payload.scratch,
);
const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends.
@@ -382,162 +226,43 @@ pub const DotServer = struct {
};
const out = transport.framePrefix(@intCast(bytes.len));
switch (race(io, budget, writeReply, .{ writer, &out, bytes })) {
switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) {
.ok => {},
.canceled => return,
.timed_out, .failed => {
bump(&self.stats.connection_errors);
listener.bump(&stats.connection_errors);
return;
},
}
}
}
fn claim(self: *DotServer, io: std.Io, stream: std.Io.net.Stream) Claim {
// Uncancelable: this section takes no Io and never blocks on a peer, so
// it cannot deadlock, and losing the lock mid-update would leak a slot.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
/// The `listener.handshakeStage` stage: everything one mbedTLS handshake
/// needs, plus the close that undoes it.
const Handshake = struct {
conn: *Conn,
gpa: std.mem.Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
const outcome = decideClaim(self.conns, self.shutdown_begun);
switch (outcome) {
.slot => |index| {
self.conns[index].stream = stream;
self.conns[index].peer = stream.socket.address;
self.conns[index].state = .active;
},
.at_capacity, .shutting_down => {},
pub fn accept(self: Handshake) anyerror!void {
const conn = self.conn;
try conn.payload.tls.accept(
self.gpa,
self.ctx,
self.io,
&conn.stream,
&conn.read_buf,
&conn.write_buf,
);
}
return outcome;
}
fn finish(self: *DotServer, io: std.Io, index: usize) void {
const conn = &self.conns[index];
self.mutex.lockUncancelable(io);
conn.state = .closing;
self.mutex.unlock(io);
// The socket is released even when this task is being torn down: the
// next cancelable call would otherwise skip the close.
const prev = io.swapCancelProtection(.blocked);
conn.stream.close(io);
_ = io.swapCancelProtection(prev);
self.mutex.lockUncancelable(io);
conn.state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones. Both
/// happen under one hold of the mutex: a `claim` that runs before this
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
/// runs after it reads `shutdown_begun` and takes no slot at all.
fn beginShutdown(self: *DotServer, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
log.debug("dot connection shutdown failed: {t}", .{err});
};
pub fn close(self: Handshake) void {
self.conn.payload.tls.close(self.gpa);
}
}
};
};
/// The capacity rule, without the mutex, so it is testable without a backend.
fn firstFree(conns: []const DotServer.Conn) ?usize {
for (conns, 0..) |*conn, index| {
if (conn.state == .free) return index;
}
return null;
}
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
/// slot is still refused once `deinit` has passed the connections.
fn decideClaim(conns: []const DotServer.Conn, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
const index = firstFree(conns) orelse return .at_capacity;
return .{ .slot = index };
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const Result = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the idle budget and cancels the loser.
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) Result {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// client went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
/// the losing race paths whether a TLS context exists that must be closed.
fn handshake(
conn: *DotServer.Conn,
gpa: std.mem.Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
handshook: *bool,
) anyerror!void {
try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf);
handshook.* = true;
}
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
/// that sent close_notify between messages, and only a partial prefix is an
/// error.
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
out_len.* = try reader.readSliceShort(buf);
}
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
return reader.readSliceAll(buf);
}
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
try writer.writeAll(prefix);
try writer.writeAll(bytes);
try writer.flush();
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -550,78 +275,15 @@ const packet = @import("../dns/packet.zig");
const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig");
fn testConns(count: usize) ![]DotServer.Conn {
const conns = try testing.allocator.alloc(DotServer.Conn, count);
for (conns) |*conn| conn.state = .free;
return conns;
}
test "the connection pool hands out every slot once" {
const conns = try testConns(3);
defer testing.allocator.free(conns);
for (0..conns.len) |expected| {
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
try testing.expectEqual(expected, index);
conns[index].state = .active;
}
}
test "a full connection pool refuses instead of growing" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
for (conns) |*conn| conn.state = .active;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
}
test "a closing slot is not reused until it is free" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
conns[1].state = .closing;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
conns[1].state = .free;
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
}
test "a claim takes the first free slot before shutdown" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
}
test "a claim after shutdown is refused even with a free slot" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
// The refusal must not consume the slot: `deinit` frees it, nothing else.
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
}
test "shutdown outranks capacity" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
}
test "snapshotStats reports the ruling-10 counters" {
var server: DotServer = undefined;
server.core.stats = .{};
server.stats = .{};
bump(&server.stats.connections);
bump(&server.stats.connections);
bump(&server.stats.tls_handshake_failures);
bump(&server.stats.connection_errors);
listener.bump(&server.core.stats.connections);
listener.bump(&server.core.stats.connections);
listener.bump(&server.stats.tls_handshake_failures);
listener.bump(&server.core.stats.connection_errors);
const snapshot = server.snapshotStats();
try testing.expectEqual(@as(u64, 2), snapshot.connections);
@@ -743,7 +405,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))
defer select.cancelDiscard();
try select.concurrent(.work, f, args);
try select.concurrent(.expiry, expire, .{ io, test_budget });
try select.concurrent(.expiry, listener.expire, .{ io, test_budget });
switch (try select.await()) {
.work => |result| return result,
@@ -953,7 +615,7 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
try group.concurrent(io, DotServer.serve, .{ &server, io });
try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address });
try waitForCounter(io, &server.stats.connection_errors, 1);
try waitForCounter(io, &server.core.stats.connection_errors, 1);
const stats = server.snapshotStats();
try testing.expectEqual(@as(u64, 1), stats.connections);