Files
nxdns/src/upstream/dot_client_integration_test.zig
T
mokhtar 025edbb093
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m55s
Gates / test-aarch64 (push) Failing after 3h1m8s
Gates / package (push) Successful in 3m55s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 3h21m29s
milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
2026-08-22 19:54:02 +02:00

652 lines
26 KiB
Zig

//! Hermetic loopback tests for DoT session reuse (`dot_client.zig`).
//!
//! This lives in its own file because it needs `@import("build_options")`,
//! which only exists when the compilation is driven by build.zig, and because a
//! DoT server is a whole fixture rather than a stub. `-Dintegration` gates it;
//! nothing here leaves the machine and nothing here resolves a name.
//!
//! The peer is the same mbedTLS `platform/tls_server.zig` the nxdns listener
//! uses, serving RFC 1035 §4.2.2 framed DNS on the plaintext side. Its
//! certificate is the committed self-signed fixture, which is why every test
//! preloads that certificate into its own `Certificate.Bundle`: `DotClient`
//! hardcodes `.ca = .system` and there is deliberately no way to ask it to skip
//! verification — a fixture-only trust anchor is a test's business, an
//! insecure-verify switch in the production client would be a hole in it.
//!
//! Every test races its client work against a budget. The failures under test
//! are "the client waits for a reply that never comes" shaped, and without a
//! deadline those hang the suite instead of failing it.
const std = @import("std");
const build_options = @import("build_options");
const tls = std.crypto.tls;
const net = std.Io.net;
const Certificate = std.crypto.Certificate;
const dot_client = @import("dot_client.zig");
const pool_mod = @import("pool.zig");
const transport = @import("transport.zig");
const tls_server = @import("../platform/tls_server.zig");
const events_fixture = @import("../storage/events_fixture.zig");
const testing = std.testing;
/// Long enough that no handshake or exchange on loopback needs it, short enough
/// that a hang ends the test rather than the suite.
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
/// The fixture certificate carries `DNS:localhost`, and
/// `Certificate.Parsed.verifyHostName` matches dNSName SANs only, so this is
/// the only name a `DotClient` can verify it as. The dial target stays the
/// loopback IP literal.
const fixture_host = "localhost";
/// A query for example.com A: id 0x1234, RD set, one question.
const query_bytes =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
/// The matching response: the question echoed plus one A record.
const response_bytes =
"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
/// The same answer under a different transaction id. Well-framed and the length
/// the prefix claims, so the client reads the whole of it and only
/// `transport.validateResponse` can reject it — which is the point: a validation
/// failure is not an I/O failure, and it has to close the session all the same.
const mismatched_response_bytes =
"\x99\x99\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
// ---------------------------------------------------------------------------
// Server side
// ---------------------------------------------------------------------------
/// The listening half: one mbedTLS context, one loopback listener, and the
/// count of connections the scripts below have accepted. That count is what
/// "one connection" and "no redial" are asserted on.
const Server = struct {
ctx: tls_server.ServerContext,
listener: net.Server,
/// Every dial a script chose to `accept`. The listener stays up for the
/// whole of every test on purpose: a refused dial is invisible to this
/// counter, so a script that stopped listening could not tell "the client
/// did not dial again" from "it dialed and was refused". A dial beyond what
/// the script accepts sits un-handshaken in the backlog instead, stalling
/// the client into its budget — the test fails either way, but only an
/// accepted dial shows up in this count.
accepts: std.atomic.Value(u32) = .init(0),
/// Framed queries taken off the wire. A test that has to know the client is
/// blocked waiting for a reply waits on this rather than on a sleep: a
/// Debug-build ECDSA handshake on loopback is slow enough that any fixed
/// gap is either a flake or a stall.
queries: std.atomic.Value(u32) = .init(0),
fn init(self: *Server, gpa: std.mem.Allocator, io: std.Io) !void {
const fixtures = @import("test_fixtures");
self.* = .{
.ctx = try .init(gpa, fixtures.cert_pem, fixtures.key_pem, null),
.listener = try (net.IpAddress{ .ip4 = .loopback(0) }).listen(io, .{
.reuse_address = true,
}),
};
}
fn deinit(self: *Server, gpa: std.mem.Allocator, io: std.Io) void {
self.listener.deinit(io);
self.ctx.deinit(gpa);
}
fn address(self: *const Server) net.IpAddress {
return self.listener.socket.address;
}
};
/// One accepted connection. Pinned: `ServerStream` recovers its `std.Io`
/// interfaces from their addresses inside itself and hands mbedTLS a pointer to
/// itself as the BIO context, and its plaintext buffers live here too.
const Accepted = struct {
server: *Server,
stream: net.Stream,
tls: tls_server.ServerStream,
read_buffer: [4096]u8,
write_buffer: [4096]u8,
fn accept(self: *Accepted, gpa: std.mem.Allocator, server: *Server, io: std.Io) !void {
self.server = server;
self.stream = try server.listener.accept(io);
_ = server.accepts.fetchAdd(1, .acq_rel);
errdefer self.stream.close(io);
try self.tls.accept(gpa, &server.ctx, io, &self.stream, &self.read_buffer, &self.write_buffer);
}
fn close(self: *Accepted, gpa: std.mem.Allocator, io: std.Io) void {
self.tls.close(gpa);
self.stream.close(io);
}
/// Reads one framed query and writes `response_bytes` back.
fn answerOnce(self: *Accepted) !void {
var query_buf: [2048]u8 = undefined;
_ = try self.readFramed(&query_buf);
try self.writeFramed(response_bytes);
}
fn readFramed(self: *Accepted, buf: []u8) ![]u8 {
var prefix: [transport.prefix_len]u8 = undefined;
try self.tls.reader().readSliceAll(&prefix);
const len = transport.parsePrefix(prefix);
if (len > buf.len) return error.QueryTooLarge;
try self.tls.reader().readSliceAll(buf[0..len]);
_ = self.server.queries.fetchAdd(1, .acq_rel);
return buf[0..len];
}
fn writeFramed(self: *Accepted, message: []const u8) !void {
const prefix = transport.framePrefix(@intCast(message.len));
try self.tls.writer().writeAll(&prefix);
try self.tls.writer().writeAll(message);
try self.tls.writer().flush();
}
};
/// Answers `count` queries on one connection and holds it open until the test
/// tears it down.
fn serveOnOneConnection(
gpa: std.mem.Allocator,
server: *Server,
io: std.Io,
count: usize,
) anyerror!void {
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
for (0..count) |_| try session.answerOnce();
}
/// Answers one query, drops the connection the way an upstream reaps an idle
/// one, then accepts a second and answers one more.
fn serveThenCloseThenServe(gpa: std.mem.Allocator, server: *Server, io: std.Io) anyerror!void {
for (0..2) |_| {
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
try session.answerOnce();
}
}
/// Answers one query, then replies to the next with a single length-prefix byte
/// and closes. The second reply has started, so the client may not redial.
fn serveThenTruncate(gpa: std.mem.Allocator, server: *Server, io: std.Io) anyerror!void {
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
try session.answerOnce();
var query_buf: [2048]u8 = undefined;
_ = try session.readFramed(&query_buf);
try session.tls.writer().writeAll(&[_]u8{0x00});
try session.tls.writer().flush();
}
/// Answers one query, then answers the next with a frame the client can read
/// whole and `transport.validateResponse` must still reject.
fn serveThenAnswerWithWrongId(gpa: std.mem.Allocator, server: *Server, io: std.Io) anyerror!void {
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
try session.answerOnce();
var query_buf: [2048]u8 = undefined;
_ = try session.readFramed(&query_buf);
try session.writeFramed(mismatched_response_bytes);
}
/// Answers one query, drops the connection, then takes the redial and drops that
/// one too — after reading its query and before answering it.
///
/// The redial *succeeds*, which is the point: the exchange that fails is the
/// retry, running on a session this call dialed itself. `retryDecision` gives a
/// fresh session no second chance, so the client owes exactly two dials.
fn serveThenCloseThenFailTheRetry(gpa: std.mem.Allocator, server: *Server, io: std.Io) anyerror!void {
{
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
try session.answerOnce();
}
{
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
// Read the query before dropping the connection: the failure under test
// is the client's *receive*, and a peer that closed before taking the
// query could fail its send instead.
var query_buf: [2048]u8 = undefined;
_ = try session.readFramed(&query_buf);
}
}
/// Completes the handshake, takes the query and never answers it.
fn serveSilently(gpa: std.mem.Allocator, server: *Server, io: std.Io) anyerror!void {
var session: Accepted = undefined;
try session.accept(gpa, server, io);
defer session.close(gpa, io);
var query_buf: [2048]u8 = undefined;
_ = try session.readFramed(&query_buf);
const forever: std.Io.Clock.Duration = .{ .raw = .fromSeconds(3600), .clock = .awake };
try forever.sleep(io);
}
// ---------------------------------------------------------------------------
// Client side
// ---------------------------------------------------------------------------
/// Everything one `DotClient` borrows, plus the client itself.
///
/// Built in place: `DotClient` pins the TLS state of an open session inside
/// itself, and its endpoint and buffers are borrowed from this struct.
const ClientFixture = struct {
gpa: std.mem.Allocator,
bundle: Certificate.Bundle = .empty,
bundle_lock: std.Io.RwLock = .init,
buffers: [4 * tls.Client.min_buffer_len]u8 = undefined,
url_buf: [32]u8 = undefined,
recoveries: std.atomic.Value(u64) = .init(0),
dot: dot_client.DotClient = undefined,
fn init(self: *ClientFixture, gpa: std.mem.Allocator, io: std.Io, address: net.IpAddress) !void {
self.* = .{ .gpa = gpa };
errdefer self.bundle.deinit(gpa);
try self.preloadFixtureCert(io);
const url = try std.fmt.bufPrint(&self.url_buf, "tls://127.0.0.1:{d}", .{address.ip4.port});
const chunk = tls.Client.min_buffer_len;
self.dot = .init(
try .parse(url),
fixture_host,
gpa,
&self.bundle,
&self.bundle_lock,
&self.recoveries,
.{
.tls_read = self.buffers[0..chunk],
.tls_write = self.buffers[chunk .. 2 * chunk],
.stream_read = self.buffers[2 * chunk .. 3 * chunk],
.stream_write = self.buffers[3 * chunk ..],
},
);
}
fn deinit(self: *ClientFixture, io: std.Io) void {
self.dot.close(io);
self.bundle.deinit(self.gpa);
}
/// Makes the self-signed fixture certificate the one trust anchor this
/// client has.
///
/// A non-empty bundle is what both `DotClient.ensureBundle` and
/// `TlsStream.init` check before they would scan the system store, so
/// preloading it also keeps the test off the host's CA directory entirely.
/// The decode mirrors `Certificate.Bundle.addCertsFromFile`, which is the
/// only PEM entry point the stdlib exposes and takes a file; writing the
/// committed fixture back out to disk to read it in again would be the
/// longer way round to the same three calls.
fn preloadFixtureCert(self: *ClientFixture, io: std.Io) !void {
const fixtures = @import("test_fixtures");
const begin_marker = "-----BEGIN CERTIFICATE-----";
const end_marker = "-----END CERTIFICATE-----";
const body_start = (std.mem.find(u8, fixtures.cert_pem, begin_marker) orelse
return error.MissingBeginCertificateMarker) + begin_marker.len;
const body_end = std.mem.findPos(u8, fixtures.cert_pem, body_start, end_marker) orelse
return error.MissingEndCertificateMarker;
const encoded = std.mem.trim(u8, fixtures.cert_pem[body_start..body_end], " \t\r\n");
const decoder = std.base64.standard.decoderWithIgnore(" \t\r\n");
try self.bundle.bytes.ensureUnusedCapacity(self.gpa, encoded.len / 4 * 3 + 3);
const decoded_start: u32 = @intCast(self.bundle.bytes.items.len);
const written = try decoder.decode(
self.bundle.bytes.allocatedSlice()[decoded_start..],
encoded,
);
self.bundle.bytes.items.len += written;
try self.bundle.parseCert(self.gpa, decoded_start, std.Io.Clock.real.now(io).toSeconds());
try testing.expect(self.bundle.map.count() == 1);
}
};
/// Blocks until the server has taken `count` framed queries off the wire.
///
/// Bounded by the same budget as everything else here: a server that died
/// before it read one would otherwise hang the suite.
fn awaitQueries(io: std.Io, server: *Server, count: u32) !void {
const step: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(10), .clock = .awake };
const steps = 1000;
for (0..steps) |_| {
if (server.queries.load(.acquire) >= count) return;
try step.sleep(io);
}
return error.DotFixtureTimedOut;
}
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
const Outcome = union(enum) {
client: anyerror!void,
expiry: std.Io.Cancelable!void,
};
/// Runs `client_work` against the budget and tears the server task down either
/// way: a client that fails before it connects would otherwise leave the server
/// blocked in `accept` forever.
fn runAgainstServer(
io: std.Io,
server_task: *std.Io.Future(anyerror!void),
comptime client_work: anytype,
args: anytype,
) !void {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
try race.concurrent(.client, client_work, args);
try race.concurrent(.expiry, expire, .{ io, budget });
const client_result: anyerror!void = switch (try race.await()) {
.client => |result| result,
.expiry => |result| blk: {
try result;
break :blk error.DotFixtureTimedOut;
},
};
const server_result = if (client_result) |_|
server_task.await(io)
else |_|
server_task.cancel(io);
try client_result;
server_result catch |err| switch (err) {
// The scripts that hold a connection open past their last answer are
// torn down by the cancel above, which is the intended end for them.
error.Canceled => {},
else => return err,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
fn twoExchangesOverOneSession(io: std.Io, fixture: *ClientFixture) anyerror!void {
var buf: [512]u8 = undefined;
const first = try fixture.dot.exchange(io, query_bytes, &buf);
try testing.expectEqualSlices(u8, response_bytes, first);
// The point of the milestone: the connection outlives the exchange.
try testing.expect(fixture.dot.session != null);
const second = try fixture.dot.exchange(io, query_bytes, &buf);
try testing.expectEqualSlices(u8, response_bytes, second);
try testing.expect(fixture.dot.session != null);
}
test "two exchanges through one DoT client share one connection" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
var server_task = try io.concurrent(serveOnOneConnection, .{ gpa, &server, io, @as(usize, 2) });
try runAgainstServer(io, &server_task, twoExchangesOverOneSession, .{ io, &fixture });
try testing.expectEqual(@as(u32, 1), server.accepts.load(.acquire));
try testing.expectEqual(@as(u64, 0), fixture.recoveries.load(.acquire));
}
fn twoPoolExchanges(io: std.Io, pool: *pool_mod.Pool) anyerror!void {
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const first = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, response_bytes, first);
const second = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, response_bytes, second);
}
test "a session the upstream closed is recovered by one redial and counted, not blamed" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
// Through a real pool entry, because the claim is about what the pool does
// *not* see: an upstream reaping an idle connection must not cost it health
// or raise an operational event.
var slots = [_]pool_mod.Slot{.{ .client = fixture.dot.client() }};
var entries = [_]pool_mod.Entry{.{
.endpoint = fixture.dot.endpoint,
.slots = &slots,
.priority = 10,
.enabled = true,
.health = .init,
.sem = .{ .permits = slots.len },
.reuse_recoveries = &fixture.recoveries,
}};
var pool: pool_mod.Pool = .init(&entries, .{
.failure_threshold = 2,
.base_backoff_ms = 60_000,
.max_backoff_ms = 60_000,
}, .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
}, 1);
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
pool.diagnostics = &fx.store;
var server_task = try io.concurrent(serveThenCloseThenServe, .{ gpa, &server, io });
try runAgainstServer(io, &server_task, twoPoolExchanges, .{ io, &pool });
try testing.expectEqual(@as(u32, 2), server.accepts.load(.acquire));
try testing.expectEqual(@as(u64, 1), fixture.recoveries.load(.acquire));
try testing.expectEqual(@as(u64, 2), entries[0].health.total_successes);
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures);
try testing.expectEqual(@as(?std.Io.Timestamp, null), entries[0].health.backoff_until);
try testing.expectEqual(
@as(i64, 0),
try fx.count("SELECT count(*) FROM operational_events"),
);
}
fn exchangeThenReadTruncatedReply(io: std.Io, fixture: *ClientFixture) anyerror!void {
var buf: [512]u8 = undefined;
_ = try fixture.dot.exchange(io, query_bytes, &buf);
// One prefix byte arrived, so the reply had started: re-sending the query on
// a fresh connection would be a second question, not a recovery.
try testing.expectError(error.ReceiveFailed, fixture.dot.exchange(io, query_bytes, &buf));
try testing.expect(fixture.dot.session == null);
}
test "a reused session that failed after one response byte is not redialed" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
var server_task = try io.concurrent(serveThenTruncate, .{ gpa, &server, io });
try runAgainstServer(io, &server_task, exchangeThenReadTruncatedReply, .{ io, &fixture });
// The whole claim: the client never came back for a second connection.
try testing.expectEqual(@as(u32, 1), server.accepts.load(.acquire));
try testing.expectEqual(@as(u64, 0), fixture.recoveries.load(.acquire));
}
fn exchangeThenReadWrongId(io: std.Io, fixture: *ClientFixture) anyerror!void {
var buf: [512]u8 = undefined;
_ = try fixture.dot.exchange(io, query_bytes, &buf);
try testing.expect(fixture.dot.session != null);
// The read succeeded; only the bytes are wrong. Nothing about that says the
// connection is stale, so it is final — but the stream position after a
// frame this client will not trust is unknowable, so the session goes.
try testing.expectError(error.ResponseMismatch, fixture.dot.exchange(io, query_bytes, &buf));
try testing.expect(fixture.dot.session == null);
}
test "a reply that fails validation is final and clears the session without redialing" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
var server_task = try io.concurrent(serveThenAnswerWithWrongId, .{ gpa, &server, io });
try runAgainstServer(io, &server_task, exchangeThenReadWrongId, .{ io, &fixture });
// Both halves of the claim. One accept: the reused session read a whole
// frame, so nothing here is a lifecycle failure and no redial is owed. And
// nothing was recovered, so the counter stays where it was.
try testing.expectEqual(@as(u32, 1), server.accepts.load(.acquire));
try testing.expectEqual(@as(u64, 0), fixture.recoveries.load(.acquire));
}
fn exchangeThenFailTheRetry(io: std.Io, fixture: *ClientFixture) anyerror!void {
var buf: [512]u8 = undefined;
_ = try fixture.dot.exchange(io, query_bytes, &buf);
try testing.expect(fixture.dot.session != null);
// Stale session, no response byte, lifecycle cause: the one redial is owed,
// taken, and connected. The exchange on that fresh session then fails its
// read, and the retry's outcome is the exchange's outcome — no third dial,
// because a session this call dialed itself is never retried.
try testing.expectError(error.ReceiveFailed, fixture.dot.exchange(io, query_bytes, &buf));
try testing.expect(fixture.dot.session == null);
// What the `nxdns check` probe loop relies on: closing a client whose
// exchange already failed is a no-op, not a double close.
fixture.dot.close(io);
try testing.expect(fixture.dot.session == null);
}
test "a stale session whose retry fails is final and leaves no session behind" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
var server_task = try io.concurrent(serveThenCloseThenFailTheRetry, .{ gpa, &server, io });
try runAgainstServer(io, &server_task, exchangeThenFailTheRetry, .{ io, &fixture });
// Exactly two: the first exchange and the one redial. A client that retried
// its retry would need a third dial; the script accepts no third connection,
// so that dial would stall un-handshaken until the client's budget failed
// the test — it cannot succeed silently.
try testing.expectEqual(@as(u32, 2), server.accepts.load(.acquire));
// The redial connected but the exchange on it failed, so nothing was
// recovered and nothing is counted.
try testing.expectEqual(@as(u64, 0), fixture.recoveries.load(.acquire));
}
fn oneExchange(io: std.Io, fixture: *ClientFixture) transport.ExchangeError!void {
var buf: [512]u8 = undefined;
_ = try fixture.dot.exchange(io, query_bytes, &buf);
}
test "a canceled exchange leaves no session open" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var server: Server = undefined;
try server.init(gpa, io);
defer server.deinit(gpa, io);
var fixture: ClientFixture = undefined;
try fixture.init(gpa, io, server.address());
defer fixture.deinit(io);
var server_task = try io.concurrent(serveSilently, .{ gpa, &server, io });
var client_task = try io.concurrent(oneExchange, .{ io, &fixture });
// The query is on the wire and no answer is coming, so the client is
// provably blocked in its read — the state a total-budget expiry cancels an
// exchange in.
try awaitQueries(io, &server, 1);
try testing.expectError(error.Canceled, client_task.cancel(io));
// Mid-frame state is unknowable after a cancellation, so the next exchange
// has to start from a fresh dial.
try testing.expect(fixture.dot.session == null);
try testing.expectEqual(@as(u64, 0), fixture.recoveries.load(.acquire));
server_task.cancel(io) catch |err| switch (err) {
error.Canceled => {},
else => return err,
};
}