//! 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, ¤t_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, ¬es, ); 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); }