db-mode config changes apply live in-process
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

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.
This commit is contained in:
2026-08-24 00:04:28 +02:00
parent f7f4c8be09
commit ce143d1d87
47 changed files with 7698 additions and 926 deletions
+218 -301
View File
@@ -28,7 +28,6 @@ const Allocator = std.mem.Allocator;
const Certificate = std.crypto.Certificate;
const Writer = std.Io.Writer;
const net = std.Io.net;
const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
@@ -40,9 +39,7 @@ const config_export = @import("config/export.zig");
const db = @import("storage/db.zig");
const disk_monitor = @import("storage/disk_monitor.zig");
const dns_cache = @import("cache/dns_cache.zig");
const doh_client = @import("upstream/doh_client.zig");
const doh_server = @import("server/doh_server.zig");
const dot_client = @import("upstream/dot_client.zig");
const dot_server = @import("server/dot_server.zig");
const events = @import("storage/events.zig");
const faults = @import("config/faults.zig");
@@ -53,26 +50,25 @@ const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
const logger_controller = @import("storage/logger_controller.zig");
const logging = @import("platform/logging.zig");
const manager_mod = @import("filter/manager.zig");
const migrations = @import("storage/migrations.zig");
const model = @import("config/model.zig");
const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const queries_repo = @import("storage/repositories/queries_repo.zig");
const query_sink = @import("server/query_sink.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
const sse = @import("web/sse.zig");
const static = @import("web/static.zig");
const tcp_server = @import("server/tcp_server.zig");
const transport = @import("upstream/transport.zig");
const udp_server = @import("server/udp_server.zig");
const upstream_owner = @import("upstream/owner.zig");
const validate = @import("config/validate.zig");
const version = @import("version.zig");
const web_server = @import("web/server.zig");
@@ -89,11 +85,6 @@ const maintenance_interval_s = 60;
/// takes longer than this is not going to finish at all.
const download_budget_s = 300;
/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
/// probes the buffers `nxdns run` serves with.
const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: {
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
@@ -220,17 +211,13 @@ fn reconcileFromFileAt(
return result;
}
/// 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.
/// Boot's replay of one upstream finding. The rendering is the owner's, shared
/// with the runtime reconciler so a warning raised at boot and the same warning
/// raised by a settings PUT are byte-identical.
fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void {
var label_buf: [events.Store.max_subject_label_len]u8 = undefined;
const label = std.fmt.bufPrint(&label_buf, "{f}", .{safe_url.redact(url)}) catch &label_buf;
var detail_buf: [events.Store.max_detail_len]u8 = undefined;
const detail = std.fmt.bufPrint(&detail_buf, "upstream {f} {s}", .{
safe_url.redactQuoted(url),
message,
}) catch &detail_buf;
config_load.note(url, label, detail);
var rendered: upstream_owner.Rendered = .{};
rendered.render(.{ .url = url, .message = message });
config_load.note(url, rendered.label, rendered.detail);
}
/// Returns the moment the transaction committed, which is what the settings
@@ -529,49 +516,50 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer bundle.deinit(gpa);
var bundle_lock: std.Io.RwLock = .init;
var upstreams = try Upstreams.build(io, gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
defer upstreams.deinit(io, gpa);
var pool: pool_mod.Pool = .init(
upstreams.active(),
.{},
.{
const upstream_generation = try upstream_owner.build(.{
.gpa = gpa,
.io = io,
.servers = cfg.upstreams,
.http = &dns_http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
.timeouts = .{
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
},
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
);
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
.diagnostics = event_store,
});
pool.diagnostics = event_store;
// `build` writes no event rows, so that a settings PUT can prepare a
// candidate that is never published without leaving a trace. Boot has no
// such candidate: this generation is the one that serves, and replaying its
// report through the collector is what keeps the `configuration.load`
// episodes — and the keys `finalize` below spares — exactly what they were
// when this composition lived in this file.
for (upstream_generation.report().notes) |finding| {
noteUpstream(&config_load, finding.url, finding.message);
}
const upstream_count = upstream_generation.activeCount();
var upstreams: upstream_owner.Owner = .init(upstream_generation);
defer upstreams.deinit(io);
// -----------------------------------------------------------------------
// per-query state
// -----------------------------------------------------------------------
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
defer cache.deinit();
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
.limit = cfg.dns.rate_limit,
.window_seconds = cfg.dns.rate_window_seconds,
});
defer limiter.deinit();
var paused: pause.Pause = .{};
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
// One cell for both retention consumers, owned here so a settings apply
// moves the daily query-log prune and the stale-client prune together.
var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days);
var tracker: clients.Tracker = .init(&retention_days);
tracker.diagnostics = event_store;
// Naming rides the tracker's pass, on the tracker's task and connection
// (milestone-25 ruling 1), and reads the live forward zones.
var client_names_resolver: client_names.Resolver = .init(&tables);
client_names_resolver.diagnostics = event_store;
// The queue holds waiting tasks in intrusive lists, so neither the buffer
// nor the `Logger` may move once a task has touched either.
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
defer gpa.free(queue_buf);
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
query_logger.diagnostics = event_store;
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
// exists only when the web interface does (ruling 6) — without it the sink
// costs the query path one null check. Its rings are ~900 KiB, so it lives
@@ -584,18 +572,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
created.init();
hub = created;
}
var sink: query_sink.QuerySink = .init(&query_logger, hub);
// -----------------------------------------------------------------------
// disk, retention and the remaining connections (ruling 21)
// -----------------------------------------------------------------------
const data_path = try arena.dupeZ(u8, paths.data_dir);
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
const log_dir_path: ?[:0]const u8 = if (logging.logDirname(cfg.logging)) |dir|
try arena.dupeZ(u8, dir)
else
null;
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
// Frees whatever log-directory generation a settings apply installed; boot's
// path is borrowed from the arena and owned by nobody here.
defer monitor.deinit(io);
// Ruling 17. The scheduler consults it before every scheduled pass; the
// startup `reload` below is an operator action and stays ungated.
@@ -618,13 +608,31 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// policy and the right one here too.
monitor.sample(io, event_store, boot_now_s);
var retention: retention_mod.Retention = .init(cfg.logging);
var retention: retention_mod.Retention = .init(&retention_days);
var querylog_opened = try data.openQuerylogDb(io);
var querylog_writer_db = querylog_opened.database;
defer querylog_writer_db.close();
reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);
// The controller adopts that first connection and owns the query logger
// from here: the buffer, the `Logger`, the writer task and the connection
// are one generation, and `logging.query_log_buffer_max` can replace all
// four while the server runs (milestone 34 §S4). Its writer starts now and
// parks on an empty queue, which is where it would be anyway — no producer
// exists until the listeners below start serving.
var log_controller: logger_controller.Controller = undefined;
try log_controller.init(io, .{
.gpa = gpa,
.database = querylog_writer_db,
.source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path },
.logging = cfg.logging,
.monitor = &monitor,
.diagnostics = event_store,
});
defer log_controller.deinit(io);
var sink: query_sink.QuerySink = .init(&log_controller, hub);
var querylog_retention_db = try data.reopenQuerylogDb(io);
defer querylog_retention_db.close();
var tracker_db = try data.openConfigDb(io);
@@ -683,20 +691,56 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// listeners
// -----------------------------------------------------------------------
// Both are on the heap and both are freed through the handler rather than
// through this frame: a `cache.size` or `dns.rate_limit` change swaps in a
// replacement built with this same `gpa` and frees what it displaced, so
// what the handler holds at shutdown is not necessarily what boot built.
// Both are built inside one block so their errdefers end with it: after
// the block the handler is the sole owner, and the teardown defers below
// are what free them. An errdefer that outlived the block would free the
// same object those defers free.
const both = blk: {
const c = try gpa.create(dns_cache.DnsCache);
errdefer gpa.destroy(c);
c.* = try .init(gpa, cfg.cache);
errdefer c.deinit();
const l = try gpa.create(rate_limiter.RateLimiter);
errdefer gpa.destroy(l);
l.* = try .init(gpa, .{
.limit = cfg.dns.rate_limit,
.window_seconds = cfg.dns.rate_window_seconds,
});
break :blk .{ .cache = c, .limiter = l };
};
const cache = both.cache;
const limiter = both.limiter;
var h: handler.Handler = .{
.upstream = pool.client(),
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
.ecs_mode = cfg.edns.ecs_mode,
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
.upstream = &upstreams,
.policy = .{
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
.ecs_mode = cfg.edns.ecs_mode,
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
.negative_ttl_max = cfg.cache.negative_ttl_max,
},
.manager = &manager,
.local_tables = &tables,
.cache = &cache,
.negative_ttl_max = cfg.cache.negative_ttl_max,
.limiter = &limiter,
.cache = cache,
.limiter = limiter,
.sink = &sink,
.pause = &paused,
.tracker = &tracker,
};
// These run after `group.cancel` below, so no query is inside either table.
defer if (h.replaceCache(io, null)) |live| {
live.deinit();
gpa.destroy(live);
};
defer if (h.replaceRateLimiter(io, null)) |live| {
live.deinit();
gpa.destroy(live);
};
// -----------------------------------------------------------------------
// DoH/DoT listeners (milestone-10 ruling 11)
@@ -770,9 +814,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// The live hash may own a gpa replacement after a settings PUT; this defer
// runs after `group.cancel` below, so no web task can still read it.
defer web_state.live_hash.deinit(gpa);
// Same argument as the live hash: a settings PUT may have installed an
// owned generation, and this runs after `group.cancel`.
defer web_state.proxies.deinit(gpa);
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.proxies = .init(cfg.web.trusted_proxies),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
@@ -781,11 +829,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.tracker = &tracker,
.client_names = &client_names_resolver,
.manager = &manager,
.pool = &pool,
.upstreams = &upstreams,
.upstream_build = .{
.http = &dns_http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
},
.monitor = &monitor,
.local_tables = &tables,
.logger = &query_logger,
.logger = &log_controller,
.retention = &retention,
.retention_days = &retention_days,
.sessions = if (sessions) |*s| s else null,
.limiter = if (web_limiter) |*l| l else null,
.hub = hub,
@@ -894,25 +948,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
const gate: ?*disk_monitor.Monitor = &monitor;
// The query-log writer is deliberately *not* in `group`, and starts before
// every producer. Inside the group its life would end with the same
// `cancel` that stops the producers, and cancellation would race the
// queue's close: whichever landed first decided whether the batch the
// writer was holding reached the database or was counted as dropped. Given
// its own future, it outlives the producers by construction, and the
// teardown below can close the queue with nobody left to fill it and then
// wait for the writer to finish emptying it.
var writer_future = try io.concurrent(
logger_mod.Logger.runWriter,
.{ &query_logger, io, &querylog_writer_db, gate },
);
// The query-log writers are deliberately *not* in `group`, and the live one
// started before every producer. Inside the group their lives would end
// with the same `cancel` that stops the producers, and cancellation would
// race the queue's close: whichever landed first decided whether the batch
// a writer was holding reached the database or was counted as dropped. The
// controller owns their futures instead, so they outlive the producers by
// construction.
//
// Ruling 4's shutdown order, on the one path every exit from here takes:
// every producer stops and is joined, then the queue closes, then the
// writer is awaited — so the last batch is written rather than raced. A
// writer the disk gate will not let write counts its batch as dropped
// instead of holding the exit open (`logger.zig`), so this wait always
// ends.
// every producer stops and is joined, then `Controller.shutdown` closes
// each queue and awaits each writer — so the last batch is written rather
// than raced. A writer the disk gate will not let write counts its batch as
// dropped instead of holding the exit open (`logger.zig`), so this wait
// always ends.
//
// A `defer` and not straight-line code after `shutdown.wait`, because a
// `concurrent` spawn below can fail with the DNS listeners already
@@ -920,8 +969,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// signal gets.
defer {
group.cancel(io);
query_logger.shutdown(io);
writer_future.await(io) catch {};
log_controller.shutdown(io);
}
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
@@ -946,7 +994,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, authority, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstream_count, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1121,18 +1169,21 @@ fn maintenanceOnce(
api: ?*api_limiter.ApiLimiter,
io: std.Io,
) std.Io.Cancelable!void {
if (h.cache) |cache| {
// Both pointers are read inside the mutex that guards their replacement,
// the same discipline the query path follows: a load taken before the lock
// could sweep a table `replaceCache`/`replaceRateLimiter` has just freed.
{
const now_s = std.Io.Clock.real.now(io).toSeconds();
try h.cache_mutex.lock(io);
_ = cache.sweep(now_s);
h.cache_mutex.unlock(io);
defer h.cache_mutex.unlock(io);
if (h.cache) |cache| _ = cache.sweep(now_s);
}
if (h.limiter) |limiter| {
{
const now = std.Io.Clock.awake.now(io);
try h.limiter_mutex.lock(io);
_ = limiter.sweep(now);
h.limiter_mutex.unlock(io);
defer h.limiter_mutex.unlock(io);
if (h.limiter) |limiter| _ = limiter.sweep(now);
}
// The API limiter takes its own mutex, unlike the two above, which are the
@@ -1140,215 +1191,6 @@ fn maintenanceOnce(
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
}
// ---------------------------------------------------------------------------
// upstreams
// ---------------------------------------------------------------------------
/// 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 pool 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 warns and is
/// skipped, because one bad row in a table of four must not take DNS down.
/// No usable row at all is a configuration fault.
fn build(
io: std.Io,
gpa: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
config_load: *ConfigLoad,
) (Allocator.Error || error{NoUsableUpstreams})!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 {
log.warn(
"upstream {f} is not an https:// or tls:// endpoint; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, 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)) {
log.warn(
"upstream {f} is not a usable DoH url; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, 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;
}
};
// ---------------------------------------------------------------------------
// listeners
// ---------------------------------------------------------------------------
@@ -2414,10 +2256,11 @@ test "one maintenance pass drops the api limiter's stale buckets" {
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
// Nothing here exchanges: the pass only sweeps the two tables.
var unreachable_upstream: upstream_owner.Borrowed = .{};
var h: handler.Handler = .{
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
.blocking = .{ .mode = .zero, .ttl = 5 },
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
};
try maintenanceOnce(&h, &limiter, io);
@@ -2520,6 +2363,80 @@ test "a configuration finding is reported once and finalize closes the rest" {
));
}
test "the upstream build's report replays into the same rows the boot path used to write" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
// Last boot's finding for a row this boot has fixed. Only `finalize`
// closes it, which is why the replay has to run before it.
fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale");
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 upstream_owner.build(.{
.gpa = testing.allocator,
.io = io,
// The credential in the bad row is why the key and the rendered text
// differ: the key is the whole url, and both rendered forms drop it.
.servers = &.{
.{ .url = "ftp://user:hunter2@nope.example" },
.{ .url = "https://good.example/dns-query" },
},
.http = &http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
.timeouts = .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
},
.seed = 1,
});
var upstreams: upstream_owner.Owner = .init(generation);
defer upstreams.deinit(io);
// `build` itself wrote nothing: a candidate a settings PUT never publishes
// must leave the diagnostics log exactly as it found it.
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
for (generation.report().notes) |finding| {
noteUpstream(&collector, finding.url, finding.message);
}
collector.finalize();
// The whole url is the key, the redaction is the label, and the detail is
// the sentence `noteUpstream` has always written — byte for byte.
try testing.expectEqual(
@as(i64, 1),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text(
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
));
try testing.expectEqualStrings("ftp://nope.example", try fx.text(
"SELECT subject_label FROM operational_events WHERE resolved_at IS NULL",
));
try testing.expectEqualStrings(
"upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped",
try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"),
);
// The row that was wrong last boot and is not wrong now is closed by the
// same `finalize` as ever: the replay is what puts the keys in front of it.
try testing.expectEqual(@as(i64, 1), try fx.count(
"SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL",
));
}
test "an over-long boot finding list refuses to finalize rather than truncate" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();