//! 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,
        .admission = .{ .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,
    };
}
