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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user