milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
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
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
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
//! 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user