milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
+187
-4
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user