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
+187 -4
View File
@@ -20,6 +20,13 @@ pub const media_type = "application/dns-message";
pub const min_request_buf = 512;
pub const min_transfer_buf = 1024;
/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an
/// upstream with. They live here rather than beside either caller because a
/// probe that used a different buffer than the running server would answer a
/// question nobody asked.
pub const default_request_buf_len = 1024;
pub const default_transfer_buf_len = 4096;
pub const DohClient = struct {
/// Caller-owned; shared across endpoints, pools connections.
http: *std.http.Client,
@@ -110,11 +117,11 @@ pub const DohClient = struct {
defer req.deinit();
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
return mapError(err, .send);
return mapError(sendCause(&req, err), .send);
// An empty redirect buffer is legal under `.not_allowed`: a redirect
// is an error before the location is ever read.
var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive);
var resp = req.receiveHead(&.{}) catch |err| return mapError(headCause(&req, err), .receive);
if (resp.head.status != .ok) return error.HttpStatus;
// `head.content_type` points into memory that `resp.reader` invalidates,
@@ -129,7 +136,7 @@ pub const DohClient = struct {
var ended = false;
while (len < response_buf.len) {
const n = body.readSliceShort(response_buf[len..]) catch |err|
return mapError(err, .receive);
return mapError(bodyCause(&resp, err), .receive);
len += n;
if (n == 0) {
ended = true;
@@ -140,7 +147,8 @@ pub const DohClient = struct {
// `response_buf` filled exactly. One more read separates a message
// that fits from one that was cut off.
var probe: [1]u8 = undefined;
const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive);
const n = body.readSliceShort(&probe) catch |err|
return mapError(bodyCause(&resp, err), .receive);
if (n != 0) return error.ResponseTooLarge;
}
@@ -166,6 +174,48 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
};
}
const Connection = std.http.Client.Connection;
const Request = std.http.Client.Request;
const Response = std.http.Client.Response;
/// `std.Io.Writer` collapses every send failure to `error.WriteFailed` and
/// stashes the cause on the connection's socket writer. Unwrapping it is what
/// keeps `error.Canceled` and the local resource errors out of the peer fault
/// group, exactly as `concreteWrite` does for DoT.
fn sendCause(req: *const Request, err: anyerror) anyerror {
if (err != error.WriteFailed) return err;
const connection = req.connection orelse return err;
return connection.stream_writer.err orelse err;
}
/// `receiveHead` collapses a transport failure to `error.ReadFailed` and names
/// `Connection.getReadError` as the accessor for the concrete cause.
fn headCause(req: *const Request, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
const connection = req.connection orelse return err;
return readCause(connection, err);
}
/// A body read reports two different kinds of failure through the same
/// `error.ReadFailed`. An HTTP framing fault lands on the response, and only a
/// read that never reached the framing leaves the connection's cause, so the
/// response is consulted first.
fn bodyCause(resp: *const Response, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
if (resp.bodyErr()) |cause| return cause;
const connection = resp.request.connection orelse return err;
return readCause(connection, err);
}
/// `Connection.getReadError` reads the socket reader's stashed cause with `.?`.
/// On a plain connection that is its only source, so calling it with nothing
/// stashed would panic rather than return null; the guard keeps this unwrap
/// total on the path this client can reach without TLS.
fn readCause(connection: *const Connection, err: anyerror) anyerror {
if (connection.protocol == .plain and connection.stream_reader.err == null) return err;
return connection.getReadError() orelse err;
}
/// RFC 8484 §6: the response media type is `application/dns-message`. The
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
/// per RFC 9110 §8.3.1.
@@ -269,3 +319,136 @@ test "mapError maps remaining errors by phase" {
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
}
/// Only the fields the unwrap helpers read are set. The rest of a `Connection`
/// is two buffered streams, a host name and a pool node, none of which the
/// helpers touch.
///
/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's
/// stashed cause through `@fieldParentPtr`, which on a stub would read memory
/// that was never a `Tls`. What the test is about — that the accessor is
/// consulted at all — is the same on both protocols.
fn stubConnection(
read_err: ?std.Io.net.Stream.Reader.Error,
write_err: ?std.Io.net.Stream.Writer.Error,
) Connection {
var connection: Connection = undefined;
connection.protocol = .plain;
connection.stream_reader.err = read_err;
connection.stream_writer.err = write_err;
return connection;
}
fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request {
var req: Request = undefined;
req.connection = connection;
req.reader.body_err = body_err;
return req;
}
test "the send unwrap keeps a cancelled write out of the peer fault group" {
var connection = stubConnection(null, error.Canceled);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the send unwrap keeps a local resource write failure out of the peer fault group" {
var connection = stubConnection(null, error.SystemResources);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
test "the send unwrap reports a peer side cause as a send fault" {
var connection = stubConnection(null, error.ConnectionResetByPeer);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
}
test "the head unwrap keeps a local resource read failure out of the peer fault group" {
var connection = stubConnection(error.SystemResources, null);
var req = stubRequest(&connection, null);
const mapped = mapError(headCause(&req, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
var canceled = stubConnection(error.Canceled, null);
var canceled_req = stubRequest(&canceled, null);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapError(headCause(&canceled_req, error.ReadFailed), .receive),
);
}
test "the head unwrap reports a peer side cause as a receive fault" {
var connection = stubConnection(error.ConnectionResetByPeer, null);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.ReadFailed), .receive),
);
}
test "the body unwrap keeps a cancelled read out of the peer fault group" {
var connection = stubConnection(error.Canceled, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
const mapped = mapError(bodyCause(&resp, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the body unwrap prefers an http framing fault over the connection" {
// A truncated chunk is the peer's doing and the connection carries no
// cause at all, so reading the connection first would report the wrong
// thing on the one path where both could be set.
var connection = stubConnection(null, null);
var req = stubRequest(&connection, error.HttpChunkTruncated);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps report the collapsed error when no cause was stored" {
var connection = stubConnection(null, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed));
try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed));
try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps pass a non-collapsed error through untouched" {
// A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any
// other error already names itself, so the stash must not be read over it.
var connection = stubConnection(error.Canceled, error.Canceled);
var req = stubRequest(&connection, error.HttpChunkInvalid);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream));
try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid));
try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.HttpHeadersInvalid), .receive),
);
}
+19 -39
View File
@@ -179,9 +179,9 @@ pub const DotClient = struct {
var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return mapPhase(err, error.ConnectFailed);
return transport.mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
defer transport.closeBlocked(io, &stream);
// `TlsStream` is pinned: it holds its reader and writer by value and the
// TLS client points at them, so it must not move after `init`.
@@ -205,9 +205,9 @@ pub const DotClient = struct {
.verify_name = self.verify_name,
.cause = cause,
} })});
return mapPhase(cause, error.TlsFailed);
return transport.mapPhase(cause, error.TlsFailed);
};
defer closeTls(io, &tls_stream);
defer transport.closeBlocked(io, &tls_stream);
const writer = tls_stream.writer();
const prefix = transport.framePrefix(@intCast(query.len));
@@ -241,7 +241,7 @@ pub const DotClient = struct {
/// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
/// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
/// local resource failure that must not count against the upstream's
/// health. Scanning here keeps the concrete error for `mapPhase`.
/// health. Scanning here keeps the concrete error for `transport.mapPhase`.
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
{
try self.bundle_lock.lockShared(io);
@@ -259,31 +259,11 @@ pub const DotClient = struct {
self.bundle.deinit(self.gpa);
self.bundle.* = .empty;
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return mapPhase(err, error.TlsFailed);
return transport.mapPhase(err, error.TlsFailed);
};
}
};
/// The pool cancels this task when the attempt budget expires. The next
/// cancelable `Io` call in the `defer` chain would then return `error.Canceled`
/// and skip the close, leaking the socket, so the close runs with cancellation
/// blocked.
fn closeStream(io: std.Io, stream: *net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close();
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// The handshake reads and writes through the socket reader and writer, so a
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
/// `error.WriteFailed` with the cause stashed on those two. Without this,
@@ -318,11 +298,11 @@ fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
}
fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteWrite(stream, err), error.SendFailed);
return transport.mapPhase(concreteWrite(stream, err), error.SendFailed);
}
fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteRead(stream, err), error.ReceiveFailed);
return transport.mapPhase(concreteRead(stream, err), error.ReceiveFailed);
}
const testing = std.testing;
@@ -460,14 +440,14 @@ fn stubStream(
test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
var stream = stubStream(error.Canceled, null, null);
const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
const mapped = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
var stream = stubStream(null, error.SystemResources, null);
const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
const mapped = transport.mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
@@ -476,13 +456,13 @@ test "the handshake unwrap reports a peer side cause as a TLS fault" {
var reset = stubStream(error.ConnectionResetByPeer, null, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
);
var refused = stubStream(null, error.ConnectionRefused, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
);
}
@@ -492,7 +472,7 @@ test "the handshake unwrap reports a TLS fault when no cause was stored" {
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
);
}
@@ -505,11 +485,11 @@ test "the handshake unwrap passes other errors through untouched" {
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
);
}
@@ -547,24 +527,24 @@ test "a CA bundle scan failure keeps local resource errors out of the peer fault
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.TlsFailed)),
transport.group(transport.mapPhase(err, error.TlsFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.TlsFailed),
transport.mapPhase(error.Canceled, error.TlsFailed),
);
// A missing or corrupt bundle is not this process running out of anything,
// so it stays a TLS fault.
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.FileNotFound, error.TlsFailed),
transport.mapPhase(error.FileNotFound, error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
);
}
+6 -59
View File
@@ -181,28 +181,10 @@ pub const Pool = struct {
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]LoopOutcome = undefined;
var race: std.Io.Select(LoopOutcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.loop, exchangeLoopLen, .{
const len = try transport.raceWithin(io, self.timeouts.total, exchangeLoopLen, .{
self, io, query, response_buf,
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.loop => |result| return response_buf[0..try result],
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the budget ran out.
try result;
return error.Timeout;
},
}
});
return response_buf[0..len];
}
/// The two-pass failover loop, as a raceable task. It returns the reply's
@@ -300,9 +282,7 @@ pub const Pool = struct {
return count;
}
/// One exchange raced against the per-attempt budget. No stream read or
/// write in 0.16.0 takes a timeout, so the budget is a second task and the
/// loser is canceled.
/// One exchange raced against the per-attempt budget.
fn attempt(
self: *Pool,
io: std.Io,
@@ -310,28 +290,9 @@ pub const Pool = struct {
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, transport.Client.exchange, .{
return transport.raceWithin(io, self.timeouts.attempt, transport.Client.exchange, .{
entry_client, io, query, response_buf,
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.attempt }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the upstream is slow.
try result;
return error.Timeout;
},
}
});
}
fn entryAvailable(
@@ -367,20 +328,6 @@ pub const Pool = struct {
}
};
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
const LoopOutcome = union(enum) {
loop: transport.ExchangeError!usize,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
const testing = std.testing;
/// A query for example.com A: id 0x1234, RD set, one question.
+202
View File
@@ -229,6 +229,97 @@ pub fn mapLocal(err: anyerror) ?ExchangeError {
};
}
/// Names the peer fault of the phase the call site is in, unless the error is
/// one `mapLocal` claims for this process. Every transport classifies its
/// failures through this one function.
pub fn mapPhase(err: anyerror, phase: PeerFault) ExchangeError {
return mapLocal(err) orelse phase;
}
/// Closes `target` with cancellation blocked.
///
/// A transport's close runs from a `defer` chain that a lost timeout race is
/// unwinding. The next cancelable `Io` call in that chain returns
/// `error.Canceled` and skips the close, leaking the descriptor, so the close
/// swaps cancellation protection for the duration.
///
/// `net.Stream` and `net.Socket` close through an `Io`; `tls_client.TlsStream`
/// owns the one it was built with and takes none. Both shapes are accepted so
/// that one helper covers every close in the transports.
pub fn closeBlocked(io: std.Io, target: anytype) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
const Target = @typeInfo(@TypeOf(target)).pointer.child;
if (@typeInfo(@TypeOf(Target.close)).@"fn".params.len == 2) {
target.close(io);
} else {
target.close();
}
}
/// The payload of `f`'s return type, which `raceWithin` requires to be
/// `ExchangeError!T`. A raced function with any other error set would let a
/// failure reach the pool without passing through `group`.
fn RacedPayload(comptime f: anytype) type {
const info = @typeInfo(@TypeOf(f));
if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f)));
const Return = info.@"fn".return_type orelse
@compileError("raceWithin needs a function with a concrete return type");
const union_info = switch (@typeInfo(Return)) {
.error_union => |u| u,
else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)),
};
if (union_info.error_set != ExchangeError)
@compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return));
return union_info.payload;
}
/// Runs `f(args...)` raced against `budget`, and cancels the loser.
///
/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second
/// task rather than a socket option. This is the one copy of that harness: the
/// pool races an attempt and its whole failover loop through it, and the
/// forward client races its TCP exchange.
///
/// `error.Timeout` means the budget won. A canceled sleep means the whole task
/// is being torn down rather than the budget running out, so it stays
/// `error.Canceled`. A backend that cannot start a second task is
/// `error.SystemResources`, which `group` keeps off the peer's health.
pub fn raceWithin(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f) {
const Outcome = union(enum) {
raced: ExchangeError!RacedPayload(f),
expiry: std.Io.Cancelable!void,
};
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.raced, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.raced => |result| return result,
.expiry => |result| {
try result;
return error.Timeout;
},
}
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// A thing that sends one DNS message and returns one validated DNS message.
/// Implemented by DohClient, DotClient, Pool, and test fakes.
pub const Client = struct {
@@ -430,6 +521,117 @@ test "mapLocal folds only local and cancellation errors" {
try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed));
}
test "mapPhase names the phase unless the error is this process's own" {
try testing.expectEqual(ExchangeError.Canceled, mapPhase(error.Canceled, error.ReceiveFailed));
try testing.expectEqual(
ExchangeError.SystemResources,
mapPhase(error.SystemResources, error.ConnectFailed),
);
try testing.expectEqual(
ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
try testing.expectEqual(
Group.local_resource,
group(mapPhase(error.OutOfMemory, error.SendFailed)),
);
}
/// Stands in for whatever the pool or the forward client races. The variants
/// are the three ways such a task ends: in time, too late, or with a failure of
/// its own.
fn racedReply(io: std.Io, delay_ms: i64, result: ExchangeError!usize) ExchangeError!usize {
if (delay_ms != 0) {
const duration: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(delay_ms), .clock = .awake };
try duration.sleep(io);
}
return result;
}
test "raceWithin returns the raced value when it finishes inside the budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
const len = try raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, 7) });
try testing.expectEqual(@as(usize, 7), len);
}
test "raceWithin returns Timeout when the budget wins" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
const started = std.Io.Clock.awake.now(io);
try testing.expectError(
error.Timeout,
raceWithin(io, budget, racedReply, .{ io, 30_000, @as(ExchangeError!usize, 7) }),
);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Far under the raced task's own sleep, so the budget is provably what
// ended the call rather than the task finishing on its own.
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
}
test "raceWithin passes the raced task's own failure through" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
// A local resource error keeps its group: the race must not turn it into
// the peer fault the budget would have produced.
const failed = raceWithin(io, budget, racedReply, .{
io, 0, @as(ExchangeError!usize, error.OutOfMemory),
});
try testing.expectError(error.OutOfMemory, failed);
try testing.expectError(
error.ConnectFailed,
raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, error.ConnectFailed) }),
);
}
test "closeBlocked closes a target of either close shape" {
// The two shapes the transports use: a socket or a plain stream, which
// closes through the `Io`, and a `TlsStream`, which owns the one it was
// built with. Dispatching on the wrong one is a compile error, so
// instantiating both is the check.
//
// That the close runs with cancellation blocked is not asserted here: the
// Threaded backend's `swapCancelProtection` is a no-op off one of its own
// task threads, so a unit test cannot observe the state it sets.
const WithIo = struct {
closed: bool = false,
fn close(self: *@This(), io: std.Io) void {
_ = io;
self.closed = true;
}
};
const WithoutIo = struct {
closed: bool = false,
fn close(self: *@This()) void {
self.closed = true;
}
};
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var with_io: WithIo = .{};
closeBlocked(io, &with_io);
try testing.expect(with_io.closed);
var without_io: WithoutIo = .{};
closeBlocked(io, &without_io);
try testing.expect(without_io.closed);
}
test "a fake client satisfies the Client interface" {
const Fake = struct {
calls: usize = 0,