//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2).
//!
//! The shape is web/server.zig's: one `std.http.Server` per connection over the
//! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and
//! a keep-alive loop per connection that ends on
//! `error.HttpConnectionClosing`. The difference is the transport: after the TCP
//! accept, a certificate generation is pinned (`CertStore.acquire`) and
//! `ServerStream.accept` runs the TLS handshake through
//! `listener.handshakeStage`, and `std.http.Server` sits on the stream's
//! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//!
//! The handshake runs under the same race budget tcp_server applies to its
//! reads (ruling 3's rationale): a client that connects and never handshakes
//! must not pin one of the 64 slots forever.
//!
//! Over capacity the raw TCP stream is closed and counted, with no response.
//! The web listener's 503 is not possible here: HTTP exists only on the far
//! side of a TLS handshake, and a handshake costs a slot's buffers and a
//! certificate acquisition — exactly what an over-capacity listener does not
//! have to spend. Closing is the cheapest honest refusal.
//!
//! Only `/dns-query` exists. This is not the admin API, so errors are plain
//! text, not JSON, and there are no cache-control headers (LAN, no
//! intermediaries).

const std = @import("std");
const http = std.http;
const net = std.Io.net;
const Allocator = std.mem.Allocator;

const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const doh_client = @import("../upstream/doh_client.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const model = @import("../config/model.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");

pub const dns_query_path = "/dns-query";

/// Ruling 5. Mbed TLS records the pointer, so the list must outlive every
/// `ServerContext` built with it; a module-scope comptime constant has static
/// lifetime. The composition root passes this to the DoH endpoint's
/// `CertStore.init`.
pub const alpn_protocols: [*:null]const ?[*:0]const u8 = &.{"http/1.1"};

/// The `ServerStream` plaintext receive buffer, which `std.http.Server` also
/// uses as the request head, so this is the maximum head size
/// (http/Server.zig:32 sets `max_head_len` from it).
const recv_buffer_len = 8 * 1024;
const send_buffer_len = 4 * 1024;

pub const default_max_connections: u16 = 64;

const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };

pub const Options = struct {
    max_connections: u16 = default_max_connections,
    /// The budget for the TLS handshake and for the wait on the next request
    /// head of a keep-alive connection (milestone-16 ruling 10). A request
    /// already being served has no timeout, like the web listener: the port is
    /// LAN-facing and the cancel path is what bounds shutdown. What is raced is
    /// every wait on a peer that owes nxdns bytes and has sent none.
    idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};

/// What DoH counts on top of `listener.CoreStats`.
pub const Stats = struct {
    tls_handshake_failures: std.atomic.Value(u64) = .init(0),
    /// Every 4xx answered on `/dns-query` and every miss beside it: the
    /// visibility counter for clients that speak, but speak wrongly.
    bad_requests: std.atomic.Value(u64) = .init(0),
};

pub const Snapshot = struct {
    connections: u64,
    rejected_at_capacity: u64,
    rejected_at_shutdown: u64,
    accept_errors: u64,
    tls_handshake_failures: u64,
    /// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
    /// request head on the wire. A stalled handshake counts as a handshake
    /// failure instead (milestone-16 ruling 9), so this name means only what
    /// it says.
    idle_timeouts: u64,
    connection_errors: u64,
    bad_requests: u64,
};

pub const DohServer = struct {
    core: listener.Core(Config),
    handler: *handler.Handler,
    certs: *cert_store.CertStore,
    options: Options,
    stats: Stats,

    /// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The
    /// two message buffers cannot shrink: a POST body and the reply both go up
    /// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext
    /// buffers belong to the core; its `read_buf` doubles as the HTTP head cap
    /// (see `recv_buffer_len`).
    pub const Payload = struct {
        /// The decoded query: a POST body or a GET `dns` parameter.
        query: [transport.max_message_len]u8,
        reply: [transport.max_message_len]u8,
        /// The handler's per-query working memory. A connection is answered
        /// serially, so one query uses it at a time.
        scratch: handler.Scratch,
        /// Valid between a successful `ServerStream.accept` and the
        /// `close(gpa)` in `serveOne`'s defer.
        tls: tls_server.ServerStream,
    };

    const Config = struct {
        pub const Owner = DohServer;
        pub const ConnPayload = Payload;
        pub const serveConn = serveOne;
        pub const read_buffer_len = recv_buffer_len;
        pub const write_buffer_len = send_buffer_len;
        pub const log = std.log.scoped(.doh_server);
        pub const name = "doh";
    };

    pub const Conn = listener.Core(Config).Conn;
    pub const ListenError = listener.Core(Config).ListenError;

    pub fn listen(
        gpa: Allocator,
        io: std.Io,
        listen_address: net.IpAddress,
        h: *handler.Handler,
        certs: *cert_store.CertStore,
        options: Options,
    ) ListenError!DohServer {
        return .{
            .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
            .handler = h,
            .certs = certs,
            .options = options,
            .stats = .{},
        };
    }

    /// The kernel-assigned address. A port of 0 in `listen` resolves here.
    pub fn boundAddress(self: *const DohServer) net.IpAddress {
        return self.core.boundAddress();
    }

    pub fn snapshotStats(self: *const DohServer) Snapshot {
        const core = &self.core.stats;
        return .{
            .connections = core.connections.load(.monotonic),
            .rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
            .rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic),
            .accept_errors = core.accept_errors.load(.monotonic),
            .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
            .idle_timeouts = core.idle_timeouts.load(.monotonic),
            .connection_errors = core.connection_errors.load(.monotonic),
            .bad_requests = self.stats.bad_requests.load(.monotonic),
        };
    }

    /// Accept loop. Returns when the task is canceled or `deinit` stops it.
    pub fn serve(self: *DohServer, io: std.Io) void {
        self.core.serve(io);
    }

    pub fn deinit(self: *DohServer, io: std.Io) void {
        self.core.deinit(io);
        self.* = undefined;
    }

    /// One connection: pin, handshake, keep-alive loop, close_notify, release —
    /// the ordering `listener.handshakeStage` documents. The core closes the
    /// TCP stream after this returns.
    fn serveOne(self: *DohServer, io: std.Io, index: usize) void {
        const conn = &self.core.conns[index];
        const stats = &self.core.stats;
        const gpa = self.core.gpa;

        // Pinned for the whole connection (ruling 6): a reload never frees the
        // generation this stream handshook against.
        const entry = self.certs.acquire(io);
        defer self.certs.release(io, entry);

        const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
        switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) {
            .ok => {},
            .canceled => return,
            // Milestone-16 ruling 9: a stalled handshake is refused like a broken
            // one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
            // wait below, so the two listeners export the same names for the
            // same events.
            .timed_out, .failed => {
                listener.bump(&self.stats.tls_handshake_failures);
                return;
            },
        }
        // Flushes, sends close_notify and frees the TLS context on every exit
        // path below; the core closes the TCP stream afterwards.
        defer conn.payload.tls.close(gpa);

        var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer());

        while (connection.reader.state == .ready) {
            // Milestone-16 ruling 10: the wait for the next request head is the
            // one place a vanished keep-alive peer could pin a slot forever, so
            // it runs under the same budget as the handshake. The body read and
            // `handleRequest` below stay untimed.
            var head: ReceiveHeadResult = error.ReadFailed;
            switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
                .ok => {},
                .timed_out => {
                    listener.bump(&stats.idle_timeouts);
                    return;
                },
                // Cancellation is shutdown; `.failed` here is only the wrapper
                // failing to start, which costs nothing and counts as nothing.
                .canceled, .failed => return,
            }

            var request = head catch |err| switch (err) {
                // The normal end of a keep-alive connection.
                error.HttpConnectionClosing => return,
                // A vanished client lands here; not worth a counter.
                error.ReadFailed => return,
                error.HttpHeadersOversize,
                error.HttpRequestTruncated,
                error.HttpHeadersInvalid,
                => {
                    listener.bump(&stats.connection_errors);
                    return;
                },
            };

            // RFC 9110 §8.6: a request with neither content-length nor
            // transfer-encoding has an empty body, but std leaves the head
            // saying "unknown" and `discardBody` asserts on it inside every
            // `respond` (http/Server.zig:631). A zero length is what the head
            // means, and `bodyReader` goes straight to `.ready` on it.
            if (request.head.method.requestHasBody() and
                request.head.transfer_encoding == .none and
                request.head.content_length == null)
            {
                request.head.content_length = 0;
            }

            const next = self.handleRequest(io, conn, &request) catch |err| switch (err) {
                // The peer went away mid-response. Normal.
                error.WriteFailed => return,
                error.HttpExpectationFailed, error.ReadFailed => {
                    listener.bump(&stats.connection_errors);
                    return;
                },
            };
            // The loop condition alone cannot end these connections: after a
            // fully-read POST body the stdlib reader is `.ready` again even
            // when the response said `connection: close`, so a client that
            // ignores the header could keep the slot. The server hangs up.
            if (next == .close) return;
        }
    }

    /// The `listener.handshakeStage` stage: everything one mbedTLS handshake
    /// needs, plus the close that undoes it.
    const Handshake = struct {
        conn: *Conn,
        gpa: Allocator,
        ctx: *tls_server.ServerContext,
        io: std.Io,

        pub fn accept(self: Handshake) anyerror!void {
            const conn = self.conn;
            try conn.payload.tls.accept(
                self.gpa,
                self.ctx,
                self.io,
                &conn.stream,
                &conn.read_buf,
                &conn.write_buf,
            );
        }

        pub fn close(self: Handshake) void {
            self.conn.payload.tls.close(self.gpa);
        }
    };

    const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed };

    /// What `serveConn`'s keep-alive loop does after the response went out.
    const Next = enum { keep_open, close };

    fn handleRequest(
        self: *DohServer,
        io: std.Io,
        conn: *Conn,
        request: *http.Server.Request,
    ) HandleError!Next {
        // Request smuggling guard: for methods where `requestHasBody` is
        // false, `respond` (http/Server.zig:618) never drains body bytes, so
        // content-length or chunked framing on e.g. a GET would leave those
        // bytes in the stream to be parsed as the next request head. Routing
        // still decides the status (a framed DELETE is a 405, a framed GET
        // beside `/dns-query` a 404); the guard only forces the connection
        // closed, which discards the framed bytes without reading them.
        const frames_unreadable = framesUnreadableBody(
            request.head.method,
            request.head.transfer_encoding,
            request.head.content_length,
        );
        // Any refusal while framed body bytes are still unread must also
        // close, whatever the method: with keep_alive=true `respond` drains
        // the ENTIRE body before sending a byte (http/Server.zig:618 keeps the
        // connection only after `discardRemaining`), so a chunked body that
        // never terminates would pin the slot with no response out.
        // keep_alive=false skips the drain; the routed status still goes out
        // first, then the connection ends.
        const keep = !framesBody(request.head.transfer_encoding, request.head.content_length);

        const target = request.head.target;
        const split = std.mem.findScalar(u8, target, '?') orelse target.len;
        if (!std.mem.eql(u8, target[0..split], dns_query_path)) {
            return self.refuse(request, .not_found, "not found\n", &.{}, keep);
        }

        switch (request.head.method) {
            .GET => {
                // The routed method itself must not frame a body; refused
                // before the query string is even looked at.
                if (frames_unreadable) {
                    return self.refuse(request, .bad_request, "bad request\n", &.{}, false);
                }
                const raw_query = if (split == target.len) "" else target[split + 1 ..];
                const value = switch (dnsParam(raw_query)) {
                    .value => |v| v,
                    .missing, .duplicate => {
                        return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
                    },
                };
                const query = decodeDnsValue(value, &conn.payload.query) catch {
                    return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
                };
                return self.answer(io, conn, request, query);
            },
            .POST => {
                if (!doh_client.contentTypeOk(request.head.content_type)) {
                    return self.refuse(request, .unsupported_media_type, "unsupported media type\n", &.{}, keep);
                }
                // Refused before any body byte is read; keep_alive=false so the
                // oversize body is never drained, the connection just ends.
                if (request.head.content_length) |len| if (len > transport.max_message_len) {
                    return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
                };
                const reader = try request.readerExpectContinue(&.{});
                const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed;
                // A full buffer is either a message of exactly the DNS maximum
                // or a chunked body that keeps going; one probe byte decides.
                if (got == conn.payload.query.len) {
                    var probe: [1]u8 = undefined;
                    const extra = reader.readSliceShort(&probe) catch return error.ReadFailed;
                    if (extra != 0) {
                        return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
                    }
                }
                return self.answer(io, conn, request, conn.payload.query[0..got]);
            },
            else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep),
        }
    }

    /// Ruling 2: `.drop` means "no answer on purpose" — malformed or
    /// refused-silent, which includes the empty body — and maps to 400 with
    /// the connection closed, GET and POST alike.
    fn answer(
        self: *DohServer,
        io: std.Io,
        conn: *Conn,
        request: *http.Server.Request,
        query: []const u8,
    ) error{ WriteFailed, HttpExpectationFailed }!Next {
        const outcome = self.handler.handle(
            io,
            .tcp,
            address.NetAddress.fromIp(conn.peer),
            query,
            &conn.payload.reply,
            &conn.payload.scratch,
        );
        switch (outcome) {
            .drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false),
            .reply => |bytes| {
                try request.respond(bytes, .{
                    .extra_headers = &.{.{ .name = "content-type", .value = doh_client.media_type }},
                });
                // `respond` honors the request head's `connection: close`
                // (http/Server.zig:628), so the loop must agree with the wire.
                return if (request.head.keep_alive) .keep_open else .close;
            },
        }
    }

    fn refuse(
        self: *DohServer,
        request: *http.Server.Request,
        status: http.Status,
        body: []const u8,
        extra_headers: []const http.Header,
        keep_alive: bool,
    ) error{ WriteFailed, HttpExpectationFailed }!Next {
        listener.bump(&self.stats.bad_requests);
        try request.respond(body, .{
            .status = status,
            .keep_alive = keep_alive,
            .extra_headers = extra_headers,
        });
        // `respond` ANDs `keep_alive` with the request head's own
        // (http/Server.zig:628); a client that asked to close gets
        // `connection: close` either way, and the loop must agree.
        return if (keep_alive and request.head.keep_alive) .keep_open else .close;
    }
};

const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request;

/// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an
/// `anyerror!void` operation, so the request — or the error that replaced it —
/// travels through a pointer instead of a return value.
fn receiveHeadInto(connection: *http.Server, out: *ReceiveHeadResult) anyerror!void {
    out.* = connection.receiveHead();
}

/// True when the head frames body bytes on the wire. A bare
/// `content-length: 0` frames nothing.
fn framesBody(transfer_encoding: http.TransferEncoding, content_length: ?u64) bool {
    return transfer_encoding != .none or (content_length orelse 0) != 0;
}

/// True when the request frames a body the server would never read: the
/// stdlib drains bodies only for methods where `requestHasBody` is true, so
/// content-length > 0 or any transfer-encoding on the other methods would
/// smuggle those bytes into the next request head.
fn framesUnreadableBody(
    method: http.Method,
    transfer_encoding: http.TransferEncoding,
    content_length: ?u64,
) bool {
    if (method.requestHasBody()) return false;
    return framesBody(transfer_encoding, content_length);
}

const DnsParam = union(enum) {
    missing,
    /// Two `dns` parameters answer differently depending on which one is read;
    /// a validating decoder refuses to pick.
    duplicate,
    value: []const u8,
};

/// Finds the `dns` parameter in a raw query string. No percent-decoding
/// happens anywhere in this parser: RFC 8484 §4.1 defines the value as
/// base64url, whose alphabet contains nothing that needs escaping, so a `%` is
/// simply an invalid character for the decoder to reject.
fn dnsParam(raw_query: []const u8) DnsParam {
    var found: ?[]const u8 = null;
    var it = std.mem.splitScalar(u8, raw_query, '&');
    while (it.next()) |pair| {
        if (!std.mem.startsWith(u8, pair, "dns=")) continue;
        if (found != null) return .duplicate;
        found = pair["dns=".len..];
    }
    return if (found) |value| .{ .value = value } else .missing;
}

/// RFC 8484 §6: base64url without padding. Padding, whitespace, `%` and every
/// other character outside the alphabet are rejected, not skipped — a lenient
/// decode would make distinct request strings alias one query.
fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 {
    if (value.len == 0) return error.Invalid;
    const decoder = std.base64.url_safe_no_pad.Decoder;
    const len = decoder.calcSizeForSlice(value) catch return error.Invalid;
    if (len > dest.len) return error.Invalid;
    decoder.decode(dest[0..len], value) catch return error.Invalid;
    return dest[0..len];
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const build_options = @import("build_options");
const fixtures = @import("test_fixtures");
const testing = std.testing;

const header_mod = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const records = @import("../local/records.zig");
const local_tables_mod = @import("local_tables.zig");
const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig");

test "framesBody sees framing in either header and none in content-length: 0" {
    try testing.expect(framesBody(.chunked, null));
    try testing.expect(framesBody(.none, 4));
    try testing.expect(!framesBody(.none, null));
    try testing.expect(!framesBody(.none, 0));
}

test "framesUnreadableBody flags body framing only on bodyless methods" {
    try testing.expect(framesUnreadableBody(.GET, .none, 4));
    try testing.expect(framesUnreadableBody(.GET, .chunked, null));
    try testing.expect(framesUnreadableBody(.HEAD, .none, 1));
    try testing.expect(!framesUnreadableBody(.GET, .none, null));
    try testing.expect(!framesUnreadableBody(.GET, .none, 0));
    try testing.expect(!framesUnreadableBody(.POST, .none, 4));
    try testing.expect(!framesUnreadableBody(.POST, .chunked, null));
}

test "dnsParam finds the value among other parameters" {
    try testing.expectEqualStrings("AAAB", dnsParam("dns=AAAB").value);
    try testing.expectEqualStrings("AAAB", dnsParam("ct=x&dns=AAAB&other=1").value);
    // An empty value is found here and rejected by the decoder.
    try testing.expectEqualStrings("", dnsParam("dns=").value);
}

test "dnsParam refuses a missing and a duplicated parameter" {
    try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("")));
    try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("ct=x")));
    // "dns" as a prefix of another key is not the dns parameter.
    try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("dnsx=AAAB")));
    try testing.expectEqual(.duplicate, std.meta.activeTag(dnsParam("dns=AAAB&dns=AAAB")));
}

test "decodeDnsValue round-trips base64url without padding" {
    const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00";
    var encoded: [64]u8 = undefined;
    const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query);

    var decoded: [64]u8 = undefined;
    try testing.expectEqualSlices(u8, query, try decodeDnsValue(value, &decoded));
}

test "decodeDnsValue rejects padding, whitespace, percent and the empty value" {
    var buf: [64]u8 = undefined;
    try testing.expectError(error.Invalid, decodeDnsValue("", &buf));
    try testing.expectError(error.Invalid, decodeDnsValue("YWJj=", &buf));
    try testing.expectError(error.Invalid, decodeDnsValue("YW Jj", &buf));
    try testing.expectError(error.Invalid, decodeDnsValue("YWJj\t", &buf));
    try testing.expectError(error.Invalid, decodeDnsValue("YW%3D", &buf));
    try testing.expectError(error.Invalid, decodeDnsValue("Y!Jj", &buf));
}

test "decodeDnsValue refuses a value larger than its buffer" {
    var big: [16]u8 = undefined;
    @memset(&big, 'A');
    var small: [4]u8 = undefined;
    try testing.expectError(error.Invalid, decodeDnsValue(&big, &small));
}

// ---------------------------------------------------------------------------
// integration tests (-Dintegration): loopback DoH over a real TLS handshake
// ---------------------------------------------------------------------------

const blocking_defaults: model.Blocking = .{};
const test_blocking: response.Options = .{
    .mode = blocking_defaults.response,
    .ttl = blocking_defaults.ttl,
};
const test_forward_timeout: std.Io.Clock.Duration = .{
    .raw = model.readTimeout(.{}),
    .clock = .awake,
};

const test_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };

const local_rows = [_]model.LocalRecord{
    .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 120 },
};

/// A query for nas.lan A: id 0xBEEF, RD set, one question, no OPT. The harness
/// handler answers it from `local_rows`, never from an upstream.
const query_bytes =
    "\xbe\xef\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
    "\x03nas\x03lan\x00\x00\x01\x00\x01";

/// Proves every 200 came from the local-records table: any upstream exchange
/// fails the query into SERVFAIL, which the assertions below would catch.
const FailingUpstream = struct {
    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        _ = ptr;
        _ = io;
        _ = query;
        _ = response_buf;
        selected.* = "fake://failing-upstream";
        return error.ConnectFailed;
    }

    fn client(self: *FailingUpstream) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }
};

/// One whole server: fixture certs written to a tmp dir for the `CertStore`,
/// a local-records handler, and a serving listener on 127.0.0.1. Must not move
/// after `start` — the store borrows the path buffers and the handler borrows
/// the tables.
const Harness = struct {
    threaded: std.Io.Threaded,
    tmp: testing.TmpDir,
    cert_path_buf: [128]u8,
    key_path_buf: [128]u8,
    store: cert_store.CertStore,
    tables: local_tables_mod.LocalTables,
    upstream: FailingUpstream,
    upstream_owner: upstream_owner.Borrowed,
    h: handler.Handler,
    server: DohServer,
    group: std.Io.Group,

    fn start(hx: *Harness) !void {
        return hx.startWith(.{ .max_connections = 4 });
    }

    fn startWith(hx: *Harness, options: Options) !void {
        hx.threaded = .init(testing.allocator, .{});
        errdefer hx.threaded.deinit();
        const hio = hx.threaded.io();

        hx.tmp = testing.tmpDir(.{});
        errdefer hx.tmp.cleanup();
        try hx.tmp.dir.writeFile(hio, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
        try hx.tmp.dir.writeFile(hio, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
        const cert_path = try std.fmt.bufPrint(&hx.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{hx.tmp.sub_path});
        const key_path = try std.fmt.bufPrint(&hx.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{hx.tmp.sub_path});

        hx.store = try cert_store.CertStore.init(testing.allocator, hio, cert_path, key_path, alpn_protocols);
        errdefer hx.store.deinit(hio);

        hx.tables = .{ .records = try records.Records.build(testing.allocator, &local_rows) };
        errdefer hx.tables.deinit(testing.allocator);

        hx.upstream = .{};
        hx.upstream_owner = .{};
        hx.h = .{
            .upstream = hx.upstream_owner.client(hx.upstream.client()),
            .policy = .{ .blocking = test_blocking, .forward_read_timeout = test_forward_timeout },
            .local_tables = &hx.tables,
        };

        const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
        hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
        errdefer hx.server.deinit(hio);

        hx.group = .init;
        try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio });
    }

    fn stop(hx: *Harness) void {
        const hio = hx.threaded.io();
        hx.server.deinit(hio);
        hx.group.await(hio) catch |err| switch (err) {
            error.Canceled => unreachable,
        };
        hx.store.deinit(hio);
        hx.tables.deinit(testing.allocator);
        hx.tmp.cleanup();
        hx.threaded.deinit();
    }

    fn io(hx: *Harness) std.Io {
        return hx.threaded.io();
    }

    fn addr(hx: *const Harness) net.IpAddress {
        return hx.server.boundAddress();
    }
};

const TestOutcome = union(enum) {
    work: anyerror!void,
    expiry: std.Io.Cancelable!void,
};

/// Runs the client side under a budget so a server that never answers fails
/// the test instead of hanging the run.
fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void {
    var outcomes: [2]TestOutcome = undefined;
    var select: std.Io.Select(TestOutcome) = .init(io, &outcomes);
    defer select.cancelDiscard();

    try select.concurrent(.work, f, args);
    try select.concurrent(.expiry, listener.expire, .{ io, test_budget });

    switch (try select.await()) {
        .work => |result| return result,
        .expiry => |result| {
            try result;
            return error.TestTimedOut;
        },
    }
}

/// One TLS client connection speaking raw HTTP/1.1 text, the ruling 12 shape:
/// no std.http.Client and no CA ceremony, just `std.crypto.tls.Client` with
/// verification off against the self-signed fixture.
const ClientConn = struct {
    stream: net.Stream,
    transport_read_buf: [std.crypto.tls.Client.min_buffer_len]u8,
    transport_write_buf: [std.crypto.tls.Client.min_buffer_len]u8,
    plaintext_read_buf: [4096]u8,
    plaintext_write_buf: [4096]u8,
    net_reader: net.Stream.Reader,
    net_writer: net.Stream.Writer,
    client: std.crypto.tls.Client,

    fn connect(self: *ClientConn, io: std.Io, remote: net.IpAddress) !void {
        self.stream = try remote.connect(io, .{ .mode = .stream });
        errdefer self.stream.close(io);

        self.net_reader = self.stream.reader(io, &self.transport_read_buf);
        self.net_writer = self.stream.writer(io, &self.transport_write_buf);

        var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined;
        io.random(&entropy);

        self.client = try std.crypto.tls.Client.init(
            &self.net_reader.interface,
            &self.net_writer.interface,
            .{
                .host = .no_verification,
                .ca = .no_verification,
                .read_buffer = &self.plaintext_read_buf,
                .write_buffer = &self.plaintext_write_buf,
                .entropy = &entropy,
                .realtime_now = std.Io.Timestamp.now(io, .real),
            },
        );
        try self.net_writer.interface.flush();
    }

    fn send(self: *ClientConn, bytes: []const u8) !void {
        try self.client.writer.writeAll(bytes);
        try self.client.writer.flush();
        try self.net_writer.interface.flush();
    }

    fn end(self: *ClientConn) !void {
        try self.client.end();
        try self.net_writer.interface.flush();
    }

    fn close(self: *ClientConn, io: std.Io) void {
        self.stream.close(io);
    }
};

const ClientResponse = struct {
    status: u16,
    content_length: usize,
    content_type_buf: [64]u8,
    content_type_len: usize,
    allow_buf: [32]u8,
    allow_len: usize,
    body_buf: [512]u8,

    fn contentType(self: *const ClientResponse) []const u8 {
        return self.content_type_buf[0..self.content_type_len];
    }

    fn allow(self: *const ClientResponse) []const u8 {
        return self.allow_buf[0..self.allow_len];
    }

    fn body(self: *const ClientResponse) []const u8 {
        return self.body_buf[0..self.content_length];
    }
};

/// Reads one HTTP/1.1 response. Header values are copied out because each
/// `takeDelimiter` may invalidate the previous line.
fn readResponse(reader: *std.Io.Reader) !ClientResponse {
    var resp: ClientResponse = .{
        .status = 0,
        .content_length = 0,
        .content_type_buf = undefined,
        .content_type_len = 0,
        .allow_buf = undefined,
        .allow_len = 0,
        .body_buf = undefined,
    };

    const status_line = (try reader.takeDelimiter('\n')) orelse return error.TestBadStatusLine;
    if (status_line.len < 12 or !std.mem.startsWith(u8, status_line, "HTTP/1.1 ")) {
        return error.TestBadStatusLine;
    }
    resp.status = try std.fmt.parseInt(u16, status_line[9..12], 10);

    while (true) {
        const raw = (try reader.takeDelimiter('\n')) orelse return error.TestTruncatedHead;
        const line = std.mem.trimEnd(u8, raw, "\r");
        if (line.len == 0) break;
        const colon = std.mem.findScalar(u8, line, ':') orelse continue;
        const name = line[0..colon];
        const value = std.mem.trim(u8, line[colon + 1 ..], " \t");
        if (std.ascii.eqlIgnoreCase(name, "content-length")) {
            resp.content_length = try std.fmt.parseInt(usize, value, 10);
        } else if (std.ascii.eqlIgnoreCase(name, "content-type")) {
            if (value.len > resp.content_type_buf.len) return error.TestHeaderTooLong;
            @memcpy(resp.content_type_buf[0..value.len], value);
            resp.content_type_len = value.len;
        } else if (std.ascii.eqlIgnoreCase(name, "allow")) {
            if (value.len > resp.allow_buf.len) return error.TestHeaderTooLong;
            @memcpy(resp.allow_buf[0..value.len], value);
            resp.allow_len = value.len;
        }
    }

    if (resp.content_length > resp.body_buf.len) return error.TestBodyTooLong;
    try reader.readSliceAll(resp.body_buf[0..resp.content_length]);
    return resp;
}

fn sendPost(conn: *ClientConn, content_type: []const u8, request_body: []const u8) !void {
    var head_buf: [256]u8 = undefined;
    const head = try std.fmt.bufPrint(
        &head_buf,
        "POST {s} HTTP/1.1\r\nhost: doh.test\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n",
        .{ dns_query_path, content_type, request_body.len },
    );
    try conn.client.writer.writeAll(head);
    try conn.send(request_body);
}

/// The local answer is deterministic, so the reply must carry the query's id,
/// exactly one answer, and the fixture address as the final rdata bytes.
fn expectLocalReply(reply: []const u8) !void {
    const p = try packet.parse(reply);
    try testing.expectEqual(@as(u16, 0xbeef), p.header.id);
    try testing.expectEqual(true, p.header.flags.qr);
    try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);
    try testing.expect(std.mem.endsWith(u8, reply, &.{ 192, 168, 1, 5 }));
}

fn postRoundTrip(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    try sendPost(&conn, doh_client.media_type, query_bytes);
    const resp = try readResponse(&conn.client.reader);

    try testing.expectEqual(@as(u16, 200), resp.status);
    try testing.expect(doh_client.contentTypeOk(resp.contentType()));
    try expectLocalReply(resp.body());

    try conn.end();
}

test "a POST round trip answers from local records" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), postRoundTrip, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 1), stats.connections);
    try testing.expectEqual(@as(u64, 0), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
}

fn getMatchesPost(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    try sendPost(&conn, doh_client.media_type, query_bytes);
    const post_resp = try readResponse(&conn.client.reader);
    try testing.expectEqual(@as(u16, 200), post_resp.status);

    var encoded: [128]u8 = undefined;
    const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes);
    var head_buf: [256]u8 = undefined;
    const head = try std.fmt.bufPrint(
        &head_buf,
        "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n",
        .{ dns_query_path, value },
    );
    try conn.send(head);
    const get_resp = try readResponse(&conn.client.reader);

    try testing.expectEqual(@as(u16, 200), get_resp.status);
    try testing.expect(doh_client.contentTypeOk(get_resp.contentType()));
    try expectLocalReply(get_resp.body());
    try testing.expectEqualSlices(u8, post_resp.body(), get_resp.body());

    try conn.end();
}

test "a GET round trip returns the same reply bytes as the POST" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), getMatchesPost, .{ hx.io(), hx.addr() });
    try testing.expectEqual(@as(u64, 1), hx.server.snapshotStats().connections);
}

fn errorMatrix(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    // 415: the right path, the wrong media type. Zero-length on purpose —
    // only a refusal with no unread body may keep the connection; a 415 with
    // body bytes in flight closes instead (the drain-free test below).
    try sendPost(&conn, "text/plain", "");
    try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status);

    // 405 with the Allow header naming what would have worked.
    try conn.send("PUT /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 0\r\n\r\n");
    const put_resp = try readResponse(&conn.client.reader);
    try testing.expectEqual(@as(u16, 405), put_resp.status);
    try testing.expectEqualStrings("GET, POST", put_resp.allow());

    // 404: only /dns-query exists.
    try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\n\r\n");
    try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status);

    // 400: padded base64 is invalid per RFC 8484 §6.
    try conn.send("GET /dns-query?dns=YWJj= HTTP/1.1\r\nhost: doh.test\r\n\r\n");
    try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status);

    try conn.end();
}

test "the error matrix: 415, 405, 404 and 400 on one keep-alive connection" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), errorMatrix, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 1), stats.connections);
    try testing.expectEqual(@as(u64, 4), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

/// A closed connection ends with close_notify (a short read) when the server
/// beat the client to the socket, or a reset when the close already landed;
/// both are the same fact for these tests: no further response can arrive.
fn expectEof(reader: *std.Io.Reader) !void {
    var byte: [1]u8 = undefined;
    const got = reader.readSliceShort(&byte) catch 0;
    try testing.expectEqual(@as(usize, 0), got);
}

fn bodiedGetIsRejectedAndClosed(io: std.Io, remote: net.IpAddress) anyerror!void {
    var encoded: [128]u8 = undefined;
    const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes);

    // content-length framing on a GET: the four body bytes would otherwise be
    // parsed as the next request head.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        var head_buf: [256]u8 = undefined;
        const head = try std.fmt.bufPrint(
            &head_buf,
            "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP",
            .{ dns_query_path, value },
        );
        try conn.send(head);
        try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }

    // chunked framing on a GET is the same refusal.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        var head_buf: [256]u8 = undefined;
        const head = try std.fmt.bufPrint(
            &head_buf,
            "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n",
            .{ dns_query_path, value },
        );
        try conn.send(head);
        try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }
}

test "a GET that frames a body is refused and the connection closes" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), bodiedGetIsRejectedAndClosed, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 2), stats.connections);
    try testing.expectEqual(@as(u64, 2), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

fn framedRequestsKeepTheirRoutedStatus(io: std.Io, remote: net.IpAddress) anyerror!void {
    // A framed DELETE of the endpoint is still a method miss: 405 with Allow,
    // and the connection closes so the framed bytes are never read.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        try conn.send("DELETE /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP");
        const resp = try readResponse(&conn.client.reader);
        try testing.expectEqual(@as(u16, 405), resp.status);
        try testing.expectEqualStrings("GET, POST", resp.allow());
        try expectEof(&conn.client.reader);
    }

    // A framed GET beside the endpoint is still a path miss: 404, closed.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n");
        try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }
}

test "a framed request keeps its routed status and the connection closes" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), framedRequestsKeepTheirRoutedStatus, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 2), stats.connections);
    try testing.expectEqual(@as(u64, 2), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

fn refusalsDoNotDrainUnfinishedBodies(io: std.Io, remote: net.IpAddress) anyerror!void {
    // A chunked POST beside the endpoint, first chunk sent, terminal chunk
    // never sent. A server that drained the body before refusing would wait
    // here forever (the `bounded` budget fails the test); the fix answers 404
    // immediately and hangs up without reading a body byte.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        try conn.send("POST /wrong-path HTTP/1.1\r\nhost: doh.test\r\n" ++
            "content-type: application/dns-message\r\n" ++
            "transfer-encoding: chunked\r\n\r\n4\r\nHTTP\r\n");
        try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }

    // A wrong-media-type POST declaring 4096 body bytes it never sends: the
    // 415 must arrive without the server waiting for the missing bytes.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        try conn.send("POST /dns-query HTTP/1.1\r\nhost: doh.test\r\n" ++
            "content-type: text/plain\r\ncontent-length: 4096\r\n\r\n");
        try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }
}

test "a refusal with an unread body answers at once and closes, never draining" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), refusalsDoNotDrainUnfinishedBodies, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 2), stats.connections);
    try testing.expectEqual(@as(u64, 2), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

fn postWithConnectionClose(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    var head_buf: [256]u8 = undefined;
    const head = try std.fmt.bufPrint(
        &head_buf,
        "POST {s} HTTP/1.1\r\nhost: doh.test\r\nconnection: close\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n",
        .{ dns_query_path, doh_client.media_type, query_bytes.len },
    );
    try conn.client.writer.writeAll(head);
    try conn.send(query_bytes);

    const resp = try readResponse(&conn.client.reader);
    try testing.expectEqual(@as(u16, 200), resp.status);
    try expectLocalReply(resp.body());
    try expectEof(&conn.client.reader);
}

test "a successful POST with connection: close is answered, then the server hangs up" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), postWithConnectionClose, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 1), stats.connections);
    try testing.expectEqual(@as(u64, 0), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

fn dropClosesConnection(io: std.Io, remote: net.IpAddress) anyerror!void {
    // POST: a well-formed request whose body is not a DNS message drops in
    // the handler; ruling 2 says 400 and the connection ends.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        try sendPost(&conn, doh_client.media_type, "xx");
        try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }

    // GET: the same garbage, validly base64url-encoded, drops the same way.
    {
        var conn: ClientConn = undefined;
        try conn.connect(io, remote);
        defer conn.close(io);

        var encoded: [8]u8 = undefined;
        const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, "xx");
        var head_buf: [128]u8 = undefined;
        const head = try std.fmt.bufPrint(
            &head_buf,
            "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n",
            .{ dns_query_path, value },
        );
        try conn.send(head);
        try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status);
        try expectEof(&conn.client.reader);
    }
}

test "a handler drop answers 400 and the connection closes, POST and GET" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), dropClosesConnection, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 2), stats.connections);
    try testing.expectEqual(@as(u64, 2), stats.bad_requests);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}

fn twoPostsOneConnection(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    for (0..2) |_| {
        try sendPost(&conn, doh_client.media_type, query_bytes);
        const resp = try readResponse(&conn.client.reader);
        try testing.expectEqual(@as(u16, 200), resp.status);
        try expectLocalReply(resp.body());
    }

    try conn.end();
}

/// Long enough for a loopback round trip, short enough that the `bounded`
/// budget still fails a server that never reclaims the connection.
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake };

/// One request, then silence on a connection the client keeps open. The server
/// owes no answer, so the only thing that can end the read is the idle budget.
fn idleAfterOneRequest(io: std.Io, remote: net.IpAddress) anyerror!void {
    var conn: ClientConn = undefined;
    try conn.connect(io, remote);
    defer conn.close(io);

    try sendPost(&conn, doh_client.media_type, query_bytes);
    const resp = try readResponse(&conn.client.reader);
    try testing.expectEqual(@as(u16, 200), resp.status);
    try expectLocalReply(resp.body());

    try expectEof(&conn.client.reader);
}

test "doh: an idle keep-alive connection is reclaimed and counted" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.startWith(.{ .max_connections = 4, .idle_timeout = short_idle });
    defer hx.stop();

    try bounded(hx.io(), idleAfterOneRequest, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 1), stats.connections);
    try testing.expectEqual(@as(u64, 1), stats.idle_timeouts);
    try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
    try testing.expectEqual(@as(u64, 0), stats.connection_errors);
    try testing.expectEqual(@as(u64, 0), stats.bad_requests);
}

test "keep-alive: two requests are answered on one connection" {
    if (!build_options.integration) return error.SkipZigTest;

    var hx: Harness = undefined;
    try hx.start();
    defer hx.stop();

    try bounded(hx.io(), twoPostsOneConnection, .{ hx.io(), hx.addr() });

    const stats = hx.server.snapshotStats();
    try testing.expectEqual(@as(u64, 1), stats.connections);
    try testing.expectEqual(@as(u64, 0), stats.bad_requests);
    try testing.expectEqual(@as(u64, 2), hx.h.stats.local_answers.load(.monotonic));
}
