Files
nxdns/src/upstream/owner.zig
T
mokhtar ce143d1d87
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00

927 lines
35 KiB
Zig

//! The upstream generation and the owner that publishes it.
//!
//! A `Generation` is everything one upstream configuration needs to answer a
//! query: the parsed endpoints, the leaf clients behind them, the pool that
//! chooses between them, and — the reason it is a generation rather than a
//! plain struct — every configuration string it borrows, copied into an arena
//! it owns. `transport.Endpoint` borrows the URL text (transport.zig:51), so a
//! generation built from database rows that live in a request arena must not
//! outlive that arena unless it took its own copy. It takes its own copy.
//!
//! `Owner` publishes one generation at a time under the `CertStore` discipline
//! (cert_store.zig:199/:237): a mutex held briefly around a refcounted borrow,
//! a swap that marks the old generation retired, and a last release that tears
//! it down. A query acquires for the length of one exchange and copies whatever
//! it needs out of the generation before releasing, so a `replace` landing
//! mid-query never frees anything the query still reads.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;
const doh_client = @import("doh_client.zig");
const dot_client = @import("dot_client.zig");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const model = @import("../config/model.zig");
const pool_mod = @import("pool.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const log = std.log.scoped(.nxdns);
const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
/// The `configuration.load` findings of one `build`, without the side effects.
///
/// `build` writes no event rows: at boot the caller replays this report through
/// its own `ConfigLoad.note` so the diagnostics are exactly what they were when
/// the composition lived in `app.zig`, and at runtime a candidate that is never
/// published must leave no trace at all. The strings are owned by the
/// generation's arena, so the report is readable for as long as the generation
/// is.
pub const BuildReport = struct {
notes: []const Note = &.{},
/// `url` is the subject key an upstream finding is filed under: the whole
/// URL is the identity, and redaction is the caller's job because the
/// caller is what renders it.
pub const Note = struct {
url: []const u8,
message: []const u8,
};
};
/// One finding rendered for the event store: the redacted label and the detail
/// line. Both borrow `Rendered`'s own buffers, so it must outlive the call that
/// writes the row.
pub const Rendered = struct {
label_buf: [events.Store.max_subject_label_len]u8 = undefined,
detail_buf: [events.Store.max_detail_len]u8 = undefined,
label: []const u8 = "",
detail: []const u8 = "",
/// An upstream's identity is its url: the whole url is the key, and the
/// redaction is the label, because a url can carry an account token.
pub fn render(self: *Rendered, finding: BuildReport.Note) void {
self.label = std.fmt.bufPrint(&self.label_buf, "{f}", .{
safe_url.redact(finding.url),
}) catch &self.label_buf;
self.detail = std.fmt.bufPrint(&self.detail_buf, "upstream {f} {s}", .{
safe_url.redactQuoted(finding.url),
finding.message,
}) catch &self.detail_buf;
}
};
/// Reconciles the `configuration.load` episodes of an upstream replace, on the
/// task that published it and after the publish.
///
/// SCOPED, never `Store.resolveExcept`: that call resolves every active
/// `configuration.load` episode outside its kept set, which at runtime would
/// falsely close boot warnings about settings this replace never touched. Only
/// the keys the previous generation reported and the new one does not are
/// resolved, one at a time.
///
/// `previous_keys` is the copy taken at prepare, so nothing here depends on the
/// retired generation still being alive.
pub fn reconcileReport(
store: *events.Store,
io: std.Io,
now_s: i64,
new_notes: []const BuildReport.Note,
previous_keys: []const []const u8,
) void {
var rendered: Rendered = .{};
for (new_notes) |finding| {
rendered.render(finding);
store.report(
io,
now_s,
.configuration_load,
finding.url,
rendered.label,
.warning,
rendered.detail,
);
}
var previous_buf: [events.Store.max_subject_key_len]u8 = undefined;
var current_buf: [events.Store.max_subject_key_len]u8 = undefined;
outer: for (previous_keys) |key| {
const previous = events.canonicalKey(key, &previous_buf);
for (new_notes) |finding| {
if (std.mem.eql(u8, previous, events.canonicalKey(finding.url, &current_buf))) continue :outer;
}
store.resolve(io, now_s, .configuration_load, key);
}
}
/// Everything a generation built from configuration rows owns. Absent when the
/// caller supplied the `transport.Client` directly — a test seam, and the shape
/// `nxdns check` would want for a single probe.
const Built = struct {
gpa: Allocator,
/// Every configuration string the generation borrows: the URL text each
/// `Endpoint` slices its host and path out of, and each DoT `tls_name`.
arena: std.heap.ArenaAllocator,
upstreams: Upstreams,
pool: pool_mod.Pool,
report: BuildReport,
};
pub const Generation = struct {
/// The exchange entry point. `built.pool.client()` for a configured
/// generation, the caller's client otherwise.
client: transport.Client,
/// The pool behind `client`, for the health and metrics reads. Null when
/// the client is not a pool.
pool: ?*pool_mod.Pool = null,
built: ?Built = null,
/// Set when `retire` must free the generation's own storage. Null when the
/// caller owns it — a generation on a test's stack.
destroy_with: ?Allocator = null,
/// Guarded by the owner's mutex, never touched outside it.
refs: usize = 0,
retired: bool = false,
/// A generation over a client the caller owns and keeps alive. Owns
/// nothing, so `retire` only invalidates it.
pub fn borrowing(client: transport.Client) Generation {
return .{ .client = client };
}
/// A generation over a pool the caller owns and keeps alive. The metrics
/// and health paths read the pool through this.
pub fn borrowingPool(pool: *pool_mod.Pool) Generation {
return .{ .client = pool.client(), .pool = pool };
}
pub fn report(self: *const Generation) BuildReport {
const built = self.built orelse return .{};
return built.report;
}
/// The active entries, for a caller that wants the count rather than the
/// health of each.
pub fn activeCount(self: *Generation) usize {
const built = &(self.built orelse return 0);
return built.upstreams.used;
}
/// Connections first, memory second, storage last. Only ever called with
/// `refs == 0`: by `Owner.release` when the last reader of a retired
/// generation leaves, by the caller `Owner.replace` handed an idle
/// generation back to, or by `Owner.deinit` at shutdown.
pub fn retire(self: *Generation, io: std.Io) void {
if (self.built) |*built| {
built.upstreams.deinit(io, built.gpa);
built.arena.deinit();
}
const destroy_with = self.destroy_with;
self.* = undefined;
if (destroy_with) |gpa| gpa.destroy(self);
}
};
pub const BuildError = Allocator.Error || error{NoUsableUpstreams};
pub const BuildOptions = struct {
gpa: Allocator,
io: std.Io,
/// Borrowed for the length of the call only: every string this generation
/// keeps is copied into its arena before `build` returns.
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
timeouts: pool_mod.Timeouts,
health_config: health.Config = .{},
seed: u64,
diagnostics: ?*events.Store = null,
};
/// Builds one heap-stable generation from a row set. Side-effect free: it
/// writes no event rows and publishes nothing. Every failure frees everything
/// it allocated.
pub fn build(opts: BuildOptions) BuildError!*Generation {
const gpa = opts.gpa;
const generation = try gpa.create(Generation);
errdefer gpa.destroy(generation);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// The rows this generation keeps, copied out of whatever memory the caller
// read them into. `Endpoint.parse` slices host and path out of the URL, so
// duplicating the URL covers all three.
const owned = try arena.alloc(model.UpstreamServer, opts.servers.len);
for (opts.servers, owned) |server, *copy| {
copy.* = .{
.url = try arena.dupe(u8, server.url),
.priority = server.priority,
.enabled = server.enabled,
.tls_name = try arena.dupe(u8, server.tls_name),
};
}
var notes: std.ArrayList(BuildReport.Note) = .empty;
var upstreams = try Upstreams.build(
opts.io,
gpa,
arena,
owned,
opts.http,
opts.bundle,
opts.bundle_lock,
&notes,
);
errdefer upstreams.deinit(opts.io, gpa);
generation.* = .{
.client = undefined,
.pool = null,
.destroy_with = gpa,
.built = .{
.gpa = gpa,
.arena = arena_state,
.upstreams = upstreams,
.pool = .init(
upstreams.active(),
opts.health_config,
opts.timeouts,
opts.seed,
),
.report = .{ .notes = try notes.toOwnedSlice(arena) },
},
};
const built = &generation.built.?;
built.pool.diagnostics = opts.diagnostics;
// Taken after the generation is in its final storage: the client is a
// pointer to the pool inside it.
generation.pool = &built.pool;
generation.client = built.pool.client();
return generation;
}
/// Publishes one generation at a time.
///
/// The initial generation is the caller's to provide and the owner's to tear
/// down: `deinit` retires whatever is live, and every generation a `replace`
/// displaces is retired by the owner or handed back to the caller idle.
pub const Owner = struct {
mutex: std.Io.Mutex = .init,
live: *Generation,
/// How many generations `replace` has published. One candidate per owner
/// means one increment per configuration write, however many keys of this
/// owner that write named. Guarded by `mutex`, like everything else here.
///
/// Not a `std.atomic.Value(u64)`: in Debug the x86_64 self-hosted backend
/// of zig 0.16.0 miscompiles `replace` when a `lock xadd` sits between the
/// `old.refs == 0` comparison and the branch on its result, and `replace`
/// then returns null for every input. See AGENTS.md.
published: u64 = 0,
pub fn init(live: *Generation) Owner {
return .{ .live = live };
}
/// Pins the live generation for one exchange or one scrape. The returned
/// generation stays valid until the matching `release`, across any number
/// of replaces.
pub fn acquire(self: *Owner, io: std.Io) *Generation {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.live.refs += 1;
return self.live;
}
/// The subject keys the live generation's report is filed under, copied
/// into `arena`.
///
/// Taken at prepare so that the retire-time reconciliation never depends on
/// the displaced generation still being alive: by then its last reader may
/// have freed it. The copy runs under the mutex because that is what pins
/// the generation whose arena the strings live in; the allocation is from a
/// bump arena and touches no `std.Io` primitive, so the hold stays as brief
/// as every other one this type takes.
pub fn copyLiveReportKeys(
self: *Owner,
io: std.Io,
arena: Allocator,
) Allocator.Error![]const []const u8 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const notes = self.live.report().notes;
const copies = try arena.alloc([]const u8, notes.len);
for (notes, copies) |finding, *slot| slot.* = try arena.dupe(u8, finding.url);
return copies;
}
pub fn release(self: *Owner, io: std.Io, generation: *Generation) void {
self.mutex.lockUncancelable(io);
std.debug.assert(generation.refs > 0);
generation.refs -= 1;
const retire_it = generation.retired and generation.refs == 0;
self.mutex.unlock(io);
if (retire_it) generation.retire(io);
}
/// Publishes `prepared` and retires the live generation. Infallible and
/// I/O-free by construction: a pointer swap under the mutex.
///
/// Returns the displaced generation when no reader held it, because there
/// is then no release left to retire it and the caller must — the same
/// `refs == 0` branch `CertStore.reload` takes. Returns null when a reader
/// still holds it; that reader's release retires it.
pub fn replace(self: *Owner, io: std.Io, prepared: *Generation) ?*Generation {
std.debug.assert(prepared.refs == 0);
std.debug.assert(!prepared.retired);
self.mutex.lockUncancelable(io);
const old = self.live;
self.live = prepared;
old.retired = true;
const idle = old.refs == 0;
self.published += 1;
self.mutex.unlock(io);
return if (idle) old else null;
}
/// How many replaces this owner has published.
pub fn publishedCount(self: *Owner, io: std.Io) u64 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.published;
}
/// Shutdown teardown. Every listener and every metrics reader must have
/// stopped: a live generation with readers left is a caller that tore down
/// out of order, and a retired one with readers was freed by its own last
/// release already.
pub fn deinit(self: *Owner, io: std.Io) void {
self.mutex.lockUncancelable(io);
const live = self.live;
std.debug.assert(live.refs == 0);
self.mutex.unlock(io);
live.retire(io);
self.* = undefined;
}
};
/// An owner over transport the caller already has: a fake client in a handler
/// test, or a pool a fixture built from fake entries. Owns nothing, so there is
/// nothing to tear down; both parts are inline so a caller keeps them on its
/// stack beside the handler.
pub const Borrowed = struct {
generation: Generation = undefined,
owner: Owner = undefined,
pub fn client(self: *Borrowed, c: transport.Client) *Owner {
self.generation = .borrowing(c);
self.owner = .init(&self.generation);
return &self.owner;
}
pub fn pool(self: *Borrowed, p: *pool_mod.Pool) *Owner {
self.generation = .borrowingPool(p);
self.owner = .init(&self.generation);
return &self.owner;
}
};
// ---------------------------------------------------------------------------
// the pool's entries and everything they point into
// ---------------------------------------------------------------------------
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
/// slot of its entry, so that many exchanges can be in flight against it at
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
/// allocation here lives exactly as long as the generation does, and none of
/// them is ever resized. One slot is used by one task at a time, which is why
/// the buffers are per client and not shared the way `cli.probeUpstreams`
/// shares them.
const Upstreams = struct {
entries: []pool_mod.Entry,
used: usize,
/// Sliced per entry into `Entry.slots`, never pointing into the client
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
slot_storage: []pool_mod.Slot,
/// One per enabled upstream, and the reason it is a separate allocation:
/// `Pool.init` sorts entries by value, so a counter living inside an entry
/// would be pointed at by the wrong upstream's clients after the sort.
recovery_counters: []std.atomic.Value(u64),
doh: []doh_client.DohClient,
dot: []dot_client.DotClient,
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
/// upstream leaves the tail of an over-allocated array undefined, and both
/// `deinit` and `build`'s failure paths iterate only the initialized
/// prefix — reading a `DotClient` that was never built, or closing a
/// session that was never opened, is what these two counts prevent.
doh_used: usize,
dot_used: usize,
doh_buf: []u8,
dot_buf: []u8,
/// A disabled upstream is left out entirely; a malformed one is noted and
/// skipped, because one bad row in a table of four must not take DNS down.
/// No usable row at all is a configuration fault.
///
/// `arena` is the generation's: `notes` borrows from it and so does every
/// string in `servers`, which the caller has already copied there.
fn build(
io: std.Io,
gpa: Allocator,
arena: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
notes: *std.ArrayList(BuildReport.Note),
) BuildError!Upstreams {
var enabled: usize = 0;
for (servers) |server| {
if (server.enabled) enabled += 1;
}
if (enabled == 0) return error.NoUsableUpstreams;
const chunk = tls.Client.min_buffer_len;
const slots = pool_mod.slots_per_entry;
const leaf_clients = enabled * slots;
var self: Upstreams = .{
.entries = try gpa.alloc(pool_mod.Entry, enabled),
.used = 0,
.slot_storage = &.{},
.recovery_counters = &.{},
.doh = &.{},
.dot = &.{},
.doh_used = 0,
.dot_used = 0,
.doh_buf = &.{},
.dot_buf = &.{},
};
errdefer self.deinit(io, gpa);
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
for (self.recovery_counters) |*counter| counter.* = .init(0);
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
for (servers) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
try note(arena, notes, server.url, "not an https:// or tls:// endpoint; skipped");
continue;
};
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
switch (endpoint.scheme) {
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
try note(arena, notes, server.url, "not a usable DoH url; skipped");
continue;
},
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
}
self.entries[self.used] = .{
.endpoint = endpoint,
.slots = entry_slots,
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = entry_slots.len },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
}
if (self.used == 0) return error.NoUsableUpstreams;
return self;
}
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
/// connection pool already serves concurrent requests, and a `DohClient`'s
/// only mutable state is the two buffers this gives each slot its own of.
///
/// False means the url is not a usable DoH url, which `DohClient.init`
/// decides from the url alone — so it fails on the first slot or on none.
/// `doh_used` still advances per client rather than per entry: it means
/// "initialized", and a skipped entry's clients are simply never reached.
fn wireDoh(
self: *Upstreams,
http: *std.http.Client,
endpoint: transport.Endpoint,
slots: []pool_mod.Slot,
) bool {
for (slots) |*slot| {
const index = self.doh_used;
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
self.doh[index] = doh_client.DohClient.init(
http,
endpoint,
self.doh_buf[base..][0..doh_request_buf_len],
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
) catch return false;
self.doh_used = index + 1;
slot.* = .{ .client = self.doh[index].client() };
}
return true;
}
/// One `DotClient` per slot, each with its own four TLS buffers and all
/// sharing the trust store. Every client of one entry reports its
/// stale-reuse recoveries through that entry's counter.
fn wireDot(
self: *Upstreams,
gpa: Allocator,
endpoint: transport.Endpoint,
tls_name: []const u8,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
slots: []pool_mod.Slot,
) void {
const chunk = tls.Client.min_buffer_len;
const recoveries = &self.recovery_counters[self.used];
for (slots) |*slot| {
const index = self.dot_used;
const base = index * 4 * chunk;
self.dot[index] = dot_client.DotClient.init(
endpoint,
tls_name,
gpa,
bundle,
bundle_lock,
recoveries,
.{
.tls_read = self.dot_buf[base..][0..chunk],
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
},
);
self.dot_used = index + 1;
slot.* = .{ .client = self.dot[index].client() };
}
}
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
/// never filled, which is what keeps `deinit` able to free the whole block.
fn active(self: *Upstreams) []pool_mod.Entry {
return self.entries[0..self.used];
}
/// Connections first, memory second: a `DotClient` holds a socket its
/// buffers belong to, so nothing it points at may be freed before it is
/// closed.
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
for (self.dot[0..self.dot_used]) |*client| client.close(io);
gpa.free(self.dot_buf);
gpa.free(self.doh_buf);
gpa.free(self.dot);
gpa.free(self.doh);
gpa.free(self.recovery_counters);
gpa.free(self.slot_storage);
gpa.free(self.entries);
self.* = undefined;
}
};
/// The warning goes out here as well as into the report: `std.log` is the
/// operator's boot transcript and a runtime candidate that is refused is still
/// worth a line, while the report is what writes the event row — at boot, or in
/// retire once a candidate is published.
fn note(
arena: Allocator,
notes: *std.ArrayList(BuildReport.Note),
url: []const u8,
message: []const u8,
) Allocator.Error!void {
log.warn("upstream {f} {s}", .{ safe_url.redactQuoted(url), message });
// `url` already lives in the generation's arena; the message is a literal.
try notes.append(arena, .{ .url = url, .message = message });
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const TestIo = struct {
threaded: std.Io.Threaded,
fn init(gpa: Allocator) TestIo {
return .{ .threaded = .init(gpa, .{}) };
}
fn io(self: *TestIo) std.Io {
return self.threaded.io();
}
fn deinit(self: *TestIo) void {
self.threaded.deinit();
}
};
const test_timeouts: pool_mod.Timeouts = .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
};
/// A client that answers from a fixed reply and records the identity it named,
/// so a test can prove which generation served an exchange.
const FakeClient = struct {
identity: []const u8,
calls: std.atomic.Value(u64) = .init(0),
/// Set once the exchange is inside the client, so a test knows the
/// generation is really pinned before it swaps.
entered: ?*std.Io.Event = null,
/// Waited on before the exchange returns, so a test can hold an exchange
/// open across a `replace`.
gate: ?*std.Io.Event = null,
fn client(self: *FakeClient) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const self: *FakeClient = @ptrCast(@alignCast(ptr));
selected.* = self.identity;
_ = self.calls.fetchAdd(1, .monotonic);
if (self.entered) |entered| entered.set(io);
if (self.gate) |gate| gate.wait(io) catch return error.Canceled;
@memcpy(response_buf[0..query.len], query);
return response_buf[0..query.len];
}
};
fn buildTestGeneration(
io: std.Io,
gpa: Allocator,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
servers: []const model.UpstreamServer,
) BuildError!*Generation {
return build(.{
.gpa = gpa,
.io = io,
.servers = servers,
.http = http,
.bundle = bundle,
.bundle_lock = bundle_lock,
.timeouts = test_timeouts,
.seed = 1,
});
}
test "an owner hands out the live generation and retires the old one on the last release" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var first: FakeClient = .{ .identity = "fake://g1" };
var second: FakeClient = .{ .identity = "fake://g2" };
var g1: Generation = .borrowing(first.client());
var g2: Generation = .borrowing(second.client());
var owner: Owner = .init(&g1);
const held = owner.acquire(io);
try testing.expectEqual(&g1, held);
// A reader holds G1, so the swap cannot retire it here.
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
try testing.expect(g1.retired);
// A new acquire lands on G2 while the old reader is still on G1.
const fresh = owner.acquire(io);
try testing.expectEqual(&g2, fresh);
owner.release(io, fresh);
owner.release(io, held);
owner.deinit(io);
}
test "a replace with no reader holding the live generation retires it through the return path" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://one.example/dns-query" },
});
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://two.example/dns-query" },
});
var owner: Owner = .init(g1);
// Nobody holds G1: `replace` must hand it back, because no release will.
const displaced = owner.replace(io, g2) orelse return error.ExpectedIdleGeneration;
try testing.expectEqual(g1, displaced);
displaced.retire(io);
owner.deinit(io);
}
test "a generation owns its configuration strings after the rows they came from are freed" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
// The rows a request arena would hand `build`. Freeing the arena poisons
// every byte of them, so a generation that kept a borrow reads garbage.
var rows_arena: std.heap.ArenaAllocator = .init(testing.allocator);
const rows = rows_arena.allocator();
const servers = try rows.dupe(model.UpstreamServer, &.{
.{ .url = try rows.dupe(u8, "tls://dot.example.net:853"), .tls_name = try rows.dupe(u8, "dot.example.net") },
});
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, servers);
rows_arena.deinit();
var owner: Owner = .init(generation);
defer owner.deinit(io);
const held = owner.acquire(io);
defer owner.release(io, held);
var snapshots: [4]pool_mod.Snapshot = undefined;
const count = try held.pool.?.snapshot(io, &snapshots);
try testing.expectEqual(@as(usize, 1), count);
try testing.expectEqualStrings("tls://dot.example.net:853", snapshots[0].url);
try testing.expectEqual(@as(usize, 1), held.activeCount());
}
test "build reports a malformed row instead of writing it, and refuses a row set with nothing usable" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "ftp://nope.example" },
.{ .url = "https://good.example/dns-query" },
.{ .url = "https://disabled.example/dns-query", .enabled = false },
});
var owner: Owner = .init(generation);
defer owner.deinit(io);
const report = generation.report();
try testing.expectEqual(@as(usize, 1), report.notes.len);
try testing.expectEqualStrings("ftp://nope.example", report.notes[0].url);
try testing.expectEqualStrings("not an https:// or tls:// endpoint; skipped", report.notes[0].message);
try testing.expectEqual(@as(usize, 1), generation.activeCount());
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
io,
testing.allocator,
&http,
&bundle,
&bundle_lock,
&.{.{ .url = "ftp://nope.example" }},
));
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
io,
testing.allocator,
&http,
&bundle,
&bundle_lock,
&.{.{ .url = "https://off.example", .enabled = false }},
));
}
// The concurrency criterion: an exchange in flight on G1 completes on G1, G1
// deinits only after that reader releases, and every exchange started after
// the swap runs on G2.
test "an exchange in flight survives a replace and the old generation retires after it" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var entered: std.Io.Event = .unset;
var gate: std.Io.Event = .unset;
var first: FakeClient = .{ .identity = "fake://g1", .entered = &entered, .gate = &gate };
var second: FakeClient = .{ .identity = "fake://g2" };
var g1: Generation = .borrowing(first.client());
var g2: Generation = .borrowing(second.client());
var owner: Owner = .init(&g1);
const Exchange = struct {
fn run(o: *Owner, inner_io: std.Io, out: *?[]const u8) void {
const generation = o.acquire(inner_io);
defer o.release(inner_io, generation);
var buf: [16]u8 = undefined;
var selected: ?[]const u8 = null;
_ = generation.client.exchange(inner_io, "abc", &buf, &selected) catch {};
out.* = selected;
}
};
var in_flight: ?[]const u8 = null;
var future = try io.concurrent(Exchange.run, .{ &owner, io, &in_flight });
// The swap must land with G1 really pinned, not merely likely to be.
entered.waitUncancelable(io);
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
var after: ?[]const u8 = null;
Exchange.run(&owner, io, &after);
try testing.expectEqualStrings("fake://g2", after.?);
gate.set(io);
future.await(io);
try testing.expectEqualStrings("fake://g1", in_flight.?);
owner.deinit(io);
}
test "a metrics scrape running against the owner survives a replace under it" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://one.example/dns-query" },
});
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://two.example/dns-query" },
.{ .url = "tls://two.example:853", .tls_name = "two.example" },
});
var owner: Owner = .init(g1);
defer owner.deinit(io);
const Scrape = struct {
/// What every scrape must be true of, whichever generation answered
/// it: a URL the scrape read out of a generation it holds is a URL
/// nothing has freed.
fn run(o: *Owner, inner_io: std.Io, started: *std.Io.Event, seen: *usize) void {
for (0..256) |i| {
const generation = o.acquire(inner_io);
defer o.release(inner_io, generation);
var raw: [8]pool_mod.Snapshot = undefined;
const count = generation.pool.?.snapshot(inner_io, &raw) catch 0;
for (raw[0..count]) |entry| {
if (std.mem.startsWith(u8, entry.url, "https://") or
std.mem.startsWith(u8, entry.url, "tls://")) seen.* += 1;
}
if (i == 0) started.set(inner_io);
}
}
};
var started: std.Io.Event = .unset;
var seen: usize = 0;
var future = try io.concurrent(Scrape.run, .{ &owner, io, &started, &seen });
started.waitUncancelable(io);
if (owner.replace(io, g2)) |old| old.retire(io);
future.await(io);
// Every one of the 256 scrapes read at least one intact URL.
try testing.expect(seen >= 256);
}