upstream: a diagnostics episode follows health, and a peer fault carries its cause
Gates / frontend (push) Successful in 2m5s
Gates / test (push) Successful in 2m43s
Gates / test-aarch64 (push) Successful in 8m19s
Gates / package (push) Successful in 4m21s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 30m43s

This commit is contained in:
2026-09-12 20:24:49 +02:00
parent 6e9a36903e
commit 08cdf86ecd
20 changed files with 1797 additions and 426 deletions
+90 -86
View File
@@ -186,7 +186,7 @@ pub const DotClient = struct {
}
}
pub fn client(self: *DotClient) transport.Client {
pub fn leaf(self: *DotClient) transport.Leaf {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
@@ -199,12 +199,8 @@ pub const DotClient = struct {
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
) transport.LeafError!transport.Outcome {
const self: *DotClient = @ptrCast(@alignCast(ptr));
// The endpoint outlives the client, so the borrow is safe for the whole
// query. Set before the attempt: a failure names this resolver too.
selected.* = self.endpoint.url;
return self.exchange(io, query, response_buf);
}
@@ -222,24 +218,24 @@ pub const DotClient = struct {
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
) transport.LeafError!transport.Outcome {
// The length prefix is 16-bit, so a longer query cannot be framed. No
// listener in this process can produce one; a caller that does gets a
// local error rather than a silently truncated frame.
if (query.len > transport.max_message_len) return error.BufferTooSmall;
const reused = self.session != null;
if (!reused) try self.dial(io);
if (!reused) if (try self.dial(io)) |dial_fault| return .{ .fault = dial_fault };
const failure = switch (self.transact(query, response_buf)) {
.ok => |reply| return reply,
.ok => |reply| return .{ .reply = reply },
.failed => |failure| failure,
};
switch (retryDecision(reused, failure.received_any, failure.cause)) {
.final => {
self.close(io);
return transport.mapPhase(failure.cause, failure.phase);
return .{ .fault = try transport.faultOrLocal(failure.cause, failure.phase) };
},
.retry => {},
}
@@ -249,38 +245,41 @@ pub const DotClient = struct {
// `reuse_recoveries` is what makes the churn visible.
log.debug("{f}", .{self.diagnose(.{ .stale_session = failure.cause })});
self.close(io);
try self.dial(io);
if (try self.dial(io)) |dial_fault| return .{ .fault = dial_fault };
switch (self.transact(query, response_buf)) {
.ok => |reply| {
if (self.reuse_recoveries) |counter| _ = counter.fetchAdd(1, .monotonic);
return reply;
return .{ .reply = reply };
},
// The retry's outcome is the exchange's outcome: one redial, never
// two.
.failed => |retried| {
self.close(io);
return transport.mapPhase(retried.cause, retried.phase);
return .{ .fault = try transport.faultOrLocal(retried.cause, retried.phase) };
},
}
}
/// Opens a session and leaves it in `self.session`, or leaves `self.session`
/// null and returns the classified failure. No partially initialized
/// session ever survives this call.
fn dial(self: *DotClient, io: std.Io) transport.ExchangeError!void {
/// null and returns the classified fault. `null` means a session is open. No
/// partially initialized session ever survives this call.
fn dial(self: *DotClient, io: std.Io) transport.LeafError!?transport.Fault {
std.debug.assert(self.session == null);
const address = resolveAddress(self.endpoint) catch |err| {
// A host that is not an IP literal is a config error, and
// `resolveAddress` has already folded the parse failure into the
// classification, so the cause it carries is the classification.
log.warn("{f}", .{self.diagnose(.not_an_ip_literal)});
return err;
return .{ .kind = err, .cause = err };
};
try self.ensureBundle(io);
if (try self.ensureBundle(io)) |bundle_fault| return bundle_fault;
const stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return transport.mapPhase(err, error.ConnectFailed);
return try transport.faultOrLocal(err, error.ConnectFailed);
};
// Emplaced before the handshake, never built beside it and copied in:
@@ -314,8 +313,9 @@ pub const DotClient = struct {
.verify_name = self.verify_name,
.cause = cause,
} })});
return transport.mapPhase(cause, error.TlsFailed);
return try transport.faultOrLocal(cause, error.TlsFailed);
};
return null;
}
/// One query and one reply on the open session, with enough detail on
@@ -380,17 +380,17 @@ 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 `transport.mapPhase`.
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
/// health. Scanning here keeps the concrete error for the fault.
fn ensureBundle(self: *DotClient, io: std.Io) transport.LeafError!?transport.Fault {
{
try self.bundle_lock.lockShared(io);
defer self.bundle_lock.unlockShared(io);
if (self.bundle.map.count() != 0) return;
if (self.bundle.map.count() != 0) return null;
}
try self.bundle_lock.lock(io);
defer self.bundle_lock.unlock(io);
if (self.bundle.map.count() != 0) return;
if (self.bundle.map.count() != 0) return null;
// A partial scan leaves entries in `map`, which the check above would
// read as "already loaded". Reset so the next exchange scans again.
@@ -398,8 +398,9 @@ pub const DotClient = struct {
self.bundle.deinit(self.gpa);
self.bundle.* = .empty;
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return transport.mapPhase(err, error.TlsFailed);
return try transport.faultOrLocal(err, error.TlsFailed);
};
return null;
}
};
@@ -642,40 +643,39 @@ 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 = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
try testing.expectError(
error.Canceled,
transport.faultOrLocal(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
);
}
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 = 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));
try testing.expectError(
error.SystemResources,
transport.faultOrLocal(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed),
);
}
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,
transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
);
const read_failed = try transport.faultOrLocal(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.PeerFault.TlsFailed, read_failed.kind);
try testing.expectEqual(@as(anyerror, error.ConnectionResetByPeer), read_failed.cause);
var refused = stubStream(null, error.ConnectionRefused, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
);
const write_failed = try transport.faultOrLocal(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed);
try testing.expectEqual(transport.PeerFault.TlsFailed, write_failed.kind);
try testing.expectEqual(@as(anyerror, error.ConnectionRefused), write_failed.cause);
}
test "the handshake unwrap reports a TLS fault when no cause was stored" {
var stream = stubStream(null, null, null);
try testing.expectEqual(error.ReadFailed, concreteHandshake(&stream, error.ReadFailed));
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
);
const failed = try transport.faultOrLocal(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.PeerFault.TlsFailed, failed.kind);
try testing.expectEqual(@as(anyerror, error.ReadFailed), failed.cause);
}
test "the handshake unwrap passes other errors through untouched" {
@@ -685,42 +685,52 @@ test "the handshake unwrap passes other errors through untouched" {
concreteHandshake(&stream, error.CertificateExpired),
);
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.Canceled,
transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
const expired = try transport.faultOrLocal(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed);
try testing.expectEqual(transport.PeerFault.TlsFailed, expired.kind);
try testing.expectEqual(@as(anyerror, error.CertificateExpired), expired.cause);
try testing.expectError(
error.Canceled,
transport.faultOrLocal(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
);
}
/// What `exchange` would return for a failure `transact` reported.
fn mappedFailure(outcome: Transact) transport.ExchangeError {
return transport.mapPhase(outcome.failed.cause, outcome.failed.phase);
/// The fault `exchange` returns for a failure `transact` reported. A local cause leaves this as an
/// error, which is what the tests of those causes assert instead.
fn mappedFailure(outcome: Transact) transport.LeafError!transport.Fault {
return transport.faultOrLocal(outcome.failed.cause, outcome.failed.phase);
}
test "the send and receive unwraps prefer the stored cause" {
var send = stubStream(null, error.Canceled, null);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mappedFailure(sendFailed(&send, error.WriteFailed)),
);
try testing.expectError(error.Canceled, mappedFailure(sendFailed(&send, error.WriteFailed)));
// The TLS client's own error wins over the socket reader's.
var receive = stubStream(error.ConnectionResetByPeer, null, error.TlsAlert);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mappedFailure(receiveFailed(&receive, error.ReadFailed, false)),
);
const received = try mappedFailure(receiveFailed(&receive, error.ReadFailed, false));
try testing.expectEqual(transport.PeerFault.ReceiveFailed, received.kind);
try testing.expectEqual(@as(anyerror, error.TlsAlert), received.cause);
var socket = stubStream(error.SystemResources, null, null);
try testing.expectEqual(
transport.ExchangeError.SystemResources,
try testing.expectError(
error.SystemResources,
mappedFailure(receiveFailed(&socket, error.ReadFailed, true)),
);
}
test "a transact failure becomes a fault carrying its concrete cause" {
var stream = stubStream(null, error.ConnectionResetByPeer, null);
const sent = try mappedFailure(sendFailed(&stream, error.WriteFailed));
try testing.expectEqual(transport.PeerFault.SendFailed, sent.kind);
try testing.expectEqual(@as(anyerror, error.ConnectionResetByPeer), sent.cause);
var text: [64]u8 = undefined;
try testing.expectEqualStrings(
"SendFailed (cause ConnectionResetByPeer)",
try std.fmt.bufPrint(&text, "{f}", .{sent}),
);
}
test "a send failure is always pre-first-byte, and a receive failure reports what it read" {
// The retry rule reads `received_any`, so where it comes from is part of the
// contract rather than an incidental field: nothing is read before the query
@@ -804,14 +814,13 @@ test "a validation failure is final and its phase survives the mapping" {
};
for (outcomes) |outcome| {
try testing.expectEqual(RetryDecision.final, retryDecision(true, true, outcome.cause));
try testing.expectEqual(
@as(transport.ExchangeError, outcome.phase),
mappedFailure(.{ .failed = .{
.cause = outcome.cause,
.phase = outcome.phase,
.received_any = true,
} }),
);
const failed = try mappedFailure(.{ .failed = .{
.cause = outcome.cause,
.phase = outcome.phase,
.received_any = true,
} });
try testing.expectEqual(outcome.phase, failed.kind);
try testing.expectEqual(outcome.cause, failed.cause);
}
}
@@ -830,26 +839,21 @@ test "a CA bundle scan failure keeps local resource errors out of the peer fault
transport.Group.local_resource,
transport.group(transport.mapPhase(err, error.TlsFailed)),
);
try testing.expectError(err, transport.faultOrLocal(err, error.TlsFailed));
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
transport.mapPhase(error.Canceled, error.TlsFailed),
);
try testing.expectError(error.Canceled, transport.faultOrLocal(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,
transport.mapPhase(error.FileNotFound, error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
);
// so it stays a TLS fault, and the cause names which of the two it was.
for ([_]anyerror{ error.FileNotFound, error.MissingEndCertificateMarker }) |err| {
const failed = try transport.faultOrLocal(err, error.TlsFailed);
try testing.expectEqual(transport.PeerFault.TlsFailed, failed.kind);
try testing.expectEqual(err, failed.cause);
}
}
test "DotClient satisfies the Client interface" {
test "DotClient satisfies the Leaf interface" {
const gpa = testing.allocator;
const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len);
@@ -878,7 +882,7 @@ test "DotClient satisfies the Client interface" {
dot.close(undefined);
try testing.expect(dot.session == null);
const iface: transport.Client = dot.client();
const iface: transport.Leaf = dot.leaf();
try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr);
}