Files
nxdns/specs/milestone-3.md

1024 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milestone 3: Resolver Transport
Goal (PLAN §16 Phase 3): UDP server, TCP server, DoH + DoT upstream clients, upstream pool with
priority failover, backoff and health. Exit: A/AAAA forwarding over UDP and TCP; health populated.
Read first: `AGENTS.md` (values), `specs/research/zig-0.16-api-notes.md` (verified stdlib facts —
pre-0.16 knowledge is stale and MUST NOT be used), `specs/milestone-1.md` and `specs/milestone-2.md`
(module conventions and the "As built" notes). The Zig source of truth is `/home/mokhtar/app/zig` at
tag `0.16.0`. RFCs: 1035 §4.2.2 (TCP length prefix), 7766 (DNS over TCP), 7858 (DoT), 8484 (DoH),
6891 (EDNS(0)), 4343 (name case-insensitivity), 9619 (QDCOUNT must be 1).
## What already exists (do not respecify, import it)
- `src/dns/*` — pure wire format. Used here: `packet.parse`, `packet.Packet`, `packet.firstQuestion`,
`packet.findOptRecord`, `packet.ResponseBuilder`, `packet.setId`, `header.parse`, `header.Flags`,
`question.Question`, `name.eqlIgnoreCase`, `edns.parseOpt`, `types.Rcode`, `types.Type`.
- `src/platform/address.zig``NetAddress`, `Prefix`, `matchLongest`.
- `src/platform/tls_client.zig``TlsStream` (pinned struct; `init`, `reader`, `writer`, `close`),
`classify`, `ErrorClass`. DoT builds on this; do not open `std.crypto.tls.Client` directly.
- `src/platform/tls_server.zig` — server-side TLS. **Not used in this milestone** (local DoH/DoT
endpoints are Phase 9).
- `build.zig``-Dintegration` (hermetic, loopback only, PR-blocking) and `-Dlive` (leaves the
machine, manual workflow only) reach test files through `@import("build_options")`.
## Sessions
```
S1 (upstream/transport.zig + upstream/health.zig)
|
+--> S2 (doh_client) S3 (dot_client) S4 (pool) S5 (handler) [parallel]
|
+--> S6 (udp_server + tcp_server)
|
+--> S7 (e2e integration test)
```
S1 defines every type the other sessions share, so S2S5 can be written against the spec alone.
S6 depends on S5's `Handler` type; S7 depends on everything.
The orchestrator — not any session — wires `src/tests.zig` imports and any `build.zig` change. A
session that needs a build change reports the exact change in its completion report.
## Design invariants (all sessions)
- The pure core stays pure. `src/dns/` gains nothing. All `std.Io` use lives in `src/server/` and
`src/upstream/`. `upstream/health.zig` and the validation half of `upstream/transport.zig` are
themselves pure (timestamps and randomness arrive as parameters), so they are unit-testable
without a backend.
- **Failures are classified into three disjoint groups**: peer fault, local resource, cancellation.
Health and backoff count peer faults ONLY. A local `OutOfMemory` must never mark an upstream sick,
and `error.Canceled` must never be recorded at all.
- **QDCOUNT must be exactly 1** (RFC 9619) — for inbound client queries (else FORMERR) and for
upstream responses (else the response is a peer fault).
- Every response from an upstream is validated against the request it answers before it reaches a
client: ID equal, QDCOUNT 1 on both sides, QR set, question name equal **case-insensitively**
(RFC 4343), qtype and qclass equal.
- TCP and DoT messages carry a 2-byte big-endian length prefix (RFC 1035 §4.2.2). DoH does not.
- No stream read or write in 0.16.0 accepts a timeout (verified: `Io.Operation` has no net-stream
variants). Bound them by running the work under `io.concurrent` / `std.Io.Select` and cancelling
the loser, exactly as `src/platform/tls_client_integration_test.zig` does. UDP `receive` is the one
exception — `Socket.receiveTimeout` is implemented on the POSIX Threaded backend (verified:
`Io/Threaded.zig:2779` handles `net_receive` in the pollable batch path).
- Never set `net.IpAddress.ConnectOptions.timeout` — the Threaded backend panics
(`Io/Threaded.zig:12077`).
- Every dropped datagram, refused connection and failed send increments a named counter. No silent
drops (AGENTS.md).
- Unit tests live in-file. Tests that touch loopback sockets live in a separate `*_integration_test.zig`
file guarded by `if (!build_options.integration) return error.SkipZigTest;`. Tests that leave the
machine are guarded by `build_options.live`. This mirrors milestone 1 — the guard is a runtime
return so the body is always compiled and cannot rot.
---
## Session S1: `src/upstream/transport.zig`, `src/upstream/health.zig`
Foundation. No sockets are opened in this session; `transport.zig` names the `Io`-taking interface
that S2S4 implement, and everything else in both files is pure.
### S1.1 transport.zig — endpoint parsing
```zig
pub const max_message_len = 65535; // RFC 1035 §4.2.2 length prefix is 16-bit
pub const doh_default_port = 443;
pub const dot_default_port = 853; // RFC 7858 §3.1
pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template
pub const Scheme = enum { doh, dot };
/// Borrowed view over the configured URL text; the caller owns the string.
pub const Endpoint = struct {
scheme: Scheme,
url: []const u8, // the original text, for logs and the health API
host: []const u8, // no brackets, no port; SNI and certificate verification name
port: u16,
path: []const u8, // DoH only; always starts with '/'; `doh_default_path` when absent
pub const ParseError = error{ UnsupportedScheme, MissingHost, BadPort, BadUrl };
/// `https://…` => .doh, `tls://…` => .dot (PLAN §9). Accepts `[v6]:port` bracket form.
pub fn parse(url: []const u8) ParseError!Endpoint;
};
```
Hand-written parse (a `std.Uri` round trip would hand back percent-encoded components that need
re-decoding for one household-scale config value). Rules: reject any other scheme, reject an empty
host, reject a port that is empty, non-numeric or > 65535, reject a `tls://` URL that carries a path
other than `/` or nothing.
Tests: `https://cloudflare-dns.com/dns-query`, `https://dns.example/x` (path preserved),
`https://dns.example` (path defaults), `tls://dns.google:853`, `tls://dns.google` (port defaults to
853), `https://[2606:4700:4700::1111]:8443/dns-query` (host without brackets, port 8443),
`udp://1.1.1.1:53``UnsupportedScheme`, `https://``MissingHost`, `https://h:99999/``BadPort`.
### S1.2 transport.zig — error groups
```zig
/// The upstream misbehaved, timed out, or was unreachable. Only these count against health.
pub const PeerFault = error{
ConnectFailed,
TlsFailed,
SendFailed,
ReceiveFailed,
Timeout,
BadResponse, // unparseable, not a response, or QDCOUNT != 1
ResponseMismatch, // ID or question does not match the query
ResponseTooLarge, // does not fit the caller's buffer
HttpStatus, // DoH: status other than 200
HttpContentType, // DoH: content-type other than application/dns-message
};
/// This process ran out of something. Never the upstream's fault.
pub const LocalResource = error{
OutOfMemory,
SystemResources,
ProcessFdQuotaExceeded,
SystemFdQuotaExceeded,
BufferTooSmall, // caller-supplied buffer cannot hold even a query
Unexpected,
};
pub const Cancellation = error{Canceled};
pub const ExchangeError = PeerFault || LocalResource || Cancellation;
pub const Group = enum { peer_fault, local_resource, cancellation };
/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member must break
/// the build here, so no failure can silently land in the wrong group.
pub fn group(err: ExchangeError) Group;
```
The three sets are disjoint by construction; a test asserts it by walking
`@typeInfo(PeerFault).error_set.?` and friends and checking no name appears twice.
`pub fn mapLocal(err: anyerror) ?ExchangeError` — helper for S2/S3: returns the `LocalResource` or
`Cancellation` member matching a foreign stdlib error by name, `null` when the caller should treat
the error as a peer fault. Implemented with an explicit switch over the named errors listed above
plus `error.Canceled`; this is the ONLY place a foreign error set is folded in.
### S1.3 transport.zig — the client interface
```zig
/// A thing that sends one DNS message and returns one validated DNS message.
/// Implemented by DohClient, DotClient, Pool, and test fakes.
pub const Client = struct {
ptr: *anyopaque,
exchangeFn: *const fn (
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
) ExchangeError![]u8,
/// Returns a prefix of `response_buf`. The returned message has already passed
/// `validateResponse` against `query`.
pub fn exchange(
self: Client,
io: std.Io,
query: []const u8,
response_buf: []u8,
) ExchangeError![]u8 {
return self.exchangeFn(self.ptr, io, query, response_buf);
}
};
```
### S1.4 transport.zig — response validation (pure)
```zig
pub const ValidateError = error{ BadResponse, ResponseMismatch };
/// RFC 9619: exactly one question on both sides. RFC 4343: names compare case-insensitively,
/// so a case-mangling (0x20) upstream still matches. Does not inspect the answer section —
/// content policy is not this layer's business.
pub fn validateResponse(query: []const u8, response: []const u8) ValidateError!void;
```
Order of checks: response parses (`packet.parse` failure → `BadResponse`); response header
`flags.qr` is true (else `BadResponse`); response `qdcount == 1` (else `BadResponse`); query parses
and has `qdcount == 1` (else `BadResponse` — a caller bug, but never a crash); IDs equal (else
`ResponseMismatch`); question `qtype` and `qclass` equal and `name.eqlIgnoreCase` (else
`ResponseMismatch`).
Tests (hand-built byte fixtures, reuse the shape of `src/dns/packet.zig`'s fixtures): matching pair
passes; mixed-case question name passes; wrong ID → `ResponseMismatch`; different qtype →
`ResponseMismatch`; different name → `ResponseMismatch`; QR clear → `BadResponse`; response with
QDCOUNT 0 → `BadResponse`; response with QDCOUNT 2 → `BadResponse`; truncated garbage →
`BadResponse`.
### S1.5 health.zig — pure health and backoff state
```zig
pub const Config = struct {
/// Consecutive peer faults before the endpoint is put in backoff.
failure_threshold: u8 = 2,
base_backoff_ms: u32 = 500,
max_backoff_ms: u32 = 60_000,
};
pub const window_len = 32; // rolling success-rate window
pub const State = struct {
consecutive_failures: u32,
total_successes: u64,
total_failures: u64,
last_success_at: ?std.Io.Timestamp,
last_error_at: ?std.Io.Timestamp,
last_error_buf: [48]u8,
last_error_len: u8, // @errorName of the last peer fault, truncated
backoff_until: ?std.Io.Timestamp,
window: u32, // bitset, 1 = success, LSB = most recent
window_filled: u8,
pub const init: State = ...;
pub fn recordSuccess(self: *State, at: std.Io.Timestamp) void;
/// `err_name` is `@errorName` of a PeerFault member. `rand` supplies jitter; the caller
/// owns the RNG so this stays pure and the test is deterministic.
pub fn recordFailure(
self: *State,
at: std.Io.Timestamp,
err_name: []const u8,
cfg: Config,
rand: u32,
) void;
pub fn available(self: *const State, now: std.Io.Timestamp) bool;
pub fn successRate(self: *const State) f32; // over the filled part of the window; 1.0 when empty
pub fn lastError(self: *const State) []const u8;
};
```
Rules:
1. `recordSuccess` clears `consecutive_failures` and `backoff_until`, pushes a 1 into the window,
and sets `last_success_at` to `@max(existing, at)` by nanoseconds.
2. `recordFailure` increments `consecutive_failures` and `total_failures`, pushes a 0, copies
`err_name` (truncated to the buffer), sets `last_error_at` to `@max(existing, at)`. When
`consecutive_failures >= cfg.failure_threshold`, it computes
`delay_ms = min(max_backoff_ms, base_backoff_ms << shift)` where
`shift = min(consecutive_failures - failure_threshold, 20)`, applies jitter
`jittered = delay_ms/2 + rand % (delay_ms/2 + 1)`, and sets
`backoff_until = @max(existing, at + jittered)`.
3. **Out-of-order completions are the normal case**, not an edge case: two concurrent exchanges
against the same endpoint complete in either order, so `at` can move backwards between calls.
Every timestamp field therefore updates through `@max` on `.nanoseconds`, and `backoff_until` is
only ever extended, never shortened. `available` must never compute a negative duration.
4. `State` carries no lock. The pool owns the mutex.
Tests: threshold not reached → `available(now)` stays true; threshold reached → unavailable until
`backoff_until`, available one nanosecond after; consecutive failures grow the delay and it saturates
at `max_backoff_ms` (walk 40 failures with `rand = 0`); success resets consecutive count, window and
backoff; jitter with `rand = 0` and `rand = maxInt(u32)` both land inside `[delay/2, delay]`; an
out-of-order pair — `recordSuccess(t=100)` then `recordSuccess(t=50)` — leaves `last_success_at` at
100; `recordFailure(t=50)` after `recordFailure(t=100)` does not shorten `backoff_until`;
`successRate` over a half-success window is 0.5; `lastError` returns the last recorded name and is
truncated, not overflowed, by a name longer than the buffer.
### S1.6 Acceptance criteria
- [ ] `zig test src/upstream/transport.zig` and `zig test src/upstream/health.zig` pass.
- [ ] `group` compiles with no `else` arm over `ExchangeError`.
- [ ] The disjointness test over the three error sets passes.
- [ ] Every listed validation and health case has a test.
- [ ] `zig fmt --check` clean on both files.
---
## Session S2: `src/upstream/doh_client.zig` (+ live test)
DoH per RFC 8484 over `std.http.Client` (HTTP/1.1; HTTP/2 is permanently out of scope, PLAN §2.2).
### S2.1 API
```zig
pub const DohClient = struct {
http: *std.http.Client, // caller-owned; shared across endpoints, pools connections
endpoint: transport.Endpoint,
uri: std.Uri, // built once in init from `endpoint`
request_buf: []u8, // caller-owned; sendBodyComplete needs a mutable body
transfer_buf: []u8, // caller-owned; HTTP body transfer buffer
pub const InitError = error{BadUrl};
pub fn init(
http: *std.http.Client,
endpoint: transport.Endpoint,
request_buf: []u8, // asserted >= 512
transfer_buf: []u8, // asserted >= 1024
) InitError!DohClient;
pub fn client(self: *DohClient) transport.Client;
};
```
`self.uri` is derived from `endpoint` with `std.Uri.parse(endpoint.url)`; a parse failure is
`error.BadUrl`. `endpoint` remains the source of truth for host/port/scheme in logs.
### S2.2 Exchange
1. `if (query.len > self.request_buf.len) return error.BufferTooSmall;` then copy `query` into
`request_buf``Request.sendBodyComplete` takes `[]u8`, not `[]const u8` (verified,
`std/http/Client.zig:935`).
2. ```zig
var req = try self.http.request(.POST, self.uri, .{
.keep_alive = true,
.redirect_behavior = .not_allowed,
.headers = .{
.content_type = .{ .override = "application/dns-message" },
// Compressed bodies would need the decompressing reader; identity keeps the
// response body byte-exact for `validateResponse`.
.accept_encoding = .{ .override = "identity" },
},
.extra_headers = &.{.{ .name = "accept", .value = "application/dns-message" }},
});
defer req.deinit();
```
`Request.Headers` has no `accept` field (verified, `std/http/Client.zig:845`), hence
`extra_headers`.
3. `try req.sendBodyComplete(self.request_buf[0..query.len]);`
4. `var resp = try req.receiveHead(&.{});` — an empty redirect buffer is legal with
`.not_allowed`.
5. `if (resp.head.status != .ok) return error.HttpStatus;`
6. Content-type: take `resp.head.content_type orelse return error.HttpContentType`, cut at the
first `;`, trim ASCII whitespace, `std.ascii.eqlIgnoreCase` against `"application/dns-message"`,
else `error.HttpContentType`.
7. `const body = resp.reader(self.transfer_buf);` then fill `response_buf` with repeated
`readSliceShort` until it returns 0. If the body has not ended when `response_buf` is full →
`error.ResponseTooLarge`. A `content_length` larger than `response_buf.len` short-circuits to the
same error.
8. `try transport.validateResponse(query, response_buf[0..len]);` mapping `ValidateError` members
straight through (both are `PeerFault` members).
9. Return `response_buf[0..len]`.
The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 for HTTP cache friendliness; nxdns puts
no HTTP cache in this path, and keeping the ID preserves the request/response binding that
`validateResponse` enforces. State that reason in a comment.
Error mapping: one `fn mapError(err: anyerror) transport.ExchangeError` that first tries
`transport.mapLocal(err)`, and otherwise maps by phase — `request`/connect errors →
`error.ConnectFailed`, TLS-named errors (`@errorName` starting with `"Tls"` or `"Certificate"`) →
`error.TlsFailed`, `sendBodyComplete` errors → `error.SendFailed`, `receiveHead` and body-read
errors → `error.ReceiveFailed`. Do the phase distinction at the call site (each `catch` knows its
phase), not by guessing from the error name.
### S2.3 Tests
- In-file unit: `init` with a `tls://` endpoint is rejected before any I/O (assert or `BadUrl`);
`init` asserts on an undersized `request_buf`; the content-type matcher accepts
`"application/dns-message"`, `"Application/DNS-Message"` and
`"application/dns-message; charset=utf-8"`, and rejects `"text/html"` and a missing header.
Factor the matcher into `fn contentTypeOk(value: ?[]const u8) bool` so it is testable without a
server.
- `src/upstream/doh_client_live_test.zig`, guarded by `build_options.live`: POST an A query for
`example.com` to `https://cloudflare-dns.com/dns-query`, assert the reply validates and carries at
least one answer record. Whole exchange raced against a 10 s budget with `std.Io.Select`, the
pattern in `src/platform/tls_client_integration_test.zig`. This host's IPv6 egress is broken (API
notes) and DoH needs name resolution — if the test stalls on AAAA, report it as an environment
finding rather than working around it; the test is non-blocking by construction.
### S2.4 Acceptance criteria
- [ ] `zig test src/upstream/doh_client.zig` passes (unit tests, no network).
- [ ] `DohClient` satisfies `transport.Client` (a compile-time test takes `client()` and calls
`exchange` through the interface against a `std.http.Client` that is never driven — verify by
instantiation, not by running an exchange).
- [ ] The live test compiles in every build and skips without `-Dlive`.
- [ ] `zig fmt --check` clean.
---
## Session S3: `src/upstream/dot_client.zig` (+ live test)
DoT per RFC 7858: TLS on port 853, DNS messages framed exactly as over TCP (RFC 1035 §4.2.2).
### S3.1 API
```zig
pub const DotClient = struct {
endpoint: transport.Endpoint,
gpa: std.mem.Allocator,
bundle: *std.crypto.Certificate.Bundle, // caller-owned, shared
bundle_lock: *std.Io.RwLock, // caller-owned, shared
buffers: Buffers, // caller-owned; one DotClient is used by one task
pub const Buffers = struct {
tls_read: []u8, // >= std.crypto.tls.Client.min_buffer_len
tls_write: []u8, // >= std.crypto.tls.Client.min_buffer_len
stream_read: []u8, // >= std.crypto.tls.Client.min_buffer_len
stream_write: []u8, // >= std.crypto.tls.Client.min_buffer_len
};
pub fn init(
endpoint: transport.Endpoint,
gpa: std.mem.Allocator,
bundle: *std.crypto.Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
buffers: Buffers,
) DotClient; // asserts endpoint.scheme == .dot and every buffer length
pub fn client(self: *DotClient) transport.Client;
};
```
### S3.2 Exchange
One TCP connection and one TLS handshake per exchange, closed before returning. Connection reuse is
not built: at household query rates the pool's per-attempt budget and the failover path stay simple
and every failure is attributable to one exchange, which is worth more here than the saved round
trips. Say that in a comment — it is a decision, not a stub.
1. Resolve the endpoint to an `std.Io.net.IpAddress` with `net.IpAddress.parse(endpoint.host,
endpoint.port)`. **A non-literal host is `error.ConnectFailed`** with a log line naming the host:
name resolution for upstreams is not in this milestone's scope, and a silent fallback would hide
it. (`tls://dns.google:853` from PLAN §12.1 therefore needs an IP literal in config; note this in
the completion report so the orchestrator can carry it into the Phase 4 config validator.)
2. `var stream = try address.connect(io, .{ .mode = .stream });` — no `timeout` field, ever.
`defer` a close that runs under cancel protection (below).
3. `var tls_stream: tls_client.TlsStream = undefined;` then `tls_stream.init(io, &stream, bundle,
bundle_lock, gpa, .{ .host = endpoint.host, .ca = .system, … })`. `TlsStream` is pinned — do not
copy or move it after init.
4. Write `[2]u8` big-endian length then `query` to `tls_stream.writer()`, then `flush()`.
5. Read exactly 2 bytes, then that many bytes, from `tls_stream.reader()`. `len == 0` →
`error.BadResponse`. `len > response_buf.len` → `error.ResponseTooLarge`. A short read or EOF →
`error.ReceiveFailed`.
6. `try transport.validateResponse(query, response_buf[0..len]);`
7. `tls_stream.close();` then `stream.close(io);`.
Cleanup under cancellation: the pool cancels this task on timeout, and the next cancelable `Io` call
in the `defer` chain would return `error.Canceled` and skip the socket close. Wrap the close path in
`const prev = io.swapCancelProtection(.blocked); defer _ = io.swapCancelProtection(prev);`
(verified: `std/Io.zig:1342`) so the socket is always released.
Error mapping: `connect` → `error.ConnectFailed`; `TlsStream.init` → `error.TlsFailed` (log
`tls_client.classify(err)` alongside — that is what milestone 1 built it for); write/flush →
`error.SendFailed`; read → `error.ReceiveFailed`; `transport.mapLocal` first at every site.
### S3.3 Tests
- In-file unit: `framePrefix(len: u16) [2]u8` and `parsePrefix(bytes: [2]u8) u16` round-trip,
including 0 and 65535, big-endian byte order asserted explicitly; `init` asserts on a `.doh`
endpoint; a non-literal host maps to `error.ConnectFailed` without touching the network (call the
address-resolution helper directly).
- `src/upstream/dot_client_live_test.zig`, guarded by `build_options.live`: exchange an A query for
`example.com` against `tls://1.1.1.1:853` with `endpoint.host` overridden to `"1.1.1.1"` — this
machine's IPv6 egress is dead, so the documented anycast literal is used, and Cloudflare's
certificate carries `1.1.1.1` as an IP SAN. If the stdlib verifier rejects an IP-literal SAN, do
NOT weaken verification: report the finding. Raced against a 10 s budget with `std.Io.Select`.
### S3.4 Acceptance criteria
- [ ] `zig test src/upstream/dot_client.zig` passes (unit tests, no network).
- [ ] `DotClient` satisfies `transport.Client`.
- [ ] The live test compiles in every build and skips without `-Dlive`.
- [ ] No direct `std.crypto.tls.Client` use — everything goes through `platform/tls_client.zig`.
- [ ] `zig fmt --check` clean.
---
## Session S4: `src/upstream/pool.zig`
Priority-ordered sequential failover with per-attempt deadline, health tracking and backoff
(PLAN §9). The pool is itself a `transport.Client`, so the handler sees one interface.
### S4.1 API
```zig
pub const Entry = struct {
endpoint: transport.Endpoint,
client: transport.Client,
priority: i32, // lower first (PLAN §11.2 `upstreams.priority`)
enabled: bool,
health: health.State,
};
pub const Snapshot = struct {
url: []const u8,
enabled: bool,
available: bool,
consecutive_failures: u32,
total_successes: u64,
total_failures: u64,
success_rate: f32,
last_success_at: ?std.Io.Timestamp,
last_error_at: ?std.Io.Timestamp,
last_error: []const u8, // borrowed from the entry; valid until the next failure
backoff_until: ?std.Io.Timestamp,
};
pub const Pool = struct {
entries: []Entry, // caller-owned, sorted ascending by priority in `init`
cfg: health.Config,
attempt_timeout: std.Io.Clock.Duration,
mutex: std.Io.Mutex,
rng: std.Random.DefaultPrng,
pub fn init(
entries: []Entry,
cfg: health.Config,
attempt_timeout: std.Io.Clock.Duration,
seed: u64,
) Pool; // asserts entries.len > 0
pub fn client(self: *Pool) transport.Client;
pub fn exchange(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8;
/// Copies health into `out` in pool order; returns the number written.
/// Feeds `GET /api/upstream/health` in Phase 8.
pub fn snapshot(self: *Pool, io: std.Io, out: []Snapshot) std.Io.Cancelable!usize;
};
```
`attempt_timeout` uses the `.awake` clock (`.{ .raw = .fromMilliseconds(2000), .clock = .awake }`) so
a suspended Pi does not burn the budget.
### S4.2 Failover algorithm
1. Take `now = std.Io.Clock.awake.now(io)`.
2. **Pass one**: walk `entries` in order; skip `!enabled`; skip entries whose
`health.available(now)` is false.
3. **Pass two**, only if pass one attempted nothing: walk again, skipping only `!enabled`. Every
endpoint being in backoff must not turn into `SERVFAIL` for every client — a probe is better than
a guaranteed failure, and it is how backoff recovers. Test this explicitly.
4. For each candidate, run `attempt`:
- Race `entry.client.exchange(io, query, response_buf)` against
`attempt_timeout.sleep(io)` using `std.Io.Select` with a two-arm union, then
`race.cancelDiscard()` on the way out (the milestone-1 pattern). The sleep arm winning yields
`error.Timeout`.
- On success: lock the mutex, `entry.health.recordSuccess(completed_at)`, unlock, return the
slice.
- On error, switch on `transport.group(err)`:
- `.peer_fault` → lock, `recordFailure(completed_at, @errorName(err), self.cfg, self.rng.random().int(u32))`,
unlock, remember it as `last_fault`, continue to the next candidate.
- `.local_resource` → return the error immediately. Do not record. Do not try another upstream:
the next one will hit the same wall.
- `.cancellation` → return `error.Canceled` immediately. Do not record.
- `completed_at` is `std.Io.Clock.awake.now(io)` taken after the attempt returns.
5. All candidates exhausted → return `last_fault` (guaranteed non-null: `entries.len > 0` and pass
two attempts every enabled entry). If every entry is disabled, return `error.ConnectFailed`.
`response_buf` is handed to each attempt in turn; a failed attempt may have written into it, so the
returned slice is only meaningful on success. Note this in a comment.
Concurrency: several handler tasks share one `Pool`. The mutex guards health mutation and
`snapshot` only — never an in-flight exchange, so a slow upstream cannot block bookkeeping. Entry
`client` values must therefore be safe for concurrent `exchange` calls; `DohClient` and `DotClient`
own per-instance buffers, so **one `Entry` per concurrent task, or one shared pool with per-entry
serialization, is a wiring decision for the orchestrator**. Record in the completion report which
one this session assumed (the specified `Entry` layout assumes each entry's client is used by one
task at a time; the e2e test in S7 exercises exactly one in-flight query at a time).
### S4.3 Tests (in-file, no sockets — fake clients implement `transport.Client`)
- Priority order: entry with priority 10 is tried before priority 100.
- Failover: first fake returns `error.Timeout`, second returns a valid response → the pool returns
the second one's bytes; first entry's `consecutive_failures == 1`, second's `total_successes == 1`.
- Backoff skip: drive a fake past `failure_threshold`, then assert the next `exchange` does not call
that fake (a call counter in the fake) while another entry is available.
- All-backed-off probe: every entry in backoff → pass two still calls them.
- `.local_resource` short-circuit: a fake returning `error.OutOfMemory` makes `exchange` return
`error.OutOfMemory`, the second fake is never called, and the first entry's `total_failures`
stays 0.
- `.cancellation` short-circuit: a fake returning `error.Canceled` returns `error.Canceled` and
records nothing.
- Attempt timeout: a fake that sleeps longer than `attempt_timeout` yields a recorded
`error.Timeout` peer fault and moves on. (Needs a live `Io` — use `std.Io.Threaded` inside the
test; no sockets are involved, so this stays in the default `zig build test`.)
- All entries disabled → `error.ConnectFailed`.
- `snapshot` reports the counters set by the preceding cases.
### S4.4 Acceptance criteria
- [ ] `zig test src/upstream/pool.zig` passes, including the timeout test on a `Threaded` backend.
- [ ] Every bullet in S4.3 exists as a named test.
- [ ] `Pool` satisfies `transport.Client`.
- [ ] `zig fmt --check` clean.
---
## Session S5: `src/server/handler.zig`
The serving handler: bytes in from a listener, validated bytes out. It owns no socket and no clock
beyond what it passes to the upstream. Phase 7 extends this file with filtering, cache, local
records and logging — nothing of that appears here.
### S5.1 API
```zig
pub const Transport = enum { udp, tcp };
pub const Handler = struct {
upstream: transport.Client, // in production `pool.client()`
stats: Stats = .{},
pub const Stats = struct {
queries: std.atomic.Value(u64) = .init(0),
dropped_malformed: std.atomic.Value(u64) = .init(0),
formerr: std.atomic.Value(u64) = .init(0),
notimp: std.atomic.Value(u64) = .init(0),
servfail: std.atomic.Value(u64) = .init(0),
truncated: std.atomic.Value(u64) = .init(0),
};
pub const Outcome = union(enum) {
reply: []u8, // a prefix of `response_buf`
drop, // no response is possible or appropriate
};
/// `response_buf.len` must be >= 512 and <= 65535 (ResponseBuilder asserts the upper bound).
pub fn handle(
self: *Handler,
io: std.Io,
which: Transport,
query: []const u8,
response_buf: []u8,
) Outcome;
};
```
`handle` never returns an error: every failure is either a DNS response or a counted drop. That is
the "every failure mode visible" rule applied to the hot path.
### S5.2 Behavior
1. `header.parse(query)` fails (< 12 bytes) → `dropped_malformed`, `.drop` (PLAN §6.1: severely
truncated is a silent drop).
2. `hdr.flags.qr == true` → a response arriving on a listener port; `dropped_malformed`, `.drop`.
3. `packet.parse(query)`:
- `error.Truncated` → `dropped_malformed`, `.drop`.
- any other `WalkError` → FORMERR reply built from `hdr` with **no** echoed question (the
question is what failed to parse); `formerr`.
4. `hdr.flags.opcode != .query` → NOTIMP reply echoing nothing; `notimp`.
5. `hdr.qdcount != 1` → FORMERR (RFC 9619 makes any other value invalid, in both directions);
`formerr`.
6. Forward: `self.upstream.exchange(io, query, response_buf)`.
- success → `queries` incremented; then the UDP size check in §5.3; `.reply`.
- `transport.group(err) == .cancellation` → `.drop` (the process is shutting down; nothing to
say).
- `.peer_fault` or `.local_resource` → SERVFAIL reply echoing the question; `servfail`.
7. Every synthesized reply preserves the request ID, opcode and RD bit, sets QR and RA, and echoes
the question when one parsed — all of that is already `packet.ResponseBuilder.init`'s contract.
When the query carried an OPT record (`packet.findOptRecord` + `edns.parseOpt`), the reply ends
with `addOptEcho(opt, opt.do_bit)` so the DO bit passes through (PLAN §6.1).
### S5.3 UDP size limit and truncation
The upstream answer can exceed what the client will accept over UDP.
- `pub fn udpLimit(query_packet: packet.Packet) u16` — the query's OPT `udp_payload_size` clamped to
`[512, 4096]`, or 512 when there is no OPT record (RFC 1035 §4.2.1 / RFC 6891 §6.2.3).
- `which == .udp` and the upstream reply is longer than `udpLimit` → build a reply with `TC = 1`,
RCODE NOERROR, the question echoed, no answer records, OPT echoed when present; increment
`truncated`. The client then retries over TCP, which is exactly what RFC 1035 §4.2.1 prescribes.
- `which == .tcp` → no size limit beyond `response_buf`.
The upstream reply lands in `response_buf`; building a truncated reply over it needs a second
buffer. Give `handle` no extra parameter: build the truncated reply in a stack `[512]u8` inside
`handle` and copy it into `response_buf` before returning. 512 is enough — the message is a header,
one question and at most one OPT record, and a question longer than ~270 bytes cannot exist
(name ≤ 255).
### S5.4 Tests (in-file, fake upstream implementing `transport.Client`)
Build queries with hand-encoded bytes or `packet.ResponseBuilder`; the fake upstream answers by
copying a fixture and calling `packet.setId`.
- A query for `example.com` A over UDP returns the fake's bytes unchanged.
- A response (QR set) sent to the handler → `.drop`, `dropped_malformed == 1`.
- An 8-byte query → `.drop`.
- A query with QDCOUNT 0 → FORMERR reply, question absent, ID preserved.
- A query with QDCOUNT 2 → FORMERR.
- A structurally broken question (name pointer loop) → FORMERR, `formerr == 1`.
- Opcode `update` → NOTIMP.
- Fake returns `error.Timeout` → SERVFAIL with the question echoed and RD preserved.
- Fake returns `error.OutOfMemory` → SERVFAIL (not a drop).
- Fake returns `error.Canceled` → `.drop`.
- `udpLimit`: no OPT → 512; OPT 1232 → 1232; OPT 200 → 512; OPT 9000 → 4096.
- Over UDP, a fake reply of 900 bytes with no OPT in the query → TC=1 reply, ancount 0, question
echoed, `truncated == 1`; the same reply over TCP passes through untouched.
- DO bit set in the query's OPT → set in the SERVFAIL reply's OPT.
- Every synthesized reply re-parses with `packet.parse`.
### S5.5 Acceptance criteria
- [ ] `zig test src/server/handler.zig` passes.
- [ ] Every bullet in S5.4 exists as a named test.
- [ ] `handle` has no error union in its return type.
- [ ] `zig fmt --check` clean.
---
## Session S6: `src/server/udp_server.zig`, `src/server/tcp_server.zig`
Listeners. Both take a `*handler.Handler`, both use a bounded pool of in-flight tasks so one slow
upstream cannot stall the listener, and both count every failure.
### S6.1 udp_server.zig
```zig
pub const max_datagram = 4096; // EDNS ceiling this server advertises
pub const Options = struct {
max_in_flight: u16 = 64,
};
pub const Stats = struct {
received: std.atomic.Value(u64) = .init(0),
dropped_oversize: std.atomic.Value(u64) = .init(0), // datagram arrived with flags.trunc
dropped_no_slot: std.atomic.Value(u64) = .init(0),
receive_errors: std.atomic.Value(u64) = .init(0),
send_errors: std.atomic.Value(u64) = .init(0),
};
pub const UdpServer = struct {
socket: std.Io.net.Socket,
handler: *handler.Handler,
slots: []Slot, // allocated in `bind`, freed in `deinit`
free: std.DynamicBitSetUnmanaged or a u64 free-list — implementer's call, guarded by `mutex`
mutex: std.Io.Mutex,
stats: Stats,
pub const Slot = struct {
query: [max_datagram]u8,
reply: [max_datagram]u8,
from: std.Io.net.IpAddress,
len: usize,
};
pub const BindError = std.Io.net.IpAddress.BindError || error{OutOfMemory};
pub fn bind(
gpa: std.mem.Allocator,
io: std.Io,
address: std.Io.net.IpAddress,
h: *handler.Handler,
options: Options,
) BindError!UdpServer;
/// The kernel-assigned address; port 0 in `bind` resolves here (needed by tests).
pub fn boundAddress(self: *const UdpServer) std.Io.net.IpAddress;
/// Receive loop. Returns when the task is canceled or the socket is closed.
pub fn serve(self: *UdpServer, io: std.Io) void;
pub fn deinit(self: *UdpServer, gpa: std.mem.Allocator, io: std.Io) void;
};
```
Loop:
1. `const msg = self.socket.receive(io, slot_query_buf)` into a claimed slot. `error.Canceled` →
return. Any other error → `receive_errors`, log, continue (a per-datagram error must not kill the
listener).
2. `msg.flags.trunc` → `dropped_oversize`, release the slot, continue. A truncated datagram cannot
be parsed reliably and answering it would be a guess.
3. No free slot → `dropped_no_slot`, continue. Do not queue: an unbounded queue is the cloudflared
failure mode (PLAN §1) in a different costume.
4. `group.concurrent(io, respondOne, .{ self, io, slot_index })` — an `std.Io.Group` owned by
`serve`. `respondOne` calls `self.handler.handle(io, .udp, query, reply)`, sends `.reply` with
`self.socket.send(io, &slot.from, bytes)` (a send failure increments `send_errors` and is logged
at debug, deduplicated by the caller later), then releases the slot.
5. `serve` awaits the group before returning, under cancel protection so in-flight replies are not
abandoned mid-send.
`deinit` closes the socket and frees the slots. Closing the socket makes a blocked `receive` fail;
the loop treats that as a stop signal.
### S6.2 tcp_server.zig
```zig
pub const Options = struct {
max_connections: u16 = 64,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance.
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};
pub const Stats = struct {
accepted: std.atomic.Value(u64) = .init(0),
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
accept_errors: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
idle_timeouts: std.atomic.Value(u64) = .init(0),
};
pub const TcpServer = struct {
server: std.Io.net.Server,
handler: *handler.Handler,
conns: []Conn, // fixed slots, same bounded-capacity rule as UDP
mutex: std.Io.Mutex,
stats: Stats,
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory};
pub fn listen(gpa, io, address, h: *handler.Handler, options: Options) ListenError!TcpServer;
pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress;
pub fn serve(self: *TcpServer, io: std.Io) void;
pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void;
};
```
Accept loop: `self.server.accept(io)`; `error.Canceled` or `error.SocketNotListening` → return
(`deinit` shuts the listening socket down, which is the documented way to unblock a pending accept —
API notes). At capacity → close the stream immediately, `rejected_at_capacity`. Otherwise a group
task per connection.
Per connection (RFC 7766 §6.2.1.1 — a connection may carry several queries, handled serially):
1. Read the 2-byte length prefix. The read is raced against `options.idle_timeout` with
`std.Io.Select`; the sleep arm winning means `idle_timeouts` and a clean close.
2. `len == 0` → close (`connection_errors`). `len > transport.max_message_len` is impossible (u16).
3. Read exactly `len` bytes; a short read → close, `connection_errors`.
4. `handler.handle(io, .tcp, query, reply)`; `.drop` closes the connection (there is nothing to
frame); `.reply` writes the 2-byte prefix plus the bytes and flushes.
5. Loop back to step 1.
6. Close under cancel protection and release the slot.
Buffers: each `Conn` owns `query: [transport.max_message_len]u8` and
`reply: [transport.max_message_len]u8` plus the stream read/write buffers. That is 128 KiB+ per
connection slot; with the default 64 slots that is ~8 MiB, inside the PLAN §18 budget. If a session
finds a materially smaller layout that keeps the 65535-byte ceiling, take it and say so.
### S6.3 Tests
Unit tests that need no socket go in-file (length-prefix framing helper, capacity accounting).
The loopback tests live in `src/server/udp_server_integration_test.zig` and
`src/server/tcp_server_integration_test.zig`, both guarded by
`if (!build_options.integration) return error.SkipZigTest;`:
- UDP: bind `127.0.0.1:0`, run `serve` in an `Io.Group` task, send a query from a second bound
socket with a fake-upstream handler, assert the reply's ID and question match; then send a 5-byte
datagram and assert no reply arrives within a short budget and `dropped_malformed` moved.
- TCP: listen on `127.0.0.1:0`, connect, send two length-prefixed queries on one connection, read
two length-prefixed replies, assert both match, then close; assert `accepted == 1`.
- TCP idle timeout: connect, send nothing, assert the server closes the connection and
`idle_timeouts == 1` (use a short `idle_timeout` in the test options).
- Both: `deinit` while `serve` is blocked ends `serve` without a hang — the group awaits cleanly.
### S6.4 Acceptance criteria
- [ ] `zig test src/server/udp_server.zig` and `.../tcp_server.zig` pass (unit tests).
- [ ] Both integration files compile in every build, skip without `-Dintegration`, and pass with it.
- [ ] No unbounded queue, no unbounded allocation per datagram or per connection.
- [ ] `zig fmt --check` clean.
---
## Session S7: end-to-end integration test
`src/server/resolver_integration_test.zig`, guarded by
`if (!build_options.integration) return error.SkipZigTest;`. Hermetic: loopback sockets and an
in-process fake upstream, no external network.
### S7.1 Fake upstream
In this file: a struct implementing `transport.Client` that parses the incoming query, echoes the
question and appends one A record (`93.184.216.34`, TTL 300) with `packet.ResponseBuilder`, and
counts calls. A second variant returns a configured `transport.PeerFault` on the first N calls.
### S7.2 The test
1. `std.Io.Threaded` backend, `io`.
2. Two `Pool` entries: entry 0 = always-fails fake (priority 10), entry 1 = good fake (priority 20).
3. `Handler` over `pool.client()`.
4. `UdpServer` bound to `127.0.0.1:0` and `TcpServer` on `127.0.0.1:0`, both served in one
`Io.Group`.
5. Client side: send an A query for `example.com` over UDP; assert the reply parses, ID matches,
RCODE is NOERROR, ancount is 1, and the A record's rdata is `93.184.216.34`.
6. Send the same query length-prefixed over TCP; assert the same.
7. Assert failover happened: entry 0's `consecutive_failures >= 1` and `backoff_until != null`;
entry 1's `total_successes >= 2`. Read them through `pool.snapshot`.
8. Send a third query and assert entry 0's call counter did not grow — it is in backoff.
9. `deinit` both servers, cancel and await the group, `threaded.deinit()` — the test must not hang.
### S7.3 Acceptance criteria
- [ ] `zig build test -Dintegration` runs this test and it passes.
- [ ] It skips (not fails) under plain `zig build test`.
- [ ] It opens no socket outside `127.0.0.1` and reaches no external host.
- [ ] It completes in bounded time — every wait carries a budget; no unbounded blocking read.
- [ ] `zig fmt --check` clean.
---
## Module Layout
```
src/upstream/transport.zig S1 Endpoint, error groups, Client, validateResponse
src/upstream/health.zig S1 pure health/backoff state
src/upstream/doh_client.zig S2 RFC 8484 over std.http.Client
src/upstream/doh_client_live_test.zig S2 -Dlive
src/upstream/dot_client.zig S3 RFC 7858 over platform/tls_client.zig
src/upstream/dot_client_live_test.zig S3 -Dlive
src/upstream/pool.zig S4 priority failover, per-attempt deadline, health
src/server/handler.zig S5 query -> upstream -> validated reply
src/server/udp_server.zig S6 UDP/53 listener
src/server/tcp_server.zig S6 TCP/53 listener, length-prefixed
src/server/udp_server_integration_test.zig S6 -Dintegration
src/server/tcp_server_integration_test.zig S6 -Dintegration
src/server/resolver_integration_test.zig S7 -Dintegration, end to end
```
## File Ownership
| Files | Owner | Notes |
|---|---|---|
| `src/upstream/transport.zig`, `src/upstream/health.zig` | S1 | frozen after S1 verifies |
| `src/upstream/doh_client.zig`, `src/upstream/doh_client_live_test.zig` | S2 | |
| `src/upstream/dot_client.zig`, `src/upstream/dot_client_live_test.zig` | S3 | |
| `src/upstream/pool.zig` | S4 | |
| `src/server/handler.zig` | S5 | |
| `src/server/udp_server.zig`, `src/server/tcp_server.zig`, both `*_server_integration_test.zig` | S6 | |
| `src/server/resolver_integration_test.zig` | S7 | |
| `build.zig`, `src/tests.zig` | orchestrator | no session edits these |
No session touches milestone 1 or milestone 2 files. A needed change there is reported, not made.
## Acceptance Criteria (Milestone 3 Complete)
- [ ] `zig build test` exits 0 with every new file wired into `src/tests.zig`.
- [ ] `zig build test -Dintegration` exits 0: the milestone-1 loopback TLS echo, both listener
integration tests, and the end-to-end resolver test all pass.
- [ ] `zig build test -Dintegration -Dlive` exits 0 locally, or the DoH/DoT live failures are
reported as environment findings with the exact error (a live-network failure is not a gate).
- [ ] `zig build cross` still produces two statically linked executables.
- [ ] `transport.group` is exhaustive over `ExchangeError` with no `else` arm.
- [ ] `pool.zig`'s failover, backoff-skip, all-backed-off probe, local-resource short-circuit and
cancellation short-circuit each have a passing named test.
- [ ] `handler.zig`'s FORMERR (QDCOUNT 0 and 2), NOTIMP, SERVFAIL, drop and UDP-truncation paths
each have a passing named test.
- [ ] `zig fmt --check` clean repo-wide; GPG-signed lowercase commits.
## Anti-Requirements
- No caching of any kind (Phase 6). The handler forwards every query.
- No filtering, blocklists, rules, or blocked-response synthesis (Phase 5).
- No query logging, no SQLite, no storage, no config file or ZON parsing (Phase 4). Timeouts,
ports and pool entries arrive as parameters or named constants.
- No rate limiting (Phase 6) — `src/server/rate_limiter.zig` is not created here.
- No local DoH or DoT **server** endpoints (Phase 9). Only upstream DoH/DoT clients are in scope;
`platform/tls_server.zig` is untouched.
- No local records, no conditional forwarding, no CNAME uncloaking (Phase 5/7).
- No web UI, no REST API, no `/metrics`. `Pool.snapshot` exists so Phase 8 has something to read;
it is not exposed anywhere yet.
- No signal handling, no `src/server/shutdown.zig`, no `main.zig` wiring. Servers stop through
`deinit` plus group cancellation; process lifetime is Phase 7's problem.
- No DNS name resolution for upstream hosts — DoT endpoints take IP literals, DoH resolution is
`std.http.Client`'s business.
- No HTTP/2, no DoQ, no connection reuse for DoT, no DNSSEC validation (DO bit passes through).
- No 0x20 query-name randomization. Case-insensitive comparison per RFC 4343 is required; generating
mixed-case queries is not.
- No changes to `src/dns/`. If a helper is missing there, report it — do not add transport-aware
code to the pure core.
## As built
The implementation matches the spec with these review-driven refinements (three
review rounds; findings went 9 → 3 → 0):
- `pool.zig`: each `Entry` carries a `busy: std.Io.Mutex` held for the whole
attempt against that entry, so one entry's client (and its buffers/TLS state)
is never used by two tasks at once. Pass one re-checks `available` with a
fresh `now` after acquiring `busy` (the wait can outlive the health it was
admitted under); pass two probes without a re-check by design. Lock order is
always `busy` then `Pool.mutex`; the pool mutex still guards health and
`snapshot` only.
- `health.zig`: staleness rules are symmetric. The newest outcome is the @max
of `last_success_at`/`last_error_at`. A stale success counts into
totals/window and @max-updates `last_success_at`, but does not clear
`consecutive_failures`/`backoff_until`. A stale failure counts into
totals/window and @max-updates `last_error_at`, but only increments
`consecutive_failures`/extends backoff when no newer success exists, and
never overwrites a newer failure's error text.
- `udp_server.zig`: each slot's reply buffer is
`[transport.max_message_len]u8` (not `max_datagram`) so an oversized upstream
reply reaches the handler and becomes a TC=1 reply instead of SERVFAIL
(64 slots ≈ 4.4 MiB, inside the PLAN §18 budget). A `dropped_handler` stat
counts `.drop` outcomes from the handler.
- `tcp_server.zig`: a shutdown flag, set under the slot mutex before the
active-connection scan, closes the accept-vs-deinit race — a stream accepted
during shutdown is closed immediately instead of claimed.
- `handler.zig`: OPT validation walks every record section. OPT in answer or
authority, more than one OPT, a non-root OPT owner, or an unparseable OPT →
FORMERR (RFC 6891); only a genuinely absent OPT takes the no-OPT path.
- `dot_client.zig`: handshake `ReadFailed`/`WriteFailed` unwrap the stream's
stored cause through `transport.mapLocal` first, and certificate-bundle
`OutOfMemory` is a local resource, not a peer fault.
- `transport.zig`: `Endpoint.parse` rejects userinfo/query/fragment delimiters
(`@`, `?`, `#`) in the authority, and `?`/`#` in a DoH path.
- `dot_client.zig` (follow-up, tls_name commit): `DotClient.init` takes a
`tls_name`; it is the SNI and certificate-verification name, while the dial
target stays `endpoint.host`. Empty keeps the endpoint host, which is the
behavior described above. The same follow-up fixed a send bug this file
had from the start, invisible until a DoT handshake first succeeded:
`tls.Client.flush` only encrypts into the socket writer's buffer and never
flushes it, so the query never left the process and the peer eventually
closed the connection (`ReceiveFailed`/`EndOfStream`). `TlsStream.flush` now
does both flushes and `exchange` calls it; a hermetic loopback test in
`tls_client_integration_test.zig` covers it.