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.
This commit is contained in:
+218
-301
@@ -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();
|
||||
|
||||
+6
-9
@@ -343,16 +343,13 @@ pub const DataDir = struct {
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
|
||||
/// already established. A running server needs two background ones — the
|
||||
/// log writer and the retention pass each own one (`retention.zig`'s
|
||||
/// contract) — plus a third for the web task when the web interface is
|
||||
/// enabled.
|
||||
/// already established, bound to this data directory's path.
|
||||
///
|
||||
/// The opener itself is `querylog_schema.reopen`, beside the schema it
|
||||
/// belongs to: a logger generation opens its own writer connection at
|
||||
/// runtime and has no `DataDir` to ask.
|
||||
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||
_ = io;
|
||||
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
return querylog_schema.reopen(io, std.Io.Dir.cwd(), self.querylog_db_path);
|
||||
}
|
||||
|
||||
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
|
||||
|
||||
@@ -380,10 +380,6 @@ pub fn sessionTtlSeconds(w: Web) i64 {
|
||||
return @as(i64, w.session_ttl_hours) * 3600;
|
||||
}
|
||||
|
||||
pub fn retentionSeconds(l: Logging) i64 {
|
||||
return @as(i64, l.retention_days) * 86400;
|
||||
}
|
||||
|
||||
pub fn maxLogBytes(l: Logging) u64 {
|
||||
return @as(u64, l.max_size_mb) * 1024 * 1024;
|
||||
}
|
||||
@@ -840,7 +836,6 @@ test "unit conversions" {
|
||||
totalTimeout(.{}).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
|
||||
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
|
||||
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
|
||||
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
|
||||
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
|
||||
@@ -865,10 +860,6 @@ test "unit conversions at the field maximum do not overflow" {
|
||||
@as(i64, std.math.maxInt(u16)) * 3600,
|
||||
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, std.math.maxInt(u16)) * 86400,
|
||||
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
|
||||
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
|
||||
|
||||
@@ -1311,11 +1311,13 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
|
||||
|
||||
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
|
||||
// only one the server ever calls. A disabled update stops it after the
|
||||
// startup pass, so the production path runs to completion here with no
|
||||
// interval to wait out.
|
||||
// only one the server ever calls. A disabled update parks it after the
|
||||
// startup pass, and the seam turns that park into the shutdown the task
|
||||
// only otherwise exits on, so the production path runs to completion here
|
||||
// with no interval to wait out.
|
||||
env.mgr.update.enabled = false;
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
|
||||
@@ -1555,8 +1557,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
// The restart. The old manager is gone and a new one comes up over the same
|
||||
// directory and the same database with nothing carried across in memory.
|
||||
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
|
||||
// then the startup pass — and a disabled update makes it return rather than
|
||||
// wait out an interval.
|
||||
// then the startup pass — and a disabled update parks it rather than
|
||||
// waiting out an interval; the seam turns that park into shutdown.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
@@ -1566,7 +1568,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// Nothing was downloaded. The server is still listening, so this is a
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
@@ -2456,6 +2459,62 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records the deadline of the first park and then reports the shutdown the
|
||||
/// scheduler loop only otherwise exits on. Its clock never moves, so the
|
||||
/// deadline it captures is exactly `anchor + interval`.
|
||||
const FirstParkRecorder = struct {
|
||||
deadline_s: ?i64 = null,
|
||||
parked: bool = false,
|
||||
|
||||
fn clock(self: *FirstParkRecorder) manager.ScheduleClock {
|
||||
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
|
||||
}
|
||||
|
||||
fn now(_: ?*anyopaque, _: std.Io) i64 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn wait(ctx: ?*anyopaque, _: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
|
||||
const self: *FirstParkRecorder = @ptrCast(@alignCast(ctx.?));
|
||||
self.deadline_s = deadline_s;
|
||||
self.parked = true;
|
||||
return error.Canceled;
|
||||
}
|
||||
};
|
||||
|
||||
test "34: a pass whose refresh failed still advances the schedule anchor" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, http_body);
|
||||
defer fixture.deinit(io);
|
||||
// Every download this pass makes fails.
|
||||
fixture.setRoute(.oversize);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
_ = try seedSource(&env.database, url);
|
||||
|
||||
var recorder: FirstParkRecorder = .{};
|
||||
env.mgr.schedule_clock = recorder.clock();
|
||||
env.mgr.setSchedule(io, true, 2);
|
||||
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// The startup pass tried the source and failed. The anchor still moved to
|
||||
// that pass's completion, so the next refresh is a full interval away
|
||||
// rather than immediate: a failing source must not become a download loop.
|
||||
try testing.expect(recorder.parked);
|
||||
try testing.expectEqual(@as(?i64, 2 * 3_600), recorder.deadline_s);
|
||||
}
|
||||
|
||||
test "27: one refreshAll pass records one occurrence of a failing source" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
|
||||
+403
-11
@@ -333,12 +333,83 @@ pub fn stripHeader(bytes: []const u8) []const u8 {
|
||||
return rest;
|
||||
}
|
||||
|
||||
/// The scheduler's two time operations, behind a seam. Validated intervals are
|
||||
/// at least an hour, so a test that used the real clock would either sleep an
|
||||
/// hour or prove nothing; a test installs its own step clock instead.
|
||||
pub const ScheduleClock = struct {
|
||||
ctx: ?*anyopaque = null,
|
||||
/// Seconds on a monotonic clock. Only differences matter.
|
||||
nowFn: *const fn (ctx: ?*anyopaque, io: std.Io) i64,
|
||||
/// Returns when `deadline_s` arrives or `event` is set, whichever comes
|
||||
/// first; a null deadline waits for the event alone. A spurious early
|
||||
/// return is allowed — the caller rechecks both the version and the clock.
|
||||
waitFn: *const fn (
|
||||
ctx: ?*anyopaque,
|
||||
io: std.Io,
|
||||
event: *std.Io.Event,
|
||||
deadline_s: ?i64,
|
||||
) std.Io.Cancelable!void,
|
||||
|
||||
pub const real: ScheduleClock = .{ .nowFn = realNow, .waitFn = realWait };
|
||||
|
||||
/// Test seam: the loop only ever exits on shutdown, so a test that wants
|
||||
/// `runScheduler` to run its startup pass and return installs this and
|
||||
/// gets `error.Canceled` at the first park.
|
||||
pub const shutdown_at_first_park: ScheduleClock = .{ .nowFn = realNow, .waitFn = cancelWait };
|
||||
|
||||
fn cancelWait(_: ?*anyopaque, _: std.Io, _: *std.Io.Event, _: ?i64) std.Io.Cancelable!void {
|
||||
return error.Canceled;
|
||||
}
|
||||
|
||||
/// `boot` rather than `awake`: a box that suspends overnight should still
|
||||
/// see its daily interval elapse.
|
||||
fn realNow(_: ?*anyopaque, io: std.Io) i64 {
|
||||
return std.Io.Clock.boot.now(io).toSeconds();
|
||||
}
|
||||
|
||||
fn realWait(
|
||||
_: ?*anyopaque,
|
||||
io: std.Io,
|
||||
event: *std.Io.Event,
|
||||
deadline_s: ?i64,
|
||||
) std.Io.Cancelable!void {
|
||||
const timeout: std.Io.Timeout = if (deadline_s) |seconds| .{ .deadline = .{
|
||||
.raw = .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s },
|
||||
.clock = .boot,
|
||||
} } else .none;
|
||||
event.waitTimeout(io, timeout) catch |err| switch (err) {
|
||||
error.Timeout => {},
|
||||
error.Canceled => return error.Canceled,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Manager = struct {
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
paths: Paths,
|
||||
fetcher: *fetcher.Fetcher,
|
||||
/// Read and written only under `schedule_mutex`; `setSchedule` replaces it
|
||||
/// while the scheduler is parked.
|
||||
update: model.BlocklistUpdate,
|
||||
/// Guards `update`, `schedule_version` and `schedule_anchor_s`.
|
||||
///
|
||||
/// Lock ordering: innermost. `needsRefresh` takes it while `refresh_lock`
|
||||
/// is held, and nothing that holds it takes another manager lock.
|
||||
schedule_mutex: std.Io.Mutex,
|
||||
/// Bumped by every `setSchedule`. The scheduler reads it before it parks
|
||||
/// and again after it wakes: a change that lands in that window is what the
|
||||
/// recheck catches, so no wake is lost and none is mistaken for a deadline.
|
||||
schedule_version: u64,
|
||||
/// When the last refresh pass that RAN completed, on `ScheduleClock`'s
|
||||
/// clock. Success, failure and a disk-gate skip all advance it — the
|
||||
/// scheduled slot is spent either way and is not retried early. Null until
|
||||
/// the startup pass finishes.
|
||||
schedule_anchor_s: ?i64,
|
||||
/// Sticky once set, so `setSchedule` can never signal into a gap. The loop
|
||||
/// resets it under `schedule_mutex` before it recomputes its deadline.
|
||||
schedule_event: std.Io.Event,
|
||||
schedule_clock: ScheduleClock,
|
||||
/// Bounds one download. `std.http.Client` has no per-request deadline, so
|
||||
/// the fetch runs under `io.concurrent` against a sleep of this length.
|
||||
total_budget: std.Io.Clock.Duration,
|
||||
@@ -412,6 +483,11 @@ pub const Manager = struct {
|
||||
.paths = paths,
|
||||
.fetcher = fetcher_ptr,
|
||||
.update = update,
|
||||
.schedule_mutex = .init,
|
||||
.schedule_version = 0,
|
||||
.schedule_anchor_s = null,
|
||||
.schedule_event = .unset,
|
||||
.schedule_clock = .real,
|
||||
.total_budget = total_budget,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
@@ -1490,8 +1566,9 @@ pub const Manager = struct {
|
||||
/// it has no usable compiled files or its `last_updated` is older than the
|
||||
/// interval.
|
||||
///
|
||||
/// `update.enabled == false` stops after the startup pass; manual refresh
|
||||
/// through `refreshAll` still works.
|
||||
/// `update.enabled == false` parks after the startup pass; manual refresh
|
||||
/// through `refreshAll` still works, and a later `setSchedule` wakes the
|
||||
/// loop rather than needing a restart.
|
||||
pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void {
|
||||
// Ahead of the pass, not after it. This is the sweep that collects what
|
||||
// a killed process left behind: a `.raw.tmp` as large as the body the
|
||||
@@ -1511,20 +1588,78 @@ pub const Manager = struct {
|
||||
self.flushDiagnostics(io);
|
||||
},
|
||||
};
|
||||
if (!self.update.enabled) return;
|
||||
// The startup pass ran, so it anchors the schedule — including when
|
||||
// updates are disabled, so a later enable measures its first interval
|
||||
// from real work rather than from the moment the operator flipped the
|
||||
// switch.
|
||||
self.anchorNow(io);
|
||||
|
||||
// `boot` rather than `awake`: a box that suspends overnight should
|
||||
// still see its daily interval elapse.
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(model.updateIntervalSeconds(self.update)),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
// One hold: read the version, reset the sticky event, and take the
|
||||
// schedule the deadline is computed from. A `setSchedule` that
|
||||
// lands after this reset completes the wait below at once, and the
|
||||
// version recheck decides whether the wake meant anything.
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
const version = self.schedule_version;
|
||||
self.schedule_event.reset();
|
||||
const enabled = self.update.enabled;
|
||||
const interval_s = model.updateIntervalSeconds(self.update);
|
||||
const anchor = self.schedule_anchor_s;
|
||||
self.schedule_mutex.unlock(io);
|
||||
|
||||
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
|
||||
// Disabled parks on the event alone. The task still exits only on
|
||||
// shutdown, exactly as it did when it returned here.
|
||||
const deadline_s: ?i64 = if (enabled) (anchor orelse now_s) + interval_s else null;
|
||||
|
||||
if (deadline_s == null or now_s < deadline_s.?) {
|
||||
try self.schedule_clock.waitFn(self.schedule_clock.ctx, io, &self.schedule_event, deadline_s);
|
||||
// Either the schedule changed under us or the wait was
|
||||
// spurious; recompute from the top rather than guess.
|
||||
if (self.scheduleVersion(io) != version) continue;
|
||||
if (deadline_s == null) continue;
|
||||
if (self.schedule_clock.nowFn(self.schedule_clock.ctx, io) < deadline_s.?) continue;
|
||||
}
|
||||
|
||||
try self.scheduledPass(io);
|
||||
self.anchorNow(io);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a new blocklist-update schedule and wakes the scheduler. The
|
||||
/// anchor is untouched: the next refresh is due one NEW interval after the
|
||||
/// last pass that ran, which the loop refreshes immediately when that
|
||||
/// moment is already past.
|
||||
pub fn setSchedule(self: *Manager, io: std.Io, enabled: bool, interval_hours: u16) void {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
self.update = .{ .enabled = enabled, .interval_hours = interval_hours };
|
||||
self.schedule_version += 1;
|
||||
self.schedule_mutex.unlock(io);
|
||||
|
||||
self.schedule_event.set(io);
|
||||
}
|
||||
|
||||
/// The live schedule. Every reader outside the scheduler loop goes through
|
||||
/// here, so none of them reads `update` while `setSchedule` writes it.
|
||||
pub fn schedule(self: *Manager, io: std.Io) model.BlocklistUpdate {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
defer self.schedule_mutex.unlock(io);
|
||||
return self.update;
|
||||
}
|
||||
|
||||
fn scheduleVersion(self: *Manager, io: std.Io) u64 {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
defer self.schedule_mutex.unlock(io);
|
||||
return self.schedule_version;
|
||||
}
|
||||
|
||||
fn anchorNow(self: *Manager, io: std.Io) void {
|
||||
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
self.schedule_anchor_s = now_s;
|
||||
self.schedule_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// What one elapsed interval does. Split from the loop above so a test can
|
||||
/// run the pass without waiting the interval out; nothing in production
|
||||
/// calls it but `runScheduler`.
|
||||
@@ -1643,7 +1778,7 @@ pub const Manager = struct {
|
||||
// else would ever clear it. A stamp from the future is not evidence of
|
||||
// a recent fetch.
|
||||
if (last > now) return true;
|
||||
return now - last >= model.updateIntervalSeconds(self.update);
|
||||
return now - last >= model.updateIntervalSeconds(self.schedule(io));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3188,3 +3323,260 @@ test "bodyChecksum covers the list body, then the wild body, then the allow body
|
||||
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeable scheduler (milestone-34 S3.6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `ScheduleClock` that never sleeps. Each park is recorded, then the clock
|
||||
/// jumps straight to the deadline so the loop runs the next pass at once; a
|
||||
/// budget of parks ends the run with the `error.Canceled` shutdown is the only
|
||||
/// other source of. A park may also fire a `setSchedule`, which is what a
|
||||
/// settings PUT landing while the scheduler waits looks like.
|
||||
const StepClock = struct {
|
||||
const max_parks = 16;
|
||||
|
||||
mutex: std.Io.Mutex = .init,
|
||||
manager: *Manager,
|
||||
now_s: i64 = 0,
|
||||
/// Deadline of each park in order; null means "parked with no deadline",
|
||||
/// which is what a disabled schedule does.
|
||||
parks: [max_parks]?i64 = @splat(null),
|
||||
park_count: usize = 0,
|
||||
budget: usize = 2,
|
||||
/// Fired from inside the park at this index, before the wait returns.
|
||||
change_at_park: ?usize = null,
|
||||
change_enabled: bool = true,
|
||||
change_hours: u16 = 1,
|
||||
|
||||
fn clock(self: *StepClock) ScheduleClock {
|
||||
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
|
||||
}
|
||||
|
||||
fn now(ctx: ?*anyopaque, io: std.Io) i64 {
|
||||
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.now_s;
|
||||
}
|
||||
|
||||
fn wait(ctx: ?*anyopaque, io: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
|
||||
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
const index = self.park_count;
|
||||
if (index < max_parks) self.parks[index] = deadline_s;
|
||||
self.park_count = index + 1;
|
||||
const fire_change = self.change_at_park == index;
|
||||
const over_budget = self.park_count >= self.budget;
|
||||
if (deadline_s) |d| self.now_s = d;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// Taken outside this clock's own mutex: `setSchedule` takes the
|
||||
// manager's, and the loop reads this clock under neither.
|
||||
if (fire_change) {
|
||||
self.manager.setSchedule(io, self.change_enabled, self.change_hours);
|
||||
return;
|
||||
}
|
||||
if (over_budget or deadline_s == null) return error.Canceled;
|
||||
}
|
||||
|
||||
fn parked(self: *StepClock) []const ?i64 {
|
||||
return self.parks[0..@min(self.park_count, max_parks)];
|
||||
}
|
||||
};
|
||||
|
||||
const hour = 3_600;
|
||||
|
||||
test "the scheduler parks one interval past the anchor and again past each pass" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 3 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass anchored at 0, so the first park is due at 2 h and each
|
||||
// completed pass re-anchors: 2 h, 4 h, 6 h.
|
||||
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
|
||||
}
|
||||
|
||||
test "a shortened interval moves the next refresh onto the new cadence" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 24 };
|
||||
|
||||
// The PUT lands while the loop waits out the 24-hour deadline.
|
||||
var step: StepClock = .{
|
||||
.manager = &manager,
|
||||
.budget = 4,
|
||||
.change_at_park = 0,
|
||||
.change_enabled = true,
|
||||
.change_hours = 1,
|
||||
};
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// Park 0 was the old 24-hour deadline; the change woke it, and every park
|
||||
// after it is one hour past the anchor the previous pass set.
|
||||
const parks = step.parked();
|
||||
try testing.expectEqual(@as(usize, 4), parks.len);
|
||||
try testing.expectEqual(@as(?i64, 24 * hour), parks[0]);
|
||||
try testing.expectEqual(@as(?i64, 24 * hour + hour), parks[1]);
|
||||
try testing.expectEqual(@as(?i64, 25 * hour + hour), parks[2]);
|
||||
}
|
||||
|
||||
test "a disabled schedule parks with no deadline and the startup pass still runs" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = false, .interval_hours = 1 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 8 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass ran — it published a snapshot even with updates off —
|
||||
// and then the loop parked once, on nothing.
|
||||
try testing.expect(manager.generation > 0);
|
||||
try testing.expectEqualSlices(?i64, &.{null}, step.parked());
|
||||
}
|
||||
|
||||
test "re-enabling anchors the first interval on the last pass that ran" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = false, .interval_hours = 1 };
|
||||
|
||||
var step: StepClock = .{
|
||||
.manager = &manager,
|
||||
.budget = 3,
|
||||
.change_at_park = 0,
|
||||
.change_enabled = true,
|
||||
.change_hours = 3,
|
||||
};
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
const parks = step.parked();
|
||||
// Park 0 is the disabled park; the enable wakes it, and the first deadline
|
||||
// is three hours past the STARTUP pass's anchor rather than past the
|
||||
// moment the operator flipped the switch.
|
||||
try testing.expectEqual(@as(?i64, null), parks[0]);
|
||||
try testing.expectEqual(@as(?i64, 3 * hour), parks[1]);
|
||||
}
|
||||
|
||||
test "an interval already elapsed at enable time refreshes immediately" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 2 };
|
||||
manager.schedule_clock = step.clock();
|
||||
// The anchor is four hours in the past, so two hours past it is already
|
||||
// gone and the loop must not wait at all before its first pass.
|
||||
manager.schedule_anchor_s = -4 * hour;
|
||||
step.now_s = 0;
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass re-anchors at 0, so this proves nothing on its own
|
||||
// unless the anchor survives it; assert on the parks instead: the first
|
||||
// park is one interval past the startup anchor, never a wait for a
|
||||
// deadline already behind us.
|
||||
const parks = step.parked();
|
||||
try testing.expectEqual(@as(?i64, 2 * hour), parks[0]);
|
||||
}
|
||||
|
||||
test "a gate-skipped pass advances the anchor rather than retrying early" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
manager.monitor = &monitor;
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 3 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// Every scheduled pass was refused by the gate, and each one still spent
|
||||
// its slot: the deadlines march one interval at a time instead of
|
||||
// collapsing onto the same anchor.
|
||||
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
|
||||
// The startup pass is gated too, so three refusals: one startup and the
|
||||
// two scheduled passes the parks above bracket.
|
||||
try testing.expectEqual(@as(u64, 3), manager.refreshesGated());
|
||||
}
|
||||
|
||||
test "setSchedule is what the live schedule readers see" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
|
||||
manager.setSchedule(io, false, 6);
|
||||
const live = manager.schedule(io);
|
||||
try testing.expect(!live.enabled);
|
||||
try testing.expectEqual(@as(u16, 6), live.interval_hours);
|
||||
try testing.expect(manager.schedule_event.isSet());
|
||||
}
|
||||
|
||||
@@ -252,6 +252,200 @@ fn installWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) void {
|
||||
if (state.output == .file) openFileLocked();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot apply (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which of the three disjoint shapes a `logging` apply takes. The case is
|
||||
/// decided from the FINAL MERGED config against the live sink, and it depends
|
||||
/// only on `output` and `file_path` — fields nothing but an apply writes.
|
||||
/// Rotation and write-failure recovery move `file`, `file_pos` and
|
||||
/// `rotate_pending`, never these two, so a case decided in one lock hold is
|
||||
/// still the right case in the next.
|
||||
pub const ApplyCase = enum {
|
||||
/// The merged config wants a file, and it is not the file that is open:
|
||||
/// the path differs, or output is switching TO file.
|
||||
target_changed,
|
||||
/// Output is switching away from file. Nothing to open.
|
||||
target_removed,
|
||||
/// Everything else — output stays stderr/syslog, or output stays file on
|
||||
/// the SAME path. Only config fields move; the handle and its position and
|
||||
/// rotation state stay with the rotation machinery that owns them.
|
||||
target_unchanged,
|
||||
};
|
||||
|
||||
/// The complete new target state for a `target_changed` apply. The handle
|
||||
/// couples to both other fields: inheriting the old `file_pos` would write
|
||||
/// past the new file's end, and inheriting a pending rotation would rotate the
|
||||
/// new target on its first line.
|
||||
pub const PreparedSink = struct {
|
||||
file: std.Io.File,
|
||||
file_pos: u64,
|
||||
rotate_pending: bool = false,
|
||||
};
|
||||
|
||||
pub const PrepareError = error{
|
||||
/// `file_path` does not fit in the sink's path buffer, so the sink could
|
||||
/// not name the file it was told to write.
|
||||
PathTooLong,
|
||||
/// The new target could not be opened, created, or measured.
|
||||
TargetUnopenable,
|
||||
};
|
||||
|
||||
/// A validated `logging` apply, owning everything publish needs. Publish takes
|
||||
/// no borrow from the request arena, so this outlives the request that built
|
||||
/// it.
|
||||
pub const PreparedApply = struct {
|
||||
case: ApplyCase,
|
||||
threshold: std.log.Level,
|
||||
output: model.LogOutput,
|
||||
path_buf: [std.Io.Dir.max_path_bytes]u8,
|
||||
path_len: usize,
|
||||
max_files: u8,
|
||||
max_bytes: u64,
|
||||
sink: ?PreparedSink,
|
||||
|
||||
pub fn path(self: *const PreparedApply) []const u8 {
|
||||
return self.path_buf[0..self.path_len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Prepare: everything fallible happens here, and nothing is published. A
|
||||
/// `target_changed` apply opens the NEW file and MEASURES it — the open is the
|
||||
/// step that can fail, and doing it here closes the close-then-reopen window
|
||||
/// `installWithMaxBytes` has, where a bad new path leaves no sink at all.
|
||||
pub fn prepareApply(io: std.Io, cfg: model.Logging) PrepareError!PreparedApply {
|
||||
return prepareApplyWithMaxBytes(io, cfg, model.maxLogBytes(cfg));
|
||||
}
|
||||
|
||||
/// Test-only entry point, mirroring `installForTest`.
|
||||
pub fn prepareApplyForTest(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
|
||||
return prepareApplyWithMaxBytes(io, cfg, max_bytes);
|
||||
}
|
||||
|
||||
fn prepareApplyWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
|
||||
if (cfg.file_path.len > std.Io.Dir.max_path_bytes) return error.PathTooLong;
|
||||
|
||||
var prepared: PreparedApply = .{
|
||||
.case = undefined,
|
||||
.threshold = toStdLevel(cfg.level),
|
||||
.output = cfg.output,
|
||||
.path_buf = undefined,
|
||||
.path_len = cfg.file_path.len,
|
||||
.max_files = cfg.max_files,
|
||||
.max_bytes = max_bytes,
|
||||
.sink = null,
|
||||
};
|
||||
@memcpy(prepared.path_buf[0..prepared.path_len], cfg.file_path);
|
||||
prepared.case = classifyApply(cfg);
|
||||
|
||||
if (prepared.case == .target_changed) {
|
||||
prepared.sink = try openTarget(io, prepared.path());
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
fn classifyApply(cfg: model.Logging) ApplyCase {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
const currently_file = state.output == .file;
|
||||
if (cfg.output != .file) return if (currently_file) .target_removed else .target_unchanged;
|
||||
if (!currently_file) return .target_changed;
|
||||
return if (std.mem.eql(u8, state.path(), cfg.file_path)) .target_unchanged else .target_changed;
|
||||
}
|
||||
|
||||
/// Opens `p` and measures it, without touching the live sink.
|
||||
fn openTarget(io: std.Io, p: []const u8) PrepareError!PreparedSink {
|
||||
if (p.len == 0) return error.TargetUnopenable;
|
||||
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
|
||||
const dir: std.Io.Dir = .cwd();
|
||||
const file = dir.openFile(io, p, .{ .mode = .write_only }) catch |open_err| switch (open_err) {
|
||||
error.FileNotFound => dir.createFile(io, p, .{ .truncate = false }) catch
|
||||
return error.TargetUnopenable,
|
||||
else => return error.TargetUnopenable,
|
||||
};
|
||||
errdefer file.close(io);
|
||||
|
||||
// A pre-existing nonempty target is appended to, so the new position is
|
||||
// its measured length rather than zero.
|
||||
const length = file.length(io) catch return error.TargetUnopenable;
|
||||
return .{ .file = file, .file_pos = length, .rotate_pending = false };
|
||||
}
|
||||
|
||||
/// Publish: infallible and I/O-free, one hold of the sink lock. Returns the
|
||||
/// DETACHED old handle, which `retireApply` closes — closing a file is retire
|
||||
/// work, and doing it here would put a syscall inside the publish.
|
||||
pub fn publishApply(prepared: PreparedApply) ?std.Io.File {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
state.threshold = prepared.threshold;
|
||||
state.output = prepared.output;
|
||||
state.path_len = prepared.path_len;
|
||||
@memcpy(state.path_buf[0..state.path_len], prepared.path());
|
||||
state.max_files = prepared.max_files;
|
||||
state.max_bytes = prepared.max_bytes;
|
||||
|
||||
switch (prepared.case) {
|
||||
// The handle and its position and rotation state are not this apply's
|
||||
// to move; a broken handle is repaired by the existing per-write
|
||||
// recovery, not by a config change.
|
||||
.target_unchanged => return null,
|
||||
.target_changed => {
|
||||
const detached = state.file;
|
||||
const sink = prepared.sink.?;
|
||||
state.file = sink.file;
|
||||
state.file_pos = sink.file_pos;
|
||||
state.rotate_pending = sink.rotate_pending;
|
||||
return detached;
|
||||
},
|
||||
.target_removed => {
|
||||
const detached = state.file;
|
||||
state.file = null;
|
||||
state.file_pos = 0;
|
||||
state.rotate_pending = false;
|
||||
return detached;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire: closes the handle `publishApply` detached, after no writer can
|
||||
/// reach it — the swap happened under the sink lock, so any writer that held
|
||||
/// it has already returned.
|
||||
pub fn retireApply(io: std.Io, detached: ?std.Io.File) void {
|
||||
const file = detached orelse return;
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
file.close(io);
|
||||
}
|
||||
|
||||
/// Discards a prepared apply that will not be published, because its commit
|
||||
/// failed or a sibling owner's prepare did.
|
||||
pub fn abortApply(io: std.Io, prepared: PreparedApply) void {
|
||||
const sink = prepared.sink orelse return;
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
sink.file.close(io);
|
||||
}
|
||||
|
||||
/// The directory the disk monitor should measure for `cfg`: the log file's
|
||||
/// directory when output is `file`, and null otherwise — with output on
|
||||
/// stderr or syslog there is no log file to run out of room for.
|
||||
///
|
||||
/// A path with no directory component measures the working directory, which is
|
||||
/// where a bare filename lands.
|
||||
pub fn logDirname(cfg: model.Logging) ?[]const u8 {
|
||||
if (cfg.output != .file) return null;
|
||||
if (cfg.file_path.len == 0) return null;
|
||||
return std.fs.path.dirname(cfg.file_path) orelse ".";
|
||||
}
|
||||
|
||||
/// Flushes and closes the file, and restores pass-through stderr formatting.
|
||||
pub fn deinstall() void {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
@@ -1099,3 +1293,365 @@ test "a message over the buffer is marked and counted" {
|
||||
const colon = std.mem.indexOf(u8, written, ": ").?;
|
||||
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hot apply (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The sink is one process-wide `state` behind the stderr lock, so an apply
|
||||
/// test has to save it, drive the apply, and put it back. Nothing here holds
|
||||
/// the lock across an apply call: `classifyApply` and `publishApply` take it
|
||||
/// themselves.
|
||||
const ApplyFixture = struct {
|
||||
threaded: std.Io.Threaded,
|
||||
tmp: testing.TmpDir,
|
||||
saved: State,
|
||||
|
||||
fn init(self: *ApplyFixture) void {
|
||||
self.threaded = .init(testing.allocator, .{});
|
||||
self.tmp = testing.tmpDir(.{});
|
||||
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
self.saved = state;
|
||||
state = .{};
|
||||
state.io = self.threaded.io();
|
||||
}
|
||||
|
||||
fn deinit(self: *ApplyFixture) void {
|
||||
{
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
if (state.file) |f| f.close(self.threaded.io());
|
||||
state = self.saved;
|
||||
}
|
||||
self.tmp.cleanup();
|
||||
self.threaded.deinit();
|
||||
}
|
||||
|
||||
fn io(self: *ApplyFixture) std.Io {
|
||||
return self.threaded.io();
|
||||
}
|
||||
|
||||
fn path(self: *ApplyFixture, buf: []u8, name: []const u8) []const u8 {
|
||||
return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ self.tmp.sub_path, name }) catch unreachable;
|
||||
}
|
||||
|
||||
/// Puts the sink on `p` with a real open handle, the way a running server
|
||||
/// with `output = file` sits.
|
||||
fn openOn(self: *ApplyFixture, p: []const u8) void {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
state.installed = true;
|
||||
state.output = .file;
|
||||
state.path_len = p.len;
|
||||
@memcpy(state.path_buf[0..p.len], p);
|
||||
state.max_bytes = 1 << 20;
|
||||
state.max_files = 3;
|
||||
openFileLocked();
|
||||
_ = self;
|
||||
}
|
||||
|
||||
fn snapshot(self: *ApplyFixture) State {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
return state;
|
||||
}
|
||||
|
||||
fn setHandleState(self: *ApplyFixture, file: ?std.Io.File, file_pos: u64, rotate_pending: bool) void {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
state.file = file;
|
||||
state.file_pos = file_pos;
|
||||
state.rotate_pending = rotate_pending;
|
||||
}
|
||||
|
||||
/// Writes one record through the live handle, as `emitFileLocked` does.
|
||||
fn writeThroughSink(self: *ApplyFixture, text: []const u8) !void {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
try writeLineLocked(state.file.?, text);
|
||||
}
|
||||
|
||||
fn read(self: *ApplyFixture, buf: []u8, name: []const u8) ![]u8 {
|
||||
return self.tmp.dir.readFileAlloc(self.io(), name, testing.allocator, .limited(buf.len)) catch |err| return err;
|
||||
}
|
||||
};
|
||||
|
||||
fn fileCfg(p: []const u8) model.Logging {
|
||||
return .{ .output = .file, .file_path = p, .level = .info, .max_files = 3, .max_size_mb = 1 };
|
||||
}
|
||||
|
||||
test "a bad target path is refused at prepare and the live sink is untouched" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
const before = fx.snapshot();
|
||||
try testing.expect(before.file != null);
|
||||
|
||||
// A directory that does not exist: the open and the create both fail.
|
||||
var bad_buf: [200]u8 = undefined;
|
||||
const bad = fx.path(&bad_buf, "no-such-dir/nxdns.log");
|
||||
try testing.expectError(
|
||||
error.TargetUnopenable,
|
||||
prepareApplyForTest(fx.io(), fileCfg(bad), 1 << 20),
|
||||
);
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(before.file.?.handle, after.file.?.handle);
|
||||
try testing.expectEqualStrings(live, after.path());
|
||||
}
|
||||
|
||||
test "a target change publishes without closing, and retire closes the old handle" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
const first = fx.path(&first_buf, "first.log");
|
||||
const second = fx.path(&second_buf, "second.log");
|
||||
fx.openOn(first);
|
||||
const before = fx.snapshot();
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_changed, prepared.case);
|
||||
|
||||
const detached = publishApply(prepared);
|
||||
const after = fx.snapshot();
|
||||
|
||||
// Publish swapped the handle and left the old one OPEN: the old descriptor
|
||||
// still writes, which it could not if publish had closed it.
|
||||
try testing.expectEqual(before.file.?.handle, detached.?.handle);
|
||||
try testing.expect(after.file.?.handle != detached.?.handle);
|
||||
try testing.expectEqualStrings(second, after.path());
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
try testing.expect(!after.rotate_pending);
|
||||
|
||||
var old_writer_buf: [64]u8 = undefined;
|
||||
var ow = detached.?.writer(fx.io(), &old_writer_buf);
|
||||
try ow.interface.writeAll("still open\n");
|
||||
try ow.interface.flush();
|
||||
|
||||
retireApply(fx.io(), detached);
|
||||
|
||||
// The new target receives lines.
|
||||
try fx.writeThroughSink("1 info: on the new target\n");
|
||||
var read_buf: [256]u8 = undefined;
|
||||
const contents = try fx.read(&read_buf, "second.log");
|
||||
defer testing.allocator.free(contents);
|
||||
try testing.expectEqualStrings("1 info: on the new target\n", contents);
|
||||
}
|
||||
|
||||
test "switching to a pre-existing nonempty file starts at its measured length" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
const existing = "already here\n";
|
||||
try fx.tmp.dir.writeFile(fx.io(), .{ .sub_path = "kept.log", .data = existing });
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var kept_buf: [160]u8 = undefined;
|
||||
fx.openOn(fx.path(&first_buf, "first.log"));
|
||||
const kept = fx.path(&kept_buf, "kept.log");
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(kept), 1 << 20);
|
||||
try testing.expectEqual(@as(u64, existing.len), prepared.sink.?.file_pos);
|
||||
retireApply(fx.io(), publishApply(prepared));
|
||||
|
||||
try testing.expectEqual(@as(u64, existing.len), fx.snapshot().file_pos);
|
||||
|
||||
// Inheriting the old position would have overwritten the existing bytes.
|
||||
try fx.writeThroughSink("appended\n");
|
||||
var read_buf: [256]u8 = undefined;
|
||||
const contents = try fx.read(&read_buf, "kept.log");
|
||||
defer testing.allocator.free(contents);
|
||||
try testing.expectEqualStrings(existing ++ "appended\n", contents);
|
||||
}
|
||||
|
||||
test "a target change does not inherit a pending rotation" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
fx.openOn(fx.path(&first_buf, "first.log"));
|
||||
const second = fx.path(&second_buf, "second.log");
|
||||
|
||||
// A rotation the old target owed and never completed: the handle is closed
|
||||
// and the rotation is still pending.
|
||||
const stale = fx.snapshot().file.?;
|
||||
stale.close(fx.io());
|
||||
fx.setHandleState(null, 0, true);
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
|
||||
try testing.expect(!prepared.sink.?.rotate_pending);
|
||||
retireApply(fx.io(), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expect(!after.rotate_pending);
|
||||
try testing.expect(after.file != null);
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
}
|
||||
|
||||
test "a file to stderr apply detaches the handle and clears position and rotation" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
fx.setHandleState(fx.snapshot().file, 4_096, true);
|
||||
const before = fx.snapshot();
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .stderr,
|
||||
.file_path = live,
|
||||
.level = .warn,
|
||||
}, 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_removed, prepared.case);
|
||||
|
||||
const detached = publishApply(prepared);
|
||||
const after = fx.snapshot();
|
||||
|
||||
try testing.expectEqual(before.file.?.handle, detached.?.handle);
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
try testing.expect(!after.rotate_pending);
|
||||
try testing.expectEqual(model.LogOutput.stderr, after.output);
|
||||
// The path still moves, so a later switch back to file opens the right one.
|
||||
try testing.expectEqualStrings(live, after.path());
|
||||
|
||||
retireApply(fx.io(), detached);
|
||||
}
|
||||
|
||||
test "a same-path apply changes only config fields, whatever the handle is doing" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
|
||||
// Publish takes the same lock a rotation and a write-failure closure hold,
|
||||
// so the only reachable interleavings are "before publish" and "after".
|
||||
// Both leave the handle state the apply must not touch; these are the two
|
||||
// states each of them leaves behind.
|
||||
const handle_states = [_]struct { file: bool, pos: u64, pending: bool }{
|
||||
// Mid-rotation: handle closed, rotation owed.
|
||||
.{ .file = false, .pos = 0, .pending = true },
|
||||
// Healthy and part-written.
|
||||
.{ .file = true, .pos = 8_192, .pending = false },
|
||||
};
|
||||
|
||||
const open_handle = fx.snapshot().file.?;
|
||||
for (handle_states) |want| {
|
||||
fx.setHandleState(if (want.file) open_handle else null, want.pos, want.pending);
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .file,
|
||||
.file_path = live,
|
||||
.level = .debug,
|
||||
.max_files = 9,
|
||||
.max_size_mb = 7,
|
||||
}, 4_242);
|
||||
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
|
||||
|
||||
// Nothing detached, so retire has nothing to close.
|
||||
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(want.pos, after.file_pos);
|
||||
try testing.expectEqual(want.pending, after.rotate_pending);
|
||||
try testing.expectEqual(want.file, after.file != null);
|
||||
// The config fields did move.
|
||||
try testing.expectEqual(std.log.Level.debug, after.threshold);
|
||||
try testing.expectEqual(@as(u8, 9), after.max_files);
|
||||
try testing.expectEqual(@as(u64, 4_242), after.max_bytes);
|
||||
}
|
||||
|
||||
fx.setHandleState(open_handle, 0, false);
|
||||
}
|
||||
|
||||
test "a file_path change while output is stderr updates the config and touches no handle" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const later = fx.path(&buf, "later.log");
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .syslog,
|
||||
.file_path = later,
|
||||
.level = .info,
|
||||
}, 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
|
||||
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqualStrings(later, after.path());
|
||||
|
||||
// The later switch to file opens exactly that path.
|
||||
const to_file = try prepareApplyForTest(fx.io(), fileCfg(later), 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_changed, to_file.case);
|
||||
retireApply(fx.io(), publishApply(to_file));
|
||||
try testing.expect(fx.snapshot().file != null);
|
||||
}
|
||||
|
||||
test "an aborted apply closes the target it opened" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const target = fx.path(&buf, "never.log");
|
||||
|
||||
// The commit failed, so the prepared target must not leak its descriptor.
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(target), 1 << 20);
|
||||
abortApply(fx.io(), prepared);
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqual(model.LogOutput.stderr, after.output);
|
||||
}
|
||||
|
||||
test "logDirname follows output and file_path in both directions" {
|
||||
try testing.expectEqualStrings("/var/log/nxdns", logDirname(.{
|
||||
.output = .file,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}).?);
|
||||
// A bare filename lands in the working directory.
|
||||
try testing.expectEqualStrings(".", logDirname(.{
|
||||
.output = .file,
|
||||
.file_path = "nxdns.log",
|
||||
}).?);
|
||||
// Output away from file stops the measurement whatever the path says.
|
||||
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
|
||||
.output = .stderr,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}));
|
||||
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
|
||||
.output = .syslog,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}));
|
||||
}
|
||||
|
||||
+274
-8
@@ -109,10 +109,13 @@ pub const CertStore = struct {
|
||||
pub const Kind = enum { doh, dot };
|
||||
|
||||
gpa: std.mem.Allocator,
|
||||
/// Borrowed from the config; must outlive the store.
|
||||
cert_path: []const u8,
|
||||
/// Borrowed from the config; must outlive the store.
|
||||
key_path: []const u8,
|
||||
/// Owned. A settings apply replaces both paths, so a borrowed config slice
|
||||
/// would dangle the moment the row it came from went away. Read and
|
||||
/// written only under `reload_mutex`, which every apply and every reload
|
||||
/// holds for its whole read-build-publish sequence.
|
||||
cert_path: []u8,
|
||||
/// Owned; see `cert_path`.
|
||||
key_path: []u8,
|
||||
/// Passed through to every `ServerContext.init`; the same lifetime rule
|
||||
/// applies — a comptime-constant NULL-terminated array, never memory that
|
||||
/// can go away before the store.
|
||||
@@ -136,7 +139,8 @@ pub const CertStore = struct {
|
||||
/// Test seam: runs inside `reload` between a successful `load` and the
|
||||
/// publish, i.e. inside `reload_mutex`. Lets a test occupy the window
|
||||
/// where an unserialized reload could be overtaken. Must not call
|
||||
/// `reload` synchronously (that would self-deadlock on `reload_mutex`).
|
||||
/// `reload`, `pollOnce` or `preparePathChange` synchronously — all four
|
||||
/// take `reload_mutex`, which is not reentrant.
|
||||
after_load_hook: ?ReloadHook,
|
||||
|
||||
/// Set by the composition root right after `init`, with `diagnostics`.
|
||||
@@ -168,10 +172,17 @@ pub const CertStore = struct {
|
||||
alpn: ?[*:null]const ?[*:0]const u8,
|
||||
) ReloadError!CertStore {
|
||||
const first = try load(gpa, io, cert_path, key_path, alpn);
|
||||
errdefer {
|
||||
first.entry.ctx.deinit(gpa);
|
||||
gpa.destroy(first.entry);
|
||||
}
|
||||
const owned_cert = try gpa.dupe(u8, cert_path);
|
||||
errdefer gpa.free(owned_cert);
|
||||
const owned_key = try gpa.dupe(u8, key_path);
|
||||
return .{
|
||||
.gpa = gpa,
|
||||
.cert_path = cert_path,
|
||||
.key_path = key_path,
|
||||
.cert_path = owned_cert,
|
||||
.key_path = owned_key,
|
||||
.alpn = alpn,
|
||||
.mutex = .init,
|
||||
.reload_mutex = .init,
|
||||
@@ -193,6 +204,8 @@ pub const CertStore = struct {
|
||||
std.debug.assert(current.refs == 0);
|
||||
self.mutex.unlock(io);
|
||||
self.destroyEntry(current);
|
||||
self.gpa.free(self.cert_path);
|
||||
self.gpa.free(self.key_path);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
@@ -226,7 +239,14 @@ pub const CertStore = struct {
|
||||
pub fn reload(self: *CertStore, io: std.Io) ReloadError!void {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
defer self.reload_mutex.unlock(io);
|
||||
return self.reloadLocked(io);
|
||||
}
|
||||
|
||||
/// `reload`'s body, for callers that already hold `reload_mutex` —
|
||||
/// `pollOnce` does, because it must read `cert_path`/`key_path` under the
|
||||
/// same lock an apply replaces them under. `std.Io.Mutex` is not
|
||||
/// reentrant, so this exists rather than a recursive `reload` call.
|
||||
fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void {
|
||||
const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| {
|
||||
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
||||
return err;
|
||||
@@ -247,6 +267,93 @@ pub const CertStore = struct {
|
||||
self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic);
|
||||
}
|
||||
|
||||
/// A candidate certificate loaded from new paths, not yet published.
|
||||
/// Holding one means holding `reload_mutex`: exactly one of
|
||||
/// `publishPathChange` or `abortPathChange` must follow, and it releases
|
||||
/// the lock.
|
||||
pub const PreparedPaths = struct {
|
||||
cert_path: []u8,
|
||||
key_path: []u8,
|
||||
loaded: Loaded,
|
||||
/// Read at prepare so publish reads no clock: publish must touch
|
||||
/// nothing outside memory it already owns.
|
||||
loaded_at_unix: i64,
|
||||
};
|
||||
|
||||
/// Prepare half of a `doh_server`/`dot_server` cert-path change: takes
|
||||
/// `reload_mutex` and loads the certificate and key from the NEW paths.
|
||||
/// Nothing is published, so a failure leaves the store exactly as it was —
|
||||
/// the old certificate keeps serving and the caller writes no DB row. The
|
||||
/// lock is released on failure and held on success, which is what makes
|
||||
/// the whole apply serialized against `reload` and `pollOnce`.
|
||||
pub fn preparePathChange(
|
||||
self: *CertStore,
|
||||
io: std.Io,
|
||||
cert_path: []const u8,
|
||||
key_path: []const u8,
|
||||
) ReloadError!PreparedPaths {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
errdefer self.reload_mutex.unlock(io);
|
||||
|
||||
const owned_cert = try self.gpa.dupe(u8, cert_path);
|
||||
errdefer self.gpa.free(owned_cert);
|
||||
const owned_key = try self.gpa.dupe(u8, key_path);
|
||||
errdefer self.gpa.free(owned_key);
|
||||
|
||||
const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| {
|
||||
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
||||
return err;
|
||||
};
|
||||
return .{
|
||||
.cert_path = owned_cert,
|
||||
.key_path = owned_key,
|
||||
.loaded = next,
|
||||
.loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Publish half: infallible and I/O-free. The paths and the generation
|
||||
/// they were loaded from are installed together — the generation `mutex`
|
||||
/// is taken only for that swap, inside `reload_mutex`, the same lock order
|
||||
/// `reload` uses. Releases `reload_mutex`.
|
||||
///
|
||||
/// Connections that pinned the old generation finish on the old
|
||||
/// certificate; the old entry is freed once its last reader releases.
|
||||
pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
||||
const old_cert_path = self.cert_path;
|
||||
const old_key_path = self.key_path;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
const old = self.current;
|
||||
self.cert_path = prepared.cert_path;
|
||||
self.key_path = prepared.key_path;
|
||||
self.current = prepared.loaded.entry;
|
||||
self.loaded = prepared.loaded.sig;
|
||||
old.retired = true;
|
||||
const free_old = old.refs == 0;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// The branch runs before the counter bump, never after: a `bool` still
|
||||
// live across an atomic read-modify-write is the zig 0.16.0 Debug
|
||||
// miscompile AGENTS.md documents.
|
||||
if (free_old) self.destroyEntry(old);
|
||||
self.gpa.free(old_cert_path);
|
||||
self.gpa.free(old_key_path);
|
||||
|
||||
_ = self.reloads.fetchAdd(1, .monotonic);
|
||||
self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic);
|
||||
self.reload_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Discards a prepared candidate — the commit that would have published it
|
||||
/// failed, or a sibling owner's prepare did. Releases `reload_mutex`.
|
||||
pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
||||
self.gpa.free(prepared.cert_path);
|
||||
self.gpa.free(prepared.key_path);
|
||||
self.destroyEntry(prepared.loaded.entry);
|
||||
self.reload_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Sleep first: `init` just loaded the files this poll would compare
|
||||
/// against. `.boot` so a suspended box still sees the interval elapse.
|
||||
pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void {
|
||||
@@ -265,7 +372,15 @@ pub const CertStore = struct {
|
||||
/// changed, and the old one keeps serving either way. A failed reload
|
||||
/// warns and counts (`reload_failures`); the signature stays at the loaded
|
||||
/// pair, so every subsequent poll retries until the files parse.
|
||||
///
|
||||
/// The whole pass runs under `reload_mutex`: the paths it stats are the
|
||||
/// ones an apply replaces, and a poll that read a path outside the lock
|
||||
/// could stat a freed slice or reload a pair that was never published
|
||||
/// together.
|
||||
pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
defer self.reload_mutex.unlock(io);
|
||||
|
||||
const cert_sig = statSig(io, self.cert_path) catch |err| {
|
||||
log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path});
|
||||
self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err));
|
||||
@@ -290,7 +405,7 @@ pub const CertStore = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.reload(io)) {
|
||||
if (self.reloadLocked(io)) {
|
||||
log.info("certificate reloaded from {s}", .{self.cert_path});
|
||||
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
|
||||
} else |err| {
|
||||
@@ -993,3 +1108,154 @@ test "an unchanged poll closes the episode a transient stat failure opened" {
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path apply (milestone-34 S3.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "a bad candidate is refused at prepare and the store keeps serving" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const before = store.acquire(io);
|
||||
store.release(io, before);
|
||||
const before_paths_cert = store.cert_path;
|
||||
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" });
|
||||
var bad_buf: [128]u8 = undefined;
|
||||
const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path});
|
||||
|
||||
try testing.expectError(
|
||||
error.CertParse,
|
||||
store.preparePathChange(io, bad_path, env.key_path),
|
||||
);
|
||||
|
||||
// Nothing published: same generation, same paths, and `reload_mutex` was
|
||||
// released — a second prepare would deadlock otherwise.
|
||||
const after = store.acquire(io);
|
||||
store.release(io, after);
|
||||
try testing.expectEqual(before, after);
|
||||
try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr);
|
||||
try testing.expectEqualStrings(env.cert_path, store.cert_path);
|
||||
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
|
||||
|
||||
// A missing candidate path is refused the same way.
|
||||
try testing.expectError(
|
||||
error.CertUnreadable,
|
||||
store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path),
|
||||
);
|
||||
}
|
||||
|
||||
test "a published path change installs the new pair and retires the old generation" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
// A second, byte-different copy of the same valid pair under new names.
|
||||
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
||||
defer testing.allocator.free(grown);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
||||
|
||||
// A connection pinned to the old generation finishes on it.
|
||||
const pinned = store.acquire(io);
|
||||
|
||||
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
||||
store.publishPathChange(io, prepared);
|
||||
|
||||
try testing.expectEqualStrings(next_cert, store.cert_path);
|
||||
try testing.expectEqualStrings(next_key, store.key_path);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
||||
try testing.expect(pinned.retired);
|
||||
|
||||
const serving = store.acquire(io);
|
||||
try testing.expect(serving != pinned);
|
||||
store.release(io, serving);
|
||||
store.release(io, pinned);
|
||||
|
||||
// The watcher now measures the new pair, so an untouched pair polls clean
|
||||
// and a rewritten one reloads.
|
||||
store.pollOnce(io, 1_000);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem });
|
||||
store.pollOnce(io, 1_100);
|
||||
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
||||
}
|
||||
|
||||
test "an aborted path change frees the candidate and leaves the store untouched" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const before = store.acquire(io);
|
||||
store.release(io, before);
|
||||
|
||||
// The commit this candidate was built for failed; the testing allocator
|
||||
// proves the abort frees everything the prepare took.
|
||||
const prepared = try store.preparePathChange(io, env.cert_path, env.key_path);
|
||||
store.abortPathChange(io, prepared);
|
||||
|
||||
const after = store.acquire(io);
|
||||
store.release(io, after);
|
||||
try testing.expectEqual(before, after);
|
||||
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
||||
|
||||
// `reload_mutex` came back, so the store still reloads.
|
||||
try store.reload(io);
|
||||
}
|
||||
|
||||
test "a reload racing a path apply is serialized behind it" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
||||
defer testing.allocator.free(grown);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
||||
|
||||
// Prepare holds `reload_mutex` across the whole apply.
|
||||
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
||||
try testing.expect(!store.reload_mutex.tryLock());
|
||||
|
||||
// The concurrent reload cannot start, so it cannot publish the OLD paths
|
||||
// over the new generation.
|
||||
var racing = try io.concurrent(CertStore.reload, .{ &store, io });
|
||||
store.publishPathChange(io, prepared);
|
||||
try racing.await(io);
|
||||
|
||||
// Two publications, and the last word is the apply's pair: the racing
|
||||
// reload reread the paths the apply installed.
|
||||
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
||||
try testing.expectEqualStrings(next_cert, store.cert_path);
|
||||
store.mutex.lockUncancelable(io);
|
||||
const final = store.loaded;
|
||||
store.mutex.unlock(io);
|
||||
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
|
||||
}
|
||||
|
||||
+59
-18
@@ -27,6 +27,7 @@ const db = @import("../storage/db.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const events = @import("../storage/events.zig");
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const retention = @import("../storage/retention.zig");
|
||||
|
||||
const log = std.log.scoped(.clients);
|
||||
|
||||
@@ -59,7 +60,7 @@ pub const Tracker = struct {
|
||||
/// Guards `pending`, `count`, `passes` and `stats`. Every field below is
|
||||
/// written under it, so a reader takes it too; see `snapshotStats`.
|
||||
mutex: std.Io.Mutex,
|
||||
retention_days: u16,
|
||||
retention_days: *const retention.RetentionDays,
|
||||
pending: [max_pending]Pending,
|
||||
count: u32,
|
||||
passes: u64,
|
||||
@@ -70,8 +71,9 @@ pub const Tracker = struct {
|
||||
|
||||
/// `retention_days` is `logging.retention_days`, the same knob the query log
|
||||
/// prunes by (milestone-7 ruling 16). A client silent for that long is as
|
||||
/// uninteresting as a query that old.
|
||||
pub fn init(retention_days: u16) Tracker {
|
||||
/// uninteresting as a query that old — so both consumers share ONE cell
|
||||
/// and a settings apply moves them together.
|
||||
pub fn init(retention_days: *const retention.RetentionDays) Tracker {
|
||||
return .{
|
||||
.mutex = .init,
|
||||
.retention_days = retention_days,
|
||||
@@ -225,7 +227,7 @@ pub const Tracker = struct {
|
||||
self.mutex.unlock(io);
|
||||
|
||||
if (due) {
|
||||
const cutoff = now_s - @as(i64, self.retention_days) * 86_400;
|
||||
const cutoff = now_s - self.retention_days.seconds();
|
||||
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.stats.pruned += deleted;
|
||||
@@ -328,7 +330,8 @@ test "a client tracked twice before a flush yields one row at the later time" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
// A table with room reports no drops.
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
|
||||
@@ -354,7 +357,8 @@ test "distinct clients each get a row and ipv6 text is canonical" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||
_ = tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||
@@ -378,7 +382,8 @@ test "a full table drops further clients and counts them" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
for (0..Tracker.max_pending) |i| {
|
||||
var octets: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &octets, @intCast(i), .big);
|
||||
@@ -421,7 +426,8 @@ test "a flush touches a hand-edited row without changing what the operator set"
|
||||
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
|
||||
@@ -449,7 +455,8 @@ test "a gated pass writes nothing and keeps the pending clients" {
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
|
||||
|
||||
@@ -477,7 +484,8 @@ test "a failing upsert counts and leaves the client to be tracked again" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
@@ -507,7 +515,8 @@ test "the pass that comes due prunes the clients that went quiet" {
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
// Every pass before the due one leaves both rows alone.
|
||||
for (0..Tracker.prune_every_passes - 1) |_| {
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
@@ -534,7 +543,8 @@ test "a shorter retention prunes what the default keeps" {
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||
|
||||
var tracker: Tracker = .init(1);
|
||||
var days: retention.RetentionDays = .init(1);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
|
||||
@@ -542,6 +552,31 @@ test "a shorter retention prunes what the default keeps" {
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
}
|
||||
|
||||
test "setRetentionDays changes the cutoff the next prune pass uses" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||
|
||||
var days: retention.RetentionDays = .init(7);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
|
||||
// The shared cell, not a copy taken at construction.
|
||||
days.setRetentionDays(1);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
}
|
||||
|
||||
test "the run loop flushes on its interval and returns on cancel" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -550,7 +585,8 @@ test "the run loop flushes on its interval and returns on cancel" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
|
||||
var future = try io.concurrent(Tracker.run, .{
|
||||
@@ -622,7 +658,8 @@ test "the drain lands before any exchange, and attempts stop at the cap" {
|
||||
names.exchange_fn = CountingExchange.exchange;
|
||||
CountingExchange.reset(&database);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
const pending = clients_repo.max_per_pass + 4;
|
||||
for (0..pending) |i| {
|
||||
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
|
||||
@@ -656,7 +693,8 @@ test "a row the due pass prunes is never asked about" {
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, &names);
|
||||
|
||||
@@ -681,7 +719,8 @@ test "a gated pass attempts no naming either" {
|
||||
CountingExchange.reset(&database);
|
||||
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.flushOnce(io, &database, false, &names);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
|
||||
@@ -700,7 +739,8 @@ test "a failing materialise opens one episode per pass and a clean pass closes i
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.diagnostics = &fx.store;
|
||||
|
||||
try database.exec(
|
||||
@@ -742,7 +782,8 @@ test "a failing prune opens its own episode the next due pass closes" {
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.diagnostics = &fx.store;
|
||||
// One pass short of due, so the pass below is the pruning one.
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
|
||||
@@ -36,6 +36,7 @@ const listener = @import("listener.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const tls_server = @import("../platform/tls_server.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
pub const dns_query_path = "/dns-query";
|
||||
|
||||
@@ -619,6 +620,7 @@ const Harness = struct {
|
||||
store: cert_store.CertStore,
|
||||
tables: local_tables_mod.LocalTables,
|
||||
upstream: FailingUpstream,
|
||||
upstream_owner: upstream_owner.Borrowed,
|
||||
h: handler.Handler,
|
||||
server: DohServer,
|
||||
group: std.Io.Group,
|
||||
@@ -646,10 +648,10 @@ const Harness = struct {
|
||||
errdefer hx.tables.deinit(testing.allocator);
|
||||
|
||||
hx.upstream = .{};
|
||||
hx.upstream_owner = .{};
|
||||
hx.h = .{
|
||||
.upstream = hx.upstream.client(),
|
||||
.blocking = test_blocking,
|
||||
.forward_read_timeout = test_forward_timeout,
|
||||
.upstream = hx.upstream_owner.client(hx.upstream.client()),
|
||||
.policy = .{ .blocking = test_blocking, .forward_read_timeout = test_forward_timeout },
|
||||
.local_tables = &hx.tables,
|
||||
};
|
||||
|
||||
|
||||
+103
-9
@@ -28,6 +28,7 @@ const handler = @import("handler.zig");
|
||||
const listener = @import("listener.zig");
|
||||
const tls_server = @import("../platform/tls_server.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
|
||||
/// record tail pass through here, while whole messages go straight to
|
||||
@@ -312,11 +313,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -567,7 +567,8 @@ test "dot: two framed queries share one TLS connection" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -607,7 +608,8 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -645,7 +647,8 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -731,7 +734,8 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -759,6 +763,95 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
|
||||
};
|
||||
}
|
||||
|
||||
/// S3.4 across a live listener: a cert PATH change — new files, not rewritten
|
||||
/// ones — serves the new certificate on the next handshake while the
|
||||
/// connection pinned to the old generation finishes on it.
|
||||
fn dotPathChangeServesNewCert(io: std.Io, address_: std.Io.net.IpAddress, env: *CertEnv) anyerror!void {
|
||||
var first: TestTls = undefined;
|
||||
try first.connect(io, address_);
|
||||
defer first.close(io);
|
||||
|
||||
try first.sendQuery();
|
||||
try expectAnswersQuery(try first.readReply());
|
||||
|
||||
const old_entry = env.store.acquire(io);
|
||||
defer env.store.release(io, old_entry);
|
||||
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert2.pem", .data = fixtures.cert2_pem });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "key2.pem", .data = fixtures.key2_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert2.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key2.pem", .{env.tmp.sub_path});
|
||||
|
||||
const prepared = try env.store.preparePathChange(io, next_cert, next_key);
|
||||
env.store.publishPathChange(io, prepared);
|
||||
|
||||
const new_entry = env.store.acquire(io);
|
||||
defer env.store.release(io, new_entry);
|
||||
try testing.expect(old_entry != new_entry);
|
||||
|
||||
var second: TestTls = undefined;
|
||||
try second.connect(io, address_);
|
||||
defer second.close(io);
|
||||
try second.sendQuery();
|
||||
try expectAnswersQuery(try second.readReply());
|
||||
|
||||
try first.sendQuery();
|
||||
try expectAnswersQuery(try first.readReply());
|
||||
|
||||
try second.client.end();
|
||||
try second.net_writer.interface.flush();
|
||||
var second_tail: [1]u8 = undefined;
|
||||
try testing.expectError(error.EndOfStream, second.client.reader.readSliceAll(&second_tail));
|
||||
|
||||
try first.client.end();
|
||||
try first.net_writer.interface.flush();
|
||||
var first_tail: [1]u8 = undefined;
|
||||
try testing.expectError(error.EndOfStream, first.client.reader.readSliceAll(&first_tail));
|
||||
}
|
||||
|
||||
test "dot: a cert path change serves the new certificate on the next handshake" {
|
||||
const build_options = @import("build_options");
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var env: CertEnv = undefined;
|
||||
try env.init(io);
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, DotServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, dotPathChangeServesNewCert, .{ io, server_address, &env });
|
||||
|
||||
const stats = server.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 2), stats.connections);
|
||||
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
|
||||
|
||||
const store_stats = env.store.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 1), store_stats.reloads);
|
||||
try testing.expectEqual(@as(u64, 0), store_stats.reload_failures);
|
||||
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "dot: an idle connection is closed with close_notify and counted" {
|
||||
const build_options = @import("build_options");
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
@@ -773,7 +866,8 @@ test "dot: an idle connection is closed with close_notify and counted" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{
|
||||
|
||||
+532
-142
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ const forward_zones = @import("../local/forward_zones.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const local_tables = @import("local_tables.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const provenance = @import("../storage/provenance.zig");
|
||||
const manager = @import("../filter/manager.zig");
|
||||
@@ -47,8 +48,10 @@ const rate_limiter = @import("rate_limiter.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const shutdown = @import("shutdown.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
|
||||
@@ -79,11 +82,10 @@ const zone_ttl: u32 = 120;
|
||||
|
||||
/// The handler every case starts from: an upstream, the blocking options and
|
||||
/// the empty local tables. Each case wires in the collaborators it exercises.
|
||||
fn baseHandler(client: transport.Client) handler.Handler {
|
||||
fn baseHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +267,11 @@ fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||
.paths = undefined,
|
||||
.fetcher = undefined,
|
||||
.update = .{},
|
||||
.schedule_mutex = .init,
|
||||
.schedule_version = 0,
|
||||
.schedule_anchor_s = null,
|
||||
.schedule_event = .unset,
|
||||
.schedule_clock = .real,
|
||||
.total_budget = forward_timeout,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
@@ -317,10 +324,12 @@ test "S7 case 1: a blocked domain is answered with the zero address and logged"
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -374,7 +383,8 @@ test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -411,7 +421,8 @@ test "S7 case 3: a local record answers authoritatively without an upstream" {
|
||||
defer table.deinit(gpa);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
var tables: local_tables.LocalTables = .{ .records = table };
|
||||
h.local_tables = &tables;
|
||||
|
||||
@@ -479,12 +490,13 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
|
||||
defer cache.deinit();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
var tables: local_tables.LocalTables = .{ .zones = zones };
|
||||
h.local_tables = &tables;
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
h.policy.negative_ttl_max = 3600;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
@@ -533,12 +545,14 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
h.policy.negative_ttl_max = 3600;
|
||||
h.sink = &sink;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -622,10 +636,12 @@ test "S7 case 6: a cname into a blocked target blocks the original question" {
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -681,7 +697,8 @@ test "S7 case 7: safe search answers the original question with a cname to the t
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -733,10 +750,12 @@ test "S7 case 8: the third query inside the window is refused" {
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.limiter = &limiter;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -785,7 +804,8 @@ test "S7 case 9: pause lifts filtering and unpause restores it" {
|
||||
paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.pause = &paused;
|
||||
|
||||
@@ -831,10 +851,12 @@ test "S7 case 10: the querying client is materialised as a row" {
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var tracker: clients.Tracker = .init(30);
|
||||
var retention_days: retention_mod.RetentionDays = .init(30);
|
||||
var tracker: clients.Tracker = .init(&retention_days);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.tracker = &tracker;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
|
||||
@@ -13,24 +13,36 @@
|
||||
const std = @import("std");
|
||||
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const sse = @import("../web/sse.zig");
|
||||
|
||||
pub const QuerySink = struct {
|
||||
logger: *logger.Logger,
|
||||
/// The controller, not a `Logger`: `logging.query_log_buffer_max` can
|
||||
/// change while the server runs, and the generation a producer enqueues
|
||||
/// into has to be the one that is live at that moment.
|
||||
controller: *logger_controller.Controller,
|
||||
/// Null when `web.enabled` is false: nothing subscribes, so nothing needs
|
||||
/// a hub, and the DNS path pays one null check.
|
||||
hub: ?*sse.Hub,
|
||||
|
||||
pub fn init(query_logger: *logger.Logger, hub: ?*sse.Hub) QuerySink {
|
||||
return .{ .logger = query_logger, .hub = hub };
|
||||
pub fn init(controller: *logger_controller.Controller, hub: ?*sse.Hub) QuerySink {
|
||||
return .{ .controller = controller, .hub = hub };
|
||||
}
|
||||
|
||||
/// Transforms once, publishes, then enqueues. Never blocks the query path
|
||||
/// and never fails: both consumers drop rather than wait.
|
||||
///
|
||||
/// The borrow spans both halves. A resize that lands between them would
|
||||
/// otherwise leave this entry going into a queue retirement has already
|
||||
/// closed, and that is exactly the drop window the controller exists to
|
||||
/// make impossible.
|
||||
pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void {
|
||||
const transformed = self.logger.transformed(entry);
|
||||
const generation = self.controller.acquire(io);
|
||||
defer self.controller.release(io, generation);
|
||||
|
||||
const transformed = generation.logger.transformed(entry);
|
||||
if (self.hub) |hub| hub.publish(io, transformed);
|
||||
self.logger.logTransformed(io, transformed);
|
||||
generation.logger.logTransformed(io, transformed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,7 +72,8 @@ test "the sink publishes and logs the same entry" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -87,7 +100,8 @@ test "fanout does not depend on the entry reaching the queue" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -115,7 +129,8 @@ test "the privacy transforms run once, before both consumers" {
|
||||
.{ .hide_domains = true, .hide_client_ips = true },
|
||||
&queue_buf,
|
||||
);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -138,7 +153,8 @@ test "a sink without a hub still logs" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, null);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), null);
|
||||
|
||||
sink.log(io, sampleEntry(5, "nohub.example"));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const types = @import("../dns/types.zig");
|
||||
const health = @import("../upstream/health.zig");
|
||||
const pool = @import("../upstream/pool.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -42,11 +43,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listeners and the pool are what this test exercises, so the handler is the
|
||||
/// same bare one its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +275,9 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
|
||||
};
|
||||
var upstreams: pool.Pool = .init(&entries, test_cfg, pool_timeouts, 1);
|
||||
|
||||
var h = bareHandler(upstreams.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
|
||||
var h = bareHandler(h_owner.client(upstreams.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -21,6 +21,7 @@ const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -37,11 +38,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ test "two length-prefixed queries share one connection" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
@@ -205,7 +206,8 @@ test "the claimed slot records the connecting client" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
@@ -282,7 +284,8 @@ test "a canceled serve does not wait for a live connection" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
// The idle budget is the whole time a drain would have to wait out, so it
|
||||
@@ -344,7 +347,8 @@ test "an idle connection is closed and counted" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -377,7 +381,8 @@ test "a zero-length message is a connection error" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -429,7 +434,8 @@ test "deinit ends a serve loop that is blocked on accept" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
|
||||
@@ -20,6 +20,7 @@ const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -36,11 +37,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ test "a udp query is answered on the loopback" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -150,7 +151,8 @@ test "a runt datagram is dropped and no reply is sent" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -185,7 +187,8 @@ test "an oversize datagram arrives truncated and is dropped" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -226,7 +229,8 @@ test "deinit ends a serve loop that is blocked on receive" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -33,11 +33,65 @@ pub fn classify(free_bytes: u64, cfg: model.Disk) State {
|
||||
return .ok;
|
||||
}
|
||||
|
||||
/// `min_free_mb` and `warn_free_mb` are one invariant pair — `classify` reads
|
||||
/// both and reports the more severe verdict — so they live in one atomic word
|
||||
/// and a reader unpacks a single load. Two atomics would let a sample land
|
||||
/// between the two stores and classify against half of one configuration and
|
||||
/// half of another.
|
||||
fn packThresholds(d: model.Disk) u64 {
|
||||
return (@as(u64, d.min_free_mb) << 32) | d.warn_free_mb;
|
||||
}
|
||||
|
||||
fn unpackThresholds(bits: u64) model.Disk {
|
||||
return .{
|
||||
.min_free_mb = @truncate(bits >> 32),
|
||||
.warn_free_mb = @truncate(bits),
|
||||
};
|
||||
}
|
||||
|
||||
/// Where the measured log directory comes from. `sample` borrows the path
|
||||
/// across a directory scan, so the path cannot simply be replaced under it: a
|
||||
/// reader pins a generation for the whole borrow and `setLogDir` retires the
|
||||
/// old one, which is freed by whichever of the two — the last reader or the
|
||||
/// setter — finds it retired with no refs.
|
||||
pub const LogDirSource = union(enum) {
|
||||
/// The path `init` was given, borrowed from the config. It outlives the
|
||||
/// process, so a reader still holding it after a swap is safe and it needs
|
||||
/// no pin. Null means logs do not go to a file.
|
||||
boot: ?[:0]const u8,
|
||||
/// Every generation `setLogDir` installs. Null means the same as above.
|
||||
installed: ?*LogDir,
|
||||
};
|
||||
|
||||
/// A reader's hold on the log directory for the length of one scan. `pinned`
|
||||
/// is null for the boot source, which nothing frees.
|
||||
pub const LogDirBorrow = struct {
|
||||
path: ?[:0]const u8,
|
||||
pinned: ?*LogDir,
|
||||
};
|
||||
|
||||
pub const LogDir = struct {
|
||||
path: [:0]const u8,
|
||||
refs: u32 = 0,
|
||||
retired: bool = false,
|
||||
/// Non-null exactly for heap generations, and the allocator that frees
|
||||
/// them.
|
||||
gpa: ?std.mem.Allocator = null,
|
||||
|
||||
fn destroy(self: *LogDir) void {
|
||||
const gpa = self.gpa orelse return;
|
||||
gpa.free(self.path);
|
||||
gpa.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Monitor = struct {
|
||||
cfg: model.Disk,
|
||||
thresholds_packed: std.atomic.Value(u64),
|
||||
data_dir: std.Io.Dir,
|
||||
data_path: [:0]const u8,
|
||||
log_dir_path: ?[:0]const u8,
|
||||
/// Guards `log_dir` and every generation's `refs`/`retired`.
|
||||
log_dir_mutex: std.Io.Mutex,
|
||||
log_dir: LogDirSource,
|
||||
|
||||
state_raw: std.atomic.Value(u8),
|
||||
free_bytes: std.atomic.Value(u64),
|
||||
@@ -57,10 +111,11 @@ pub const Monitor = struct {
|
||||
log_dir_path: ?[:0]const u8,
|
||||
) Monitor {
|
||||
return .{
|
||||
.cfg = cfg,
|
||||
.thresholds_packed = .init(packThresholds(cfg)),
|
||||
.data_dir = data_dir,
|
||||
.data_path = data_path,
|
||||
.log_dir_path = log_dir_path,
|
||||
.log_dir_mutex = .init,
|
||||
.log_dir = .{ .boot = log_dir_path },
|
||||
.state_raw = .init(@intFromEnum(State.ok)),
|
||||
.free_bytes = .init(0),
|
||||
.db_bytes = .init(0),
|
||||
@@ -69,6 +124,90 @@ pub const Monitor = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// The live threshold pair, from one load: `warn >= min` holds for every
|
||||
/// value this ever returns, whatever a concurrent `setThresholds` does.
|
||||
pub fn thresholds(self: *const Monitor) model.Disk {
|
||||
return unpackThresholds(self.thresholds_packed.load(.monotonic));
|
||||
}
|
||||
|
||||
pub fn setThresholds(self: *Monitor, d: model.Disk) void {
|
||||
self.thresholds_packed.store(packThresholds(d), .monotonic);
|
||||
}
|
||||
|
||||
/// Pins the log directory for one scan. Every borrow is matched by a
|
||||
/// `releaseLogDir`, which is what lets `setLogDir` free a generation the
|
||||
/// moment no scan is reading its path.
|
||||
pub fn acquireLogDir(self: *Monitor, io: std.Io) LogDirBorrow {
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
defer self.log_dir_mutex.unlock(io);
|
||||
|
||||
switch (self.log_dir) {
|
||||
.boot => |path| return .{ .path = path, .pinned = null },
|
||||
.installed => |maybe| {
|
||||
const gen = maybe orelse return .{ .path = null, .pinned = null };
|
||||
gen.refs += 1;
|
||||
return .{ .path = gen.path, .pinned = gen };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn releaseLogDir(self: *Monitor, io: std.Io, borrow: LogDirBorrow) void {
|
||||
const gen = borrow.pinned orelse return;
|
||||
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
std.debug.assert(gen.refs > 0);
|
||||
gen.refs -= 1;
|
||||
const free_it = gen.retired and gen.refs == 0;
|
||||
self.log_dir_mutex.unlock(io);
|
||||
|
||||
if (free_it) gen.destroy();
|
||||
}
|
||||
|
||||
/// Prepare half of a log-directory change: allocates the owned path and
|
||||
/// its generation node before any commit, so publish cannot fail. `path`
|
||||
/// null means logs no longer go to a file and nothing is measured.
|
||||
pub fn prepareLogDir(
|
||||
gpa: std.mem.Allocator,
|
||||
path: ?[]const u8,
|
||||
) std.mem.Allocator.Error!?*LogDir {
|
||||
const p = path orelse return null;
|
||||
const owned = try gpa.dupeZ(u8, p);
|
||||
errdefer gpa.free(owned);
|
||||
const gen = try gpa.create(LogDir);
|
||||
gen.* = .{ .path = owned, .gpa = gpa };
|
||||
return gen;
|
||||
}
|
||||
|
||||
/// Discards a generation `prepareLogDir` built that will not be published.
|
||||
pub fn destroyPreparedLogDir(prepared: ?*LogDir) void {
|
||||
if (prepared) |gen| gen.destroy();
|
||||
}
|
||||
|
||||
/// Publish half: infallible and I/O-free. The old generation is retired
|
||||
/// and freed here when no scan holds it, or by the last release otherwise.
|
||||
pub fn setLogDir(self: *Monitor, io: std.Io, prepared: ?*LogDir) void {
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
const old: ?*LogDir = switch (self.log_dir) {
|
||||
.boot => null,
|
||||
.installed => |maybe| maybe,
|
||||
};
|
||||
self.log_dir = .{ .installed = prepared };
|
||||
var free_old = false;
|
||||
if (old) |gen| {
|
||||
gen.retired = true;
|
||||
free_old = gen.refs == 0;
|
||||
}
|
||||
self.log_dir_mutex.unlock(io);
|
||||
|
||||
if (free_old) old.?.destroy();
|
||||
}
|
||||
|
||||
/// Frees any installed log-directory generation. Every scan must have
|
||||
/// released first, which shutdown ordering guarantees.
|
||||
pub fn deinit(self: *Monitor, io: std.Io) void {
|
||||
self.setLogDir(io, null);
|
||||
}
|
||||
|
||||
pub fn state(self: *const Monitor) State {
|
||||
return @enumFromInt(self.state_raw.load(.monotonic));
|
||||
}
|
||||
@@ -109,7 +248,9 @@ pub const Monitor = struct {
|
||||
probeFailed(store, io, now_s, "data_dir", "sizing the data directory failed", err);
|
||||
}
|
||||
|
||||
if (self.log_dir_path) |path| {
|
||||
const borrow = self.acquireLogDir(io);
|
||||
defer self.releaseLogDir(io, borrow);
|
||||
if (borrow.path) |path| {
|
||||
if (self.sumLogDir(io, path)) |bytes| {
|
||||
self.log_bytes.store(bytes, .monotonic);
|
||||
if (store) |s| s.resolve(io, now_s, .disk_probe, "log_dir");
|
||||
@@ -120,7 +261,7 @@ pub const Monitor = struct {
|
||||
}
|
||||
}
|
||||
|
||||
self.publish(io, store, now_s, classify(free, self.cfg), free);
|
||||
self.publish(io, store, now_s, classify(free, self.thresholds()), free);
|
||||
}
|
||||
|
||||
/// Sample first, then sleep: a process that starts on a full disk must not
|
||||
@@ -466,7 +607,7 @@ test "a threshold above the real free space drives the state to critical" {
|
||||
try testing.expectEqual(State.critical, monitor.state());
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
@@ -509,7 +650,7 @@ test "a disk transition records an episode per severity and closes it on recover
|
||||
monitor.sample(io, &fx.store, 1060);
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, &fx.store, 1120);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expectEqual(
|
||||
@@ -551,7 +692,9 @@ test "a failed probe opens an episode the next clean pass closes" {
|
||||
|
||||
try tmp.dir.createDirPath(io, "logs");
|
||||
var path_buf: [256]u8 = undefined;
|
||||
monitor.log_dir_path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
const good_dir = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, good_dir));
|
||||
defer monitor.deinit(io);
|
||||
monitor.sample(io, &fx.store, 1100);
|
||||
|
||||
try testing.expectEqual(
|
||||
@@ -560,6 +703,37 @@ test "a failed probe opens an episode the next clean pass closes" {
|
||||
);
|
||||
}
|
||||
|
||||
/// Alternates between two pairs that each satisfy `warn >= min`, so any
|
||||
/// observed pair violating it can only have been torn out of two stores.
|
||||
fn storeThresholdPairs(monitor: *Monitor, rounds: usize) void {
|
||||
for (0..rounds) |i| {
|
||||
monitor.setThresholds(if (i % 2 == 0)
|
||||
.{ .min_free_mb = 1, .warn_free_mb = 2 }
|
||||
else
|
||||
.{ .min_free_mb = 3_000_000, .warn_free_mb = 4_000_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test "a threshold reader never observes a pair from two different stores" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 1, .warn_free_mb = 2 }, std.Io.Dir.cwd(), ".", null);
|
||||
|
||||
const rounds = 20_000;
|
||||
var writer = try io.concurrent(storeThresholdPairs, .{ &monitor, rounds });
|
||||
// Recorded, not asserted, while the writer runs: an assertion that returned
|
||||
// here would leave `Threaded.deinit` joining a task nothing ends.
|
||||
var torn = false;
|
||||
for (0..rounds) |_| {
|
||||
const pair = monitor.thresholds();
|
||||
if (pair.warn_free_mb < pair.min_free_mb) torn = true;
|
||||
}
|
||||
writer.await(io);
|
||||
try testing.expect(!torn);
|
||||
}
|
||||
|
||||
test "every emit site is inert when the store is absent" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -574,3 +748,89 @@ test "every emit site is inert when the store is absent" {
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setLogDir (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "setLogDir re-points the measurement and frees the retired generation" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(io, "first");
|
||||
try tmp.dir.createDirPath(io, "second");
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "first/nxdns.log", .data = "aaaa" });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "second/nxdns.log", .data = "bbbbbbbb" });
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
const first = try std.fmt.bufPrint(&first_buf, ".zig-cache/tmp/{s}/first", .{tmp.sub_path});
|
||||
const second = try std.fmt.bufPrint(&second_buf, ".zig-cache/tmp/{s}/second", .{tmp.sub_path});
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
|
||||
defer monitor.deinit(io);
|
||||
|
||||
// Boot measures nothing.
|
||||
monitor.sample(io, null, 1_000);
|
||||
try testing.expectEqual(@as(u64, 0), monitor.gauges().log_bytes);
|
||||
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, first));
|
||||
monitor.sample(io, null, 1_100);
|
||||
try testing.expectEqual(@as(u64, 4), monitor.gauges().log_bytes);
|
||||
|
||||
// The retired generation is freed here; the testing allocator says so.
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, second));
|
||||
monitor.sample(io, null, 1_200);
|
||||
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
|
||||
|
||||
// Output moved away from file: nothing is measured, and the gauge keeps
|
||||
// its last reading rather than claiming zero bytes of logs.
|
||||
monitor.setLogDir(io, null);
|
||||
monitor.sample(io, null, 1_300);
|
||||
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
|
||||
}
|
||||
|
||||
test "a prepared log directory that is never published is freed by the caller" {
|
||||
const prepared = try Monitor.prepareLogDir(testing.allocator, "/var/log/nxdns");
|
||||
Monitor.destroyPreparedLogDir(prepared);
|
||||
try testing.expectEqual(@as(?*LogDir, null), try Monitor.prepareLogDir(testing.allocator, null));
|
||||
}
|
||||
|
||||
test "a sample borrowing a log directory survives a concurrent setLogDir" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(io, "logs");
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "logs/nxdns.log", .data = "aaaa" });
|
||||
|
||||
var path_buf: [160]u8 = undefined;
|
||||
const logs = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
|
||||
defer monitor.deinit(io);
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, logs));
|
||||
|
||||
const Racer = struct {
|
||||
fn sample(m: *Monitor, sio: std.Io) void {
|
||||
for (0..200) |i| m.sample(sio, null, @intCast(1_000 + i));
|
||||
}
|
||||
fn repoint(m: *Monitor, sio: std.Io, p: []const u8) void {
|
||||
for (0..200) |i| {
|
||||
const prepared = Monitor.prepareLogDir(testing.allocator, if (i % 2 == 0) p else null) catch return;
|
||||
m.setLogDir(sio, prepared);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, Racer.sample, .{ &monitor, io });
|
||||
try group.concurrent(io, Racer.repoint, .{ &monitor, io, logs });
|
||||
try group.await(io);
|
||||
}
|
||||
|
||||
+192
-6
@@ -399,8 +399,23 @@ const discard_stall = if (builtin.is_test) struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// The §11.4 privacy policy: one value, never two. A producer decides the
|
||||
/// domain fields and the client field from ONE load of `privacy_packed`, so no
|
||||
/// entry can leave `transformed` with the domain redacted and the client
|
||||
/// exposed, or the reverse, because a `setPrivacy` landed between the two
|
||||
/// decisions.
|
||||
pub const Privacy = packed struct(u8) {
|
||||
hide_domains: bool = false,
|
||||
hide_client_ips: bool = false,
|
||||
_reserved: u6 = 0,
|
||||
};
|
||||
|
||||
pub const Logger = struct {
|
||||
cfg: model.Logging,
|
||||
/// Both privacy flags in one atomic byte, loaded once per entry.
|
||||
privacy_packed: std.atomic.Value(u8),
|
||||
/// Independent of the privacy policy: it governs when a batch commits, not
|
||||
/// what a row contains, so nothing pairs the two.
|
||||
flush_interval_s: std.atomic.Value(u16),
|
||||
queue: EntryQueue,
|
||||
queries_dropped: std.atomic.Value(u64),
|
||||
/// When the newest drop happened, in unix seconds; 0 means none yet. Read
|
||||
@@ -434,7 +449,11 @@ pub const Logger = struct {
|
||||
/// touched it.
|
||||
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
|
||||
return .{
|
||||
.cfg = cfg,
|
||||
.privacy_packed = .init(@bitCast(Privacy{
|
||||
.hide_domains = cfg.hide_domains,
|
||||
.hide_client_ips = cfg.hide_client_ips,
|
||||
})),
|
||||
.flush_interval_s = .init(cfg.query_log_flush_interval_s),
|
||||
.queue = .init(queue_buf),
|
||||
.queries_dropped = .init(0),
|
||||
.last_drop_s = .init(0),
|
||||
@@ -464,8 +483,9 @@ pub const Logger = struct {
|
||||
/// configuration labels the operator wrote, identical on every row that
|
||||
/// hits them, and they say nothing about which name a client looked up.
|
||||
pub fn transformed(self: *const Logger, entry: Entry) Entry {
|
||||
const policy = self.privacy();
|
||||
var out = entry;
|
||||
if (self.cfg.hide_domains) {
|
||||
if (policy.hide_domains) {
|
||||
out.setDomain(hidden_marker);
|
||||
// Only where there is something to hide: an empty field means the
|
||||
// query had no such value, and writing a marker would claim it did.
|
||||
@@ -473,10 +493,24 @@ pub const Logger = struct {
|
||||
if (out.cname_len != 0) out.setCnameTarget(hidden_marker);
|
||||
if (out.safe_search_len != 0) out.setSafeSearchTarget(hidden_marker);
|
||||
}
|
||||
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
if (policy.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The live policy, from one load. Every producer decision about one entry
|
||||
/// must come from a single call to this.
|
||||
pub fn privacy(self: *const Logger) Privacy {
|
||||
return @bitCast(self.privacy_packed.load(.monotonic));
|
||||
}
|
||||
|
||||
pub fn setPrivacy(self: *Logger, p: Privacy) void {
|
||||
self.privacy_packed.store(@bitCast(p), .monotonic);
|
||||
}
|
||||
|
||||
pub fn setFlushInterval(self: *Logger, seconds: u16) void {
|
||||
self.flush_interval_s.store(seconds, .monotonic);
|
||||
}
|
||||
|
||||
/// `log` without the transforms, for a caller that already applied them.
|
||||
pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
|
||||
self.enqueue(io, entry);
|
||||
@@ -540,7 +574,22 @@ pub const Logger = struct {
|
||||
return;
|
||||
};
|
||||
defer writer.deinit();
|
||||
return self.runPrepared(io, &writer, monitor);
|
||||
}
|
||||
|
||||
/// The writer loop over statements someone else prepared.
|
||||
///
|
||||
/// `runWriter` prepares and then calls this. A logger generation created by
|
||||
/// a resize prepares separately, before anything is published, so that a
|
||||
/// statement failure is refused at prepare time instead of silently killing
|
||||
/// the writer of a queue producers are already filling
|
||||
/// (`logger_controller.zig`).
|
||||
pub fn runPrepared(
|
||||
self: *Logger,
|
||||
io: std.Io,
|
||||
writer: *queries_repo.BatchWriter,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
var batch: [flush_batch]Entry = undefined;
|
||||
while (true) {
|
||||
// A closed queue hands over its buffered elements before it reports
|
||||
@@ -564,7 +613,7 @@ pub const Logger = struct {
|
||||
self.countDropped(io, n, at);
|
||||
return err;
|
||||
};
|
||||
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
|
||||
self.flush(io, writer, batch[0..n], monitor) catch |err| switch (err) {
|
||||
error.Canceled => |e| {
|
||||
self.countDropped(io, n, at);
|
||||
return e;
|
||||
@@ -606,7 +655,7 @@ pub const Logger = struct {
|
||||
/// returns without waiting for anything.
|
||||
fn flushDeadline(self: *const Logger, io: std.Io) std.Io.Clock.Timestamp {
|
||||
return .fromNow(io, .{
|
||||
.raw = .fromSeconds(self.cfg.query_log_flush_interval_s),
|
||||
.raw = .fromSeconds(self.flush_interval_s.load(.monotonic)),
|
||||
.clock = .boot,
|
||||
});
|
||||
}
|
||||
@@ -644,6 +693,18 @@ pub const Logger = struct {
|
||||
self.draining.store(true, .release);
|
||||
}
|
||||
|
||||
/// `shutdown` without `draining`: this generation is being replaced, not
|
||||
/// the process stopped.
|
||||
///
|
||||
/// The flag is what turns a gate-held batch into a counted loss
|
||||
/// (`flush`'s `GatedAtShutdown`), and a retired writer must not take that
|
||||
/// path — the disk can still recover, and the rows it is holding are still
|
||||
/// going to be written when it does. Every producer of this generation
|
||||
/// must have released it before the close, exactly as at shutdown.
|
||||
pub fn retire(self: *Logger, io: std.Io) void {
|
||||
self.queue.close(io);
|
||||
}
|
||||
|
||||
/// Fills `batch` behind the entry already in slot 0, until it is full or
|
||||
/// `deadline` passes. `n` counts the slots that hold an entry, and stays
|
||||
/// accurate on the cancellation path so the caller can count what is lost.
|
||||
@@ -1240,6 +1301,80 @@ test "log hides only the field its switch names" {
|
||||
try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
|
||||
}
|
||||
|
||||
/// The rendezvous that forces the flip to land BETWEEN two producer entries
|
||||
/// rather than whenever the scheduler feels like it.
|
||||
const PrivacyFlip = struct {
|
||||
logger: *Logger,
|
||||
/// Set by the producer once its pre-flip entry is enqueued.
|
||||
before_done: std.Io.Event = .unset,
|
||||
/// Set by the flipper once `setPrivacy` has returned.
|
||||
flipped: std.Io.Event = .unset,
|
||||
|
||||
fn run(self: *PrivacyFlip, io: std.Io) void {
|
||||
self.before_done.wait(io) catch return;
|
||||
self.logger.setPrivacy(.{ .hide_domains = true, .hide_client_ips = true });
|
||||
self.flipped.set(io);
|
||||
}
|
||||
};
|
||||
|
||||
test "a privacy flip redacts every entry after it and none before it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
var flip: PrivacyFlip = .{ .logger = &logger };
|
||||
|
||||
var future = try io.concurrent(PrivacyFlip.run, .{ &flip, io });
|
||||
logger.log(io, sampleEntry(1, "before.example"));
|
||||
flip.before_done.set(io);
|
||||
try flip.flipped.wait(io);
|
||||
logger.log(io, sampleEntry(2, "after.example"));
|
||||
future.await(io);
|
||||
|
||||
const before = try logger.queue.getOne(io);
|
||||
try testing.expectEqualStrings("before.example", before.domain());
|
||||
try testing.expectEqualStrings("192.0.2.10", before.clientIp());
|
||||
|
||||
const after = try logger.queue.getOne(io);
|
||||
try testing.expectEqualStrings(hidden_marker, after.domain());
|
||||
try testing.expectEqualStrings(hidden_marker, after.clientIp());
|
||||
}
|
||||
|
||||
fn flipPrivacyRepeatedly(logger: *Logger, rounds: usize) void {
|
||||
for (0..rounds) |i| {
|
||||
logger.setPrivacy(if (i % 2 == 0)
|
||||
.{}
|
||||
else
|
||||
.{ .hide_domains = true, .hide_client_ips = true });
|
||||
}
|
||||
}
|
||||
|
||||
test "no producer observes a privacy policy that redacts one field and not the other" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
const source = sampleEntry(1, "tracker.example");
|
||||
|
||||
const rounds = 20_000;
|
||||
var flipper = try io.concurrent(flipPrivacyRepeatedly, .{ &logger, rounds });
|
||||
// Recorded, not asserted, while the flipper runs: an assertion that
|
||||
// returned here would leave `Threaded.deinit` joining a task nothing ends.
|
||||
var mixed = false;
|
||||
for (0..rounds) |_| {
|
||||
const out = logger.transformed(source);
|
||||
const domain_hidden = std.mem.eql(u8, out.domain(), hidden_marker);
|
||||
const client_hidden = std.mem.eql(u8, out.clientIp(), hidden_marker);
|
||||
if (domain_hidden != client_hidden) mixed = true;
|
||||
}
|
||||
flipper.await(io);
|
||||
try testing.expect(!mixed);
|
||||
}
|
||||
|
||||
test "the split halves reproduce log byte for byte" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -1507,6 +1642,57 @@ test "a full batch flushes without waiting for the interval" {
|
||||
try testing.expectEqual(@as(i64, 150), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "the writer's next cycle uses the interval set since its last one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var buf: [8]Entry = undefined;
|
||||
// Zero: every cycle commits what it has and goes straight back to `getOne`,
|
||||
// so the first entry proves the writer is running and parked between cycles.
|
||||
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
|
||||
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
logger.log(io, sampleEntry(1, "first.example"));
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
while (logger.rows_written.load(.monotonic) == 0 and waited < 400) : (waited += 1) {
|
||||
try poll.sleep(io);
|
||||
}
|
||||
|
||||
// An hour, installed while the writer is parked: the cycle the next entry
|
||||
// starts must wait it out instead of committing at once.
|
||||
logger.setFlushInterval(3600);
|
||||
logger.log(io, sampleEntry(2, "second.example"));
|
||||
const quarter: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(250), .clock = .awake };
|
||||
try quarter.sleep(io);
|
||||
const written_under_the_new_interval = logger.rows_written.load(.monotonic);
|
||||
|
||||
logger.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
try testing.expect(waited < 400);
|
||||
// The first entry, and only the first: the second is still held.
|
||||
try testing.expectEqual(@as(u64, 1), written_under_the_new_interval);
|
||||
// The close releases it, which is what makes the hold a hold and not a loss.
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "a gated flush holds the batch until the disk recovers" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -406,7 +406,8 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
|
||||
try testing.expectEqual(@as(i64, 4), try queries_repo.countRows(log_db.database()));
|
||||
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
|
||||
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
var pass_days: retention.RetentionDays = .init(30);
|
||||
var pass: retention.Retention = .init(&pass_days);
|
||||
pass.runOnce(io, log_db.database(), null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||
@@ -473,7 +474,7 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
try testing.expectEqual(@as(u64, 0), query_log.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(disk_monitor.State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
|
||||
@@ -199,6 +199,29 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `open` has already
|
||||
/// established, with the pragmas every connection to the file needs.
|
||||
///
|
||||
/// The canonical opener for every background connection: the log writer, the
|
||||
/// retention pass and the web task each own one (`retention.zig`'s contract),
|
||||
/// and a logger generation opens one per writer for that writer's whole life
|
||||
/// (`logger_controller.zig`) — two writers must never share a handle.
|
||||
///
|
||||
/// `dir` and `path` follow `open`'s resolution rule, and `dir` participates in
|
||||
/// it the same way: the caller passes either an absolute path with `dir` open
|
||||
/// on its parent, or `std.Io.Dir.cwd()` with a cwd-relative path. Nothing here
|
||||
/// touches the directory itself — the file already exists by contract — so the
|
||||
/// handle is present to make the pairing explicit at every call site rather
|
||||
/// than to be dereferenced.
|
||||
pub fn reopen(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) db.Error!db.Db {
|
||||
_ = io;
|
||||
_ = dir;
|
||||
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
}
|
||||
|
||||
/// The whitelist. `null` means "propagate, do not touch the file".
|
||||
fn recreatable(e: db.Error) ?RecreateReason {
|
||||
return switch (e) {
|
||||
|
||||
+77
-17
@@ -14,7 +14,6 @@ const std = @import("std");
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const events = @import("events.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
|
||||
const log = std.log.scoped(.retention);
|
||||
@@ -50,15 +49,42 @@ const Counters = struct {
|
||||
vacuums_gated: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// `logging.retention_days`, shared by its two consumers — this pass and
|
||||
/// `server/clients.zig`'s stale-client prune. One cell rather than a copy in
|
||||
/// each: the two must never prune to different cutoffs, and a settings apply
|
||||
/// stores once. Owned by app-level state and outlives both readers.
|
||||
///
|
||||
/// `.monotonic` is enough: the value stands alone and orders nothing else, and
|
||||
/// each consumer reads it once per pass.
|
||||
pub const RetentionDays = struct {
|
||||
value: std.atomic.Value(u32),
|
||||
|
||||
pub fn init(days: u16) RetentionDays {
|
||||
return .{ .value = .init(days) };
|
||||
}
|
||||
|
||||
pub fn get(self: *const RetentionDays) u32 {
|
||||
return self.value.load(.monotonic);
|
||||
}
|
||||
|
||||
pub fn setRetentionDays(self: *RetentionDays, days: u16) void {
|
||||
self.value.store(days, .monotonic);
|
||||
}
|
||||
|
||||
pub fn seconds(self: *const RetentionDays) i64 {
|
||||
return @as(i64, self.get()) * std.time.s_per_day;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Retention = struct {
|
||||
cfg: model.Logging,
|
||||
days: *const RetentionDays,
|
||||
counters: Counters,
|
||||
/// Passes since the last vacuum that succeeded. Plain rather than atomic:
|
||||
/// only the retention task reads or writes it, and no consumer reports it.
|
||||
passes_since_vacuum: u32,
|
||||
|
||||
pub fn init(cfg: model.Logging) Retention {
|
||||
return .{ .cfg = cfg, .counters = .{}, .passes_since_vacuum = 0 };
|
||||
pub fn init(days: *const RetentionDays) Retention {
|
||||
return .{ .days = days, .counters = .{}, .passes_since_vacuum = 0 };
|
||||
}
|
||||
|
||||
/// The counters, read one at a time. A scrape that lands mid-pass can see a
|
||||
@@ -100,7 +126,7 @@ pub const Retention = struct {
|
||||
) void {
|
||||
add(&self.counters.passes, 1);
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const cutoff = now - model.retentionSeconds(self.cfg);
|
||||
const cutoff = now - self.days.seconds();
|
||||
|
||||
// Diagnostics retention rides this pass rather than a schedule of its
|
||||
// own: one daily housekeeping task, and a box restarted every night
|
||||
@@ -268,7 +294,8 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
|
||||
const day = 86_400;
|
||||
try writeRows(&database, &.{ now - 40 * day, now - 31 * day, now - 29 * day, now - 60 });
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
@@ -292,17 +319,41 @@ test "the cutoff follows retention_days" {
|
||||
// window of the other.
|
||||
try writeRows(&database, &.{now - 3 * day});
|
||||
|
||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||
var keeps_days: RetentionDays = .init(7);
|
||||
var keeps: Retention = .init(&keeps_days);
|
||||
keeps.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
|
||||
|
||||
var prunes: Retention = .init(.{ .retention_days = 1 });
|
||||
var prunes_days: RetentionDays = .init(1);
|
||||
var prunes: Retention = .init(&prunes_days);
|
||||
prunes.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "setRetentionDays changes the cutoff the next pass prunes by" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try writeRows(&database, &.{now - 3 * 86_400});
|
||||
|
||||
var days: RetentionDays = .init(7);
|
||||
var pass: Retention = .init(&days);
|
||||
pass.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
|
||||
days.setRetentionDays(1);
|
||||
pass.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "the seventh pass vacuums and the six before it do not" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -311,7 +362,8 @@ test "the seventh pass vacuums and the six before it do not" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..6) |_| {
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
@@ -340,7 +392,8 @@ test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var gated: Retention = .init(.{});
|
||||
var gated_days: RetentionDays = .init(30);
|
||||
var gated: Retention = .init(&gated_days);
|
||||
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor, null);
|
||||
|
||||
// Prune and checkpoint ran on every pass; only the vacuum was refused.
|
||||
@@ -371,7 +424,8 @@ test "a warn state still allows the vacuum" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
@@ -386,7 +440,8 @@ test "a pass over an empty database still counts" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -410,7 +465,8 @@ test "a failing prune counts the pass and leaves the rows alone" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
@@ -435,7 +491,8 @@ test "the next pass retries what the failed one could not do" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
|
||||
@@ -466,7 +523,8 @@ test "a failing prune opens a maintenance episode the next clean pass closes" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
// Only the prune failed; the checkpoint succeeded, and a success writes no
|
||||
@@ -501,7 +559,8 @@ test "a gated vacuum is a maintenance failure the next ungated pass closes" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, &fx.store);
|
||||
|
||||
try testing.expectEqualStrings("vacuum", try fx.text(
|
||||
@@ -536,7 +595,8 @@ test "a pass prunes the diagnostics store once" {
|
||||
fx.store.reportResolved(io, stale, .query_log_recreated, "one-shot", "one-shot", .warning, "aside kept");
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
@@ -22,6 +22,7 @@ comptime {
|
||||
_ = @import("upstream/doh_client.zig");
|
||||
_ = @import("upstream/doh_client_live_test.zig");
|
||||
_ = @import("upstream/pool.zig");
|
||||
_ = @import("upstream/owner.zig");
|
||||
_ = @import("upstream/dot_client.zig");
|
||||
_ = @import("upstream/dot_client_live_test.zig");
|
||||
_ = @import("upstream/dot_client_integration_test.zig");
|
||||
@@ -82,6 +83,7 @@ comptime {
|
||||
_ = @import("storage/events.zig");
|
||||
_ = @import("storage/events_fixture.zig");
|
||||
_ = @import("storage/logger.zig");
|
||||
_ = @import("storage/logger_controller.zig");
|
||||
_ = @import("platform/statfs.zig");
|
||||
_ = @import("storage/disk_monitor.zig");
|
||||
_ = @import("platform/logging.zig");
|
||||
@@ -110,6 +112,7 @@ comptime {
|
||||
_ = @import("web/handlers/lookup.zig");
|
||||
_ = @import("web/handlers/health.zig");
|
||||
_ = @import("web/handlers/version.zig");
|
||||
_ = @import("web/handlers/apply.zig");
|
||||
_ = @import("web/handlers/mutations.zig");
|
||||
_ = @import("web/handlers/groups.zig");
|
||||
_ = @import("web/handlers/blocklists.zig");
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
//! 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);
|
||||
}
|
||||
+195
-5
@@ -134,16 +134,16 @@ pub const ApiLimiter = struct {
|
||||
|
||||
/// Spends one token for a request from `addr`. Never allocates, never fails.
|
||||
pub fn check(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, addr: address.NetAddress) Result {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
// `localhost_exempt` is read under the mutex like every other config
|
||||
// field: `setLimits` may be installing a new one concurrently.
|
||||
if (self.config.localhost_exempt and isLoopback(addr)) {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
self.stats.exempt += 1;
|
||||
return .ok;
|
||||
}
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const bucket = self.bucketLocked(addr.key(), now) orelse {
|
||||
self.stats.untracked += 1;
|
||||
self.stats.refused += 1;
|
||||
@@ -238,6 +238,53 @@ pub const ApiLimiter = struct {
|
||||
return self.table.count();
|
||||
}
|
||||
|
||||
/// Installs new limits. Every live bucket is first refilled THROUGH `now`
|
||||
/// at the OLD rate — the time already elapsed was earned under the rate
|
||||
/// that was in force, and refilling it at the new rate would credit or
|
||||
/// deny tokens retroactively — then clamped to the new capacity, and only
|
||||
/// then does the new rate take over.
|
||||
///
|
||||
/// Per-address SSE counts are untouched: they count live connections, not
|
||||
/// a budget, and the new `sse_max_per_ip` applies to the next acquire.
|
||||
///
|
||||
/// No bucket's clock moves BACKWARD here. `now` is read before the write
|
||||
/// this install belongs to commits, so a request served in between leaves
|
||||
/// a bucket already newer than it; rewinding that bucket to `now` would
|
||||
/// let the next request buy the same interval a second time, at the new
|
||||
/// rate. Such a bucket is only clamped to the new capacity — it has
|
||||
/// already been refilled through a later reading at the old rate, which is
|
||||
/// exactly what this phase owes it.
|
||||
pub fn setLimits(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, limits: Config) void {
|
||||
std.debug.assert(limits.rate_per_min > 0);
|
||||
const new_capacity = @as(u64, limits.rate_per_min) * token_scale;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const old_capacity = self.capacity;
|
||||
var it = self.table.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const bucket = entry.value_ptr;
|
||||
if (bucket.updated_ns >= now.nanoseconds) {
|
||||
bucket.tokens = @min(bucket.tokens, new_capacity);
|
||||
continue;
|
||||
}
|
||||
const state = refilled(bucket.*, now, old_capacity);
|
||||
bucket.tokens = @min(state.tokens, new_capacity);
|
||||
bucket.updated_ns = state.updated_ns;
|
||||
}
|
||||
|
||||
self.config = limits;
|
||||
self.capacity = new_capacity;
|
||||
}
|
||||
|
||||
/// The live limits, read under the same mutex that installs them.
|
||||
pub fn snapshotConfig(self: *ApiLimiter, io: std.Io) Config {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.config;
|
||||
}
|
||||
|
||||
pub fn snapshotStats(self: *ApiLimiter, io: std.Io) Stats {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
@@ -685,3 +732,146 @@ fn initCheckDeinit(allocator: Allocator) !void {
|
||||
test "init surfaces allocation failure without leaking" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, initCheckDeinit, .{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setLimits (milestone-34 S3.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "setLimits refills through now at the OLD rate before the new one applies" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 5);
|
||||
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
// Thirty seconds under the old 60/min rate is worth 30 tokens. Refilling
|
||||
// those same thirty seconds at the new 600/min rate would be worth 300 —
|
||||
// a burst the operator never authorized for time already spent.
|
||||
fx.limiter.setLimits(fx.io(), at(30), .{
|
||||
.rate_per_min = 600,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..30) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
}
|
||||
|
||||
test "a bucket newer than the reading setLimits was given keeps its clock" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 8);
|
||||
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
// The request that lands between the settings prepare and its publish.
|
||||
// Thirty seconds at 60/min is worth thirty tokens, one of which it spends.
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
|
||||
// The install carries the reading taken at prepare, which is now stale.
|
||||
// Rewinding the bucket to it would sell those same thirty seconds again,
|
||||
// and at 600/min they are worth three hundred tokens rather than thirty.
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 600,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..29) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
}
|
||||
|
||||
test "a capacity cut clamps a bucket that held more than the new capacity" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 6);
|
||||
// One spend creates the bucket, leaving it at 599 of 600.
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 5,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
const refused = fx.limiter.check(fx.io(), at(0), client);
|
||||
try testing.expect(!refused.allowed);
|
||||
// At 5 per minute a whole token is twelve seconds.
|
||||
try testing.expectEqual(@as(u32, 12), refused.retry_after_s);
|
||||
}
|
||||
|
||||
test "setLimits leaves per-address SSE counts alone" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 7);
|
||||
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 120,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 2,
|
||||
});
|
||||
|
||||
// The live connections survive; the tightened cap governs the next one.
|
||||
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
|
||||
try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
}
|
||||
|
||||
test "a localhost exemption flip is observed by the next check" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = true, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const local = v4(127, 0, 0, 1);
|
||||
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
try testing.expectEqual(@as(u64, 5), fx.limiter.snapshotStats(fx.io()).exempt);
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 1,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
const stats = fx.limiter.snapshotStats(fx.io());
|
||||
try testing.expectEqual(@as(u64, 5), stats.exempt);
|
||||
try testing.expectEqual(@as(u64, 1), stats.refused);
|
||||
}
|
||||
|
||||
test "concurrent checks against a running setLimits stay consistent" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const Racer = struct {
|
||||
fn spend(limiter: *ApiLimiter, io: std.Io) void {
|
||||
for (0..2_000) |i| {
|
||||
_ = limiter.check(io, atMillis(@intCast(i)), indexed(@intCast(i % 64)));
|
||||
}
|
||||
}
|
||||
fn reconfigure(limiter: *ApiLimiter, io: std.Io) void {
|
||||
for (0..2_000) |i| {
|
||||
limiter.setLimits(io, atMillis(@intCast(i)), .{
|
||||
.rate_per_min = if (i % 2 == 0) 5 else 600,
|
||||
.localhost_exempt = i % 3 == 0,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(fx.io());
|
||||
try group.concurrent(fx.io(), Racer.spend, .{ &fx.limiter, fx.io() });
|
||||
try group.concurrent(fx.io(), Racer.reconfigure, .{ &fx.limiter, fx.io() });
|
||||
try group.await(fx.io());
|
||||
|
||||
// Every non-exempt call landed on exactly one side of the ledger.
|
||||
const stats = fx.limiter.snapshotStats(fx.io());
|
||||
try testing.expectEqual(@as(u64, 2_000), stats.allowed + stats.refused + stats.exempt);
|
||||
}
|
||||
|
||||
+86
-2
@@ -245,11 +245,14 @@ pub const LiveHash = struct {
|
||||
};
|
||||
|
||||
/// One live session. `last_used` drives the LRU eviction and moves on every
|
||||
/// successful validation; `expires_at` is fixed at login, so a session ends at
|
||||
/// its TTL however busy it was.
|
||||
/// successful validation; `expires_at` is `issued_at + ttl`, so a session ends
|
||||
/// at its TTL however busy it was. `issued_at` is kept so `setTtl` can
|
||||
/// recompute `expires_at` for live sessions from their origin rather than from
|
||||
/// the moment of the change.
|
||||
const Slot = struct {
|
||||
used: bool,
|
||||
digest: [Sha256.digest_length]u8,
|
||||
issued_at: i64,
|
||||
expires_at: i64,
|
||||
last_used: i64,
|
||||
};
|
||||
@@ -282,6 +285,7 @@ pub const Sessions = struct {
|
||||
.slots = @splat(.{
|
||||
.used = false,
|
||||
.digest = @splat(0),
|
||||
.issued_at = 0,
|
||||
.expires_at = 0,
|
||||
.last_used = 0,
|
||||
}),
|
||||
@@ -311,6 +315,7 @@ pub const Sessions = struct {
|
||||
slot.* = .{
|
||||
.used = true,
|
||||
.digest = digest,
|
||||
.issued_at = now_s,
|
||||
.expires_at = now_s + self.ttl_seconds,
|
||||
.last_used = now_s,
|
||||
};
|
||||
@@ -381,6 +386,36 @@ pub const Sessions = struct {
|
||||
return live;
|
||||
}
|
||||
|
||||
/// Installs a new TTL and re-dates every live session from its
|
||||
/// `issued_at`, so the change is retroactive rather than sliding.
|
||||
///
|
||||
/// The semantics are SERVER-SIDE only. Shortening takes effect for every
|
||||
/// session at once, including ones that are already over the new age — the
|
||||
/// next sweep drops them. Lengthening extends how long the table honours a
|
||||
/// session, but the browser still holds the cookie's ORIGINAL `Max-Age`:
|
||||
/// the cookie is never refreshed, so a session does not become sliding and
|
||||
/// a lengthened session ends when the browser drops the cookie.
|
||||
pub fn setTtl(self: *Sessions, io: std.Io, ttl_hours: u16) void {
|
||||
std.debug.assert(ttl_hours > 0);
|
||||
const ttl_seconds = @as(i64, ttl_hours) * 3600;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.ttl_seconds = ttl_seconds;
|
||||
for (&self.slots) |*slot| {
|
||||
if (!slot.used) continue;
|
||||
slot.expires_at = slot.issued_at + ttl_seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// The live TTL in seconds, for the login cookie's `Max-Age`.
|
||||
pub fn ttlSeconds(self: *Sessions, io: std.Io) i64 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.ttl_seconds;
|
||||
}
|
||||
|
||||
fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot {
|
||||
var found: ?*Slot = null;
|
||||
for (&self.slots) |*slot| {
|
||||
@@ -557,6 +592,55 @@ test "a session expires at its ttl and frees its slot" {
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200));
|
||||
}
|
||||
|
||||
test "shortening the ttl expires an over-age live session at once" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// Two hours, so a session minted at 0 is live at 3_600.
|
||||
var sessions: Sessions = .init(2);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 3_600));
|
||||
|
||||
// One hour re-dates it from `issued_at`, which puts its expiry at 3_600.
|
||||
sessions.setTtl(io, 1);
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 3_600));
|
||||
}
|
||||
|
||||
test "lengthening the ttl extends a live session server-side" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(1);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
|
||||
|
||||
// Re-dating from `issued_at` rather than from now: three hours after
|
||||
// minting, not three hours from here.
|
||||
var extended: Sessions = .init(1);
|
||||
const live = extended.createWithToken(io, tokenOf(9), 0);
|
||||
extended.setTtl(io, 3);
|
||||
try testing.expect(extended.validateAt(io, &live, 3_600));
|
||||
try testing.expect(extended.validateAt(io, &live, 10_799));
|
||||
try testing.expect(!extended.validateAt(io, &live, 10_800));
|
||||
}
|
||||
|
||||
test "a session minted after setTtl uses the new ttl" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
sessions.setTtl(io, 2);
|
||||
try testing.expectEqual(@as(i64, 7_200), sessions.ttlSeconds(io));
|
||||
|
||||
const cookie = sessions.createWithToken(io, tokenOf(4), 100);
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 7_299));
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 7_300));
|
||||
}
|
||||
|
||||
test "the thirty-third session evicts the least recently used one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
//! The apply table and the prepare → commit → publish → retire machinery a
|
||||
//! configuration write drives.
|
||||
//!
|
||||
//! Every settings key names one CONCRETE operation — the owner that has to be
|
||||
//! told, not a class of change. `bind` and `web_lifecycle` are the two that
|
||||
//! cannot be executed in-process: they create or destroy a socket, so they set
|
||||
//! `restart_pending` and milestone 35 executes them. Everything else applies
|
||||
//! live, and no response or document claims otherwise.
|
||||
//!
|
||||
//! The four phases are the write contract, and `Plan` is what carries state
|
||||
//! between them:
|
||||
//!
|
||||
//! 1. `prepare` builds and validates ONE candidate per affected owner from the
|
||||
//! FINAL MERGED configuration. It is the only fallible phase. A failure
|
||||
//! leaves the database and the running server untouched, and the caller
|
||||
//! calls `abandon`.
|
||||
//! 2. The caller commits its transaction. A commit failure is also `abandon`.
|
||||
//! 3. `publish` swaps handles and pointers and stores atomics. Infallible and
|
||||
//! I/O-free: nothing here opens, closes, joins or frees.
|
||||
//! 4. `retire` closes and frees what publish displaced, and reconciles the
|
||||
//! upstream diagnostics — event rows are SQLite I/O and are banned from a
|
||||
//! publish.
|
||||
//!
|
||||
//! An operation whose owner this `WebState` does not have is skipped: the web
|
||||
//! layer must build without a whole running server, and a handler test wires
|
||||
//! only what it asserts on. The database row is still written, because the row
|
||||
//! is the configuration and the runtime is a consumer of it.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const cert_store = @import("../../server/cert_store.zig");
|
||||
const dns_cache = @import("../../cache/dns_cache.zig");
|
||||
const dns_handler = @import("../../server/handler.zig");
|
||||
const disk_monitor = @import("../../storage/disk_monitor.zig");
|
||||
const logger_controller = @import("../../storage/logger_controller.zig");
|
||||
const logging = @import("../../platform/logging.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const rate_limiter = @import("../../server/rate_limiter.zig");
|
||||
const server = @import("../server.zig");
|
||||
const upstream_owner = @import("../../upstream/owner.zig");
|
||||
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
|
||||
const log = std.log.scoped(.web_api);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the key table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The owner a settings key belongs to. One entry per owner, never one per key:
|
||||
/// two keys of the same owner produce ONE candidate.
|
||||
pub const Operation = enum {
|
||||
dns_policy,
|
||||
trusted_proxies,
|
||||
logger_privacy,
|
||||
logger_flush,
|
||||
disk_thresholds,
|
||||
upstream_generation,
|
||||
cache,
|
||||
rate_limiter,
|
||||
sessions_ttl,
|
||||
api_limiter,
|
||||
retention,
|
||||
certs_doh,
|
||||
certs_dot,
|
||||
log_sink,
|
||||
scheduler,
|
||||
logger_queue,
|
||||
/// A listener's address, port, or existence. Milestone 35 executes these.
|
||||
bind,
|
||||
/// `web.enabled`. Milestone 35 executes it.
|
||||
web_lifecycle,
|
||||
|
||||
/// The broad class, DERIVED rather than listed a second time: a second
|
||||
/// table would be a second authority, and the two would drift.
|
||||
pub fn class(self: Operation) Class {
|
||||
return switch (self) {
|
||||
.bind => .bind,
|
||||
.web_lifecycle => .web_lifecycle,
|
||||
// Everything a candidate has to be built for before it can be
|
||||
// published: memory, a file, a connection, a task.
|
||||
.upstream_generation,
|
||||
.cache,
|
||||
.rate_limiter,
|
||||
.certs_doh,
|
||||
.certs_dot,
|
||||
.log_sink,
|
||||
.logger_queue,
|
||||
.trusted_proxies,
|
||||
=> .subsystem,
|
||||
.dns_policy,
|
||||
.logger_privacy,
|
||||
.logger_flush,
|
||||
.disk_thresholds,
|
||||
.sessions_ttl,
|
||||
.api_limiter,
|
||||
.retention,
|
||||
.scheduler,
|
||||
=> .live,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a change to this owner waits for a restart. The two that do are
|
||||
/// the two milestone 35 owns.
|
||||
pub fn needsRestart(self: Operation) bool {
|
||||
return switch (self.class()) {
|
||||
.bind, .web_lifecycle => true,
|
||||
.live, .subsystem => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Class = enum { live, subsystem, bind, web_lifecycle };
|
||||
|
||||
pub const Set = std.EnumSet(Operation);
|
||||
|
||||
pub const Entry = struct { key: []const u8, operation: Operation };
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order, with the owner it
|
||||
/// belongs to. `web.password` is write-only and `web.password_hash` is hidden;
|
||||
/// neither is a settings key, and the completeness test below spells out the
|
||||
/// same two exclusions the read and write shapes use.
|
||||
pub const table = [_]Entry{
|
||||
.{ .key = "upstream.attempt_timeout_ms", .operation = .upstream_generation },
|
||||
// The forward-zone client's read deadline, which the per-query policy
|
||||
// carries — not one of the pool's two timeouts.
|
||||
.{ .key = "upstream.read_timeout_ms", .operation = .dns_policy },
|
||||
.{ .key = "upstream.total_timeout_ms", .operation = .upstream_generation },
|
||||
|
||||
.{ .key = "dns.bind_ipv4", .operation = .bind },
|
||||
.{ .key = "dns.bind_ipv6", .operation = .bind },
|
||||
.{ .key = "dns.port", .operation = .bind },
|
||||
.{ .key = "dns.rate_limit", .operation = .rate_limiter },
|
||||
.{ .key = "dns.rate_window_seconds", .operation = .rate_limiter },
|
||||
|
||||
.{ .key = "blocking.response", .operation = .dns_policy },
|
||||
.{ .key = "blocking.ttl", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "cache.size", .operation = .cache },
|
||||
// The cache keeps no copy of its configuration: `classify` reads the
|
||||
// negative ceiling off the per-query policy.
|
||||
.{ .key = "cache.negative_ttl_max", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "web.enabled", .operation = .web_lifecycle },
|
||||
.{ .key = "web.bind", .operation = .bind },
|
||||
.{ .key = "web.port", .operation = .bind },
|
||||
.{ .key = "web.session_ttl_hours", .operation = .sessions_ttl },
|
||||
.{ .key = "web.api_rate_limit_per_min", .operation = .api_limiter },
|
||||
.{ .key = "web.api_localhost_exempt", .operation = .api_limiter },
|
||||
.{ .key = "web.sse_max_connections_per_ip", .operation = .api_limiter },
|
||||
.{ .key = "web.trusted_proxies", .operation = .trusted_proxies },
|
||||
|
||||
.{ .key = "doh_server.enabled", .operation = .bind },
|
||||
.{ .key = "doh_server.bind", .operation = .bind },
|
||||
.{ .key = "doh_server.port", .operation = .bind },
|
||||
.{ .key = "doh_server.cert_path", .operation = .certs_doh },
|
||||
.{ .key = "doh_server.key_path", .operation = .certs_doh },
|
||||
|
||||
.{ .key = "dot_server.enabled", .operation = .bind },
|
||||
.{ .key = "dot_server.bind", .operation = .bind },
|
||||
.{ .key = "dot_server.port", .operation = .bind },
|
||||
.{ .key = "dot_server.cert_path", .operation = .certs_dot },
|
||||
.{ .key = "dot_server.key_path", .operation = .certs_dot },
|
||||
|
||||
.{ .key = "edns.ecs_mode", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "logging.level", .operation = .log_sink },
|
||||
.{ .key = "logging.retention_days", .operation = .retention },
|
||||
.{ .key = "logging.query_log_buffer_max", .operation = .logger_queue },
|
||||
.{ .key = "logging.query_log_flush_interval_s", .operation = .logger_flush },
|
||||
.{ .key = "logging.hide_domains", .operation = .logger_privacy },
|
||||
.{ .key = "logging.hide_client_ips", .operation = .logger_privacy },
|
||||
.{ .key = "logging.output", .operation = .log_sink },
|
||||
.{ .key = "logging.file_path", .operation = .log_sink },
|
||||
.{ .key = "logging.max_size_mb", .operation = .log_sink },
|
||||
.{ .key = "logging.max_files", .operation = .log_sink },
|
||||
|
||||
.{ .key = "disk.min_free_mb", .operation = .disk_thresholds },
|
||||
.{ .key = "disk.warn_free_mb", .operation = .disk_thresholds },
|
||||
|
||||
.{ .key = "blocklist_update.enabled", .operation = .scheduler },
|
||||
.{ .key = "blocklist_update.interval_hours", .operation = .scheduler },
|
||||
};
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash nxdns
|
||||
/// never computed.
|
||||
pub fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned. It is not a settings
|
||||
/// key: the hash it produces is, and its apply is the live-hash install.
|
||||
pub fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
pub fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
fn find(comptime key: []const u8) ?Operation {
|
||||
@setEvalBranchQuota(20_000);
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.key, key)) return entry.operation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The keys whose change waits for a restart, which is exactly the two
|
||||
/// milestone-35 operations. `/api/settings` reports this list as field-level
|
||||
/// metadata about the form.
|
||||
pub const restart_required_keys: []const []const u8 = &restart_keys;
|
||||
|
||||
const restart_keys = blk: {
|
||||
var list: [table.len][]const u8 = undefined;
|
||||
var count = 0;
|
||||
for (table) |entry| {
|
||||
if (!entry.operation.needsRestart()) continue;
|
||||
list[count] = entry.key;
|
||||
count += 1;
|
||||
}
|
||||
const final = list[0..count].*;
|
||||
break :blk final;
|
||||
};
|
||||
|
||||
/// Whether a scalar settings value differs between two configurations. Strings
|
||||
/// compare by bytes; everything else a settings row can hold compares by value.
|
||||
fn differs(comptime T: type, a: T, b: T) bool {
|
||||
if (T == []const u8) return !std.mem.eql(u8, a, b);
|
||||
return a != b;
|
||||
}
|
||||
|
||||
/// The owners a change from `before` to `after` has to tell.
|
||||
///
|
||||
/// Derived from the values, not from the keys the patch named: a PUT that
|
||||
/// rewrites a setting to what it already was changes nothing, so it must not
|
||||
/// resize a queue, rebuild a pool, or owe a restart. The admin form submits
|
||||
/// every field, and treating that as eighteen applies would be wrong as well as
|
||||
/// wasteful.
|
||||
pub fn changedOperations(before: model.Config, after: model.Config) Set {
|
||||
var ops: Set = .initEmpty();
|
||||
inline for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
comptime if (isHidden(section_field.name, field.name)) continue;
|
||||
comptime if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
const operation = comptime find(section_field.name ++ "." ++ field.name).?;
|
||||
const old = @field(@field(before, section_field.name), field.name);
|
||||
const new = @field(@field(after, section_field.name), field.name);
|
||||
if (differs(@TypeOf(old), old, new)) ops.insert(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything one configuration write prepared, published and still owes a
|
||||
/// retire. Exactly one of `abandon` or `publish` consumes a prepared plan, and
|
||||
/// a published one is always followed by `retire`.
|
||||
pub const Plan = struct {
|
||||
state: *server.WebState,
|
||||
/// The per-request arena. Holds the copied upstream report keys, which
|
||||
/// nothing outside this request reads.
|
||||
arena: Allocator,
|
||||
/// The final merged configuration every candidate is built from.
|
||||
cfg: model.Config,
|
||||
ops: Set,
|
||||
|
||||
// prepared candidates ---------------------------------------------------
|
||||
proxies: ?[]u8 = null,
|
||||
cache: ?*dns_cache.DnsCache = null,
|
||||
limiter: ?*rate_limiter.RateLimiter = null,
|
||||
upstream: ?*upstream_owner.Generation = null,
|
||||
upstream_previous_keys: []const []const u8 = &.{},
|
||||
doh_paths: ?cert_store.CertStore.PreparedPaths = null,
|
||||
dot_paths: ?cert_store.CertStore.PreparedPaths = null,
|
||||
sink: ?logging.PreparedApply = null,
|
||||
log_dir: ?*disk_monitor.LogDir = null,
|
||||
queue: ?logger_controller.Prepared = null,
|
||||
/// The reading `setLimits` refills its buckets against. Taken at prepare
|
||||
/// because publish reads no clock: a clock is I/O.
|
||||
limiter_now: ?std.Io.Timestamp = null,
|
||||
|
||||
// what publish displaced, for retire ------------------------------------
|
||||
retired_proxies: ?[]const u8 = null,
|
||||
retired_cache: ?*dns_cache.DnsCache = null,
|
||||
retired_limiter: ?*rate_limiter.RateLimiter = null,
|
||||
retired_upstream: ?*upstream_owner.Generation = null,
|
||||
detached_log_file: ?std.Io.File = null,
|
||||
published_upstream: bool = false,
|
||||
|
||||
pub fn init(
|
||||
state: *server.WebState,
|
||||
arena: Allocator,
|
||||
cfg: model.Config,
|
||||
ops: Set,
|
||||
) Plan {
|
||||
return .{ .state = state, .arena = arena, .cfg = cfg, .ops = ops };
|
||||
}
|
||||
|
||||
/// Builds every candidate the change needs. Returns the failure that
|
||||
/// refused it, in which case the caller must still call `abandon`: an
|
||||
/// earlier owner's candidate may already exist.
|
||||
///
|
||||
/// A propagated error abandons here instead, because the caller has no
|
||||
/// plan left to abandon: an earlier owner may already hold memory, a file,
|
||||
/// a connection, a parked writer or a lock — the cert store keeps
|
||||
/// `reload_mutex` held between its prepare and its publish.
|
||||
pub fn prepare(self: *Plan, io: std.Io) error{OutOfMemory}!?Failure {
|
||||
errdefer self.abandon(io);
|
||||
|
||||
const state = self.state;
|
||||
const gpa = state.gpa;
|
||||
|
||||
if (self.ops.contains(.trusted_proxies)) {
|
||||
self.proxies = try gpa.dupe(u8, self.cfg.web.trusted_proxies);
|
||||
}
|
||||
|
||||
if (self.ops.contains(.cache)) {
|
||||
const candidate = try gpa.create(dns_cache.DnsCache);
|
||||
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
|
||||
gpa.destroy(candidate);
|
||||
return err;
|
||||
};
|
||||
self.cache = candidate;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.rate_limiter)) {
|
||||
const candidate = try gpa.create(rate_limiter.RateLimiter);
|
||||
candidate.* = rate_limiter.RateLimiter.init(gpa, .{
|
||||
.limit = self.cfg.dns.rate_limit,
|
||||
.window_seconds = self.cfg.dns.rate_window_seconds,
|
||||
}) catch |err| {
|
||||
gpa.destroy(candidate);
|
||||
return err;
|
||||
};
|
||||
self.limiter = candidate;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.upstream_generation)) {
|
||||
if (try self.prepareUpstreams(io, self.cfg.upstreams)) |failure| return failure;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.certs_doh)) {
|
||||
// A disabled endpoint has no store: the change is a database row
|
||||
// and nothing else, and milestone 35 validates the paths when it
|
||||
// implements enable.
|
||||
if (state.doh_certs) |store| {
|
||||
self.doh_paths = store.preparePathChange(
|
||||
io,
|
||||
self.cfg.doh_server.cert_path,
|
||||
self.cfg.doh_server.key_path,
|
||||
) catch |err| return certFailure("doh_server", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.certs_dot)) {
|
||||
if (state.dot_certs) |store| {
|
||||
self.dot_paths = store.preparePathChange(
|
||||
io,
|
||||
self.cfg.dot_server.cert_path,
|
||||
self.cfg.dot_server.key_path,
|
||||
) catch |err| return certFailure("dot_server", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.log_sink)) {
|
||||
self.sink = logging.prepareApply(io, self.cfg.logging) catch |err| return switch (err) {
|
||||
error.PathTooLong => Failure{ .invalid = "logging.file_path: too long for this system" },
|
||||
error.TargetUnopenable => Failure{
|
||||
.invalid = "logging.file_path: this file cannot be opened for writing",
|
||||
},
|
||||
};
|
||||
// Derived from the final merged output AND file_path on every sink
|
||||
// apply, in both directions: output `file` measures the file's
|
||||
// directory, anything else measures nothing.
|
||||
self.log_dir = try disk_monitor.Monitor.prepareLogDir(gpa, logging.logDirname(self.cfg.logging));
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_queue)) {
|
||||
if (state.logger) |controller| {
|
||||
// The merged configuration, not the live one: a PUT that
|
||||
// changes the buffer size and a privacy flag together must not
|
||||
// hand the replacement generation the privacy it replaced.
|
||||
if (controller.prepare(io, self.cfg.logging)) |prepared| {
|
||||
self.queue = prepared;
|
||||
} else |err| {
|
||||
const entries = self.cfg.logging.query_log_buffer_max;
|
||||
if (try self.queueFailure(err, entries)) |failure| return failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.api_limiter)) self.limiter_now = std.Io.Clock.awake.now(io);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// What a refused resize means to the client, or null when it means nothing
|
||||
/// — a controller with no generation to replace is a test's borrowed one,
|
||||
/// and the row is still the configuration.
|
||||
fn queueFailure(
|
||||
self: *Plan,
|
||||
err: logger_controller.PrepareError,
|
||||
entries: u32,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
var buf: [96]u8 = undefined;
|
||||
if (logger_controller.sizeMessage(err, entries, &buf)) |message| {
|
||||
return Failure{ .invalid = try std.fmt.allocPrint(
|
||||
self.arena,
|
||||
"logging.query_log_buffer_max: {s}",
|
||||
.{message},
|
||||
) };
|
||||
}
|
||||
return switch (err) {
|
||||
error.NotResizable => null,
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.PreviousResizeDraining => Failure{
|
||||
.conflict = "a previous query-log resize is still draining",
|
||||
},
|
||||
else => blk: {
|
||||
log.warn("preparing the query-log resize failed: {s}", .{@errorName(err)});
|
||||
break :blk Failure{ .unavailable = "the query logger could not be resized" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// The upstream half, shared with the upstream resource handlers: they
|
||||
/// build their candidate from the HYPOTHETICAL post-mutation row set,
|
||||
/// before the repository write.
|
||||
pub fn prepareUpstreams(
|
||||
self: *Plan,
|
||||
io: std.Io,
|
||||
servers: []const model.UpstreamServer,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const state = self.state;
|
||||
const owner = state.upstreams orelse return null;
|
||||
const inputs = state.upstream_build orelse return null;
|
||||
|
||||
self.upstream_previous_keys = try owner.copyLiveReportKeys(io, self.arena);
|
||||
self.upstream = upstream_owner.build(.{
|
||||
.gpa = state.gpa,
|
||||
.io = io,
|
||||
.servers = servers,
|
||||
.http = inputs.http,
|
||||
.bundle = inputs.bundle,
|
||||
.bundle_lock = inputs.bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = model.attemptTimeout(self.cfg.upstream), .clock = .awake },
|
||||
.total = .{ .raw = model.totalTimeout(self.cfg.upstream), .clock = .awake },
|
||||
},
|
||||
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
.diagnostics = state.events,
|
||||
}) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.NoUsableUpstreams => return Failure{
|
||||
.conflict = "no usable upstream would be left",
|
||||
},
|
||||
};
|
||||
self.ops.insert(.upstream_generation);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Frees every candidate. The change did not happen: nothing was published,
|
||||
/// so nothing the running server holds is touched.
|
||||
pub fn abandon(self: *Plan, io: std.Io) void {
|
||||
const gpa = self.state.gpa;
|
||||
|
||||
if (self.proxies) |text| gpa.free(text);
|
||||
if (self.cache) |candidate| {
|
||||
candidate.deinit();
|
||||
gpa.destroy(candidate);
|
||||
}
|
||||
if (self.limiter) |candidate| {
|
||||
candidate.deinit();
|
||||
gpa.destroy(candidate);
|
||||
}
|
||||
if (self.upstream) |candidate| candidate.retire(io);
|
||||
if (self.doh_paths) |prepared| self.state.doh_certs.?.abortPathChange(io, prepared);
|
||||
if (self.dot_paths) |prepared| self.state.dot_certs.?.abortPathChange(io, prepared);
|
||||
if (self.sink) |prepared| logging.abortApply(io, prepared);
|
||||
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
|
||||
if (self.queue) |prepared| self.state.logger.?.abandon(io, prepared);
|
||||
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Infallible and I/O-free. Every operation whose owner is wired is told,
|
||||
/// and what it displaced is recorded for `retire`.
|
||||
pub fn publish(self: *Plan, io: std.Io) void {
|
||||
const state = self.state;
|
||||
|
||||
if (self.ops.contains(.dns_policy)) {
|
||||
if (state.handler) |h| h.setPolicy(io, .{
|
||||
.blocking = .{ .mode = self.cfg.blocking.response, .ttl = self.cfg.blocking.ttl },
|
||||
.ecs_mode = self.cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(self.cfg.upstream), .clock = .awake },
|
||||
.negative_ttl_max = self.cfg.cache.negative_ttl_max,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.proxies) |prepared| {
|
||||
self.retired_proxies = state.proxies.install(io, prepared);
|
||||
self.proxies = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_privacy)) {
|
||||
if (state.logger) |controller| controller.setPrivacy(io, .{
|
||||
.hide_domains = self.cfg.logging.hide_domains,
|
||||
.hide_client_ips = self.cfg.logging.hide_client_ips,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_flush)) {
|
||||
if (state.logger) |controller| {
|
||||
controller.setFlushInterval(io, self.cfg.logging.query_log_flush_interval_s);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.disk_thresholds)) {
|
||||
if (state.monitor) |monitor| monitor.setThresholds(self.cfg.disk);
|
||||
}
|
||||
|
||||
if (self.upstream) |candidate| {
|
||||
self.retired_upstream = state.upstreams.?.replace(io, candidate);
|
||||
self.published_upstream = true;
|
||||
self.upstream = null;
|
||||
}
|
||||
|
||||
if (self.cache) |candidate| {
|
||||
// A handler-less state still owns the candidate, and dropping it
|
||||
// here would leak it: `replaceCache` is the only thing that can
|
||||
// hand the old one back.
|
||||
if (state.handler) |h| {
|
||||
self.retired_cache = h.replaceCache(io, candidate);
|
||||
} else {
|
||||
self.retired_cache = candidate;
|
||||
}
|
||||
self.cache = null;
|
||||
}
|
||||
|
||||
if (self.limiter) |candidate| {
|
||||
if (state.handler) |h| {
|
||||
self.retired_limiter = h.replaceRateLimiter(io, candidate);
|
||||
} else {
|
||||
self.retired_limiter = candidate;
|
||||
}
|
||||
self.limiter = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.sessions_ttl)) {
|
||||
if (state.sessions) |sessions| sessions.setTtl(io, self.cfg.web.session_ttl_hours);
|
||||
}
|
||||
|
||||
if (self.limiter_now) |now| {
|
||||
if (state.limiter) |limiter| limiter.setLimits(io, now, .{
|
||||
.rate_per_min = self.cfg.web.api_rate_limit_per_min,
|
||||
.localhost_exempt = self.cfg.web.api_localhost_exempt,
|
||||
.sse_max_per_ip = self.cfg.web.sse_max_connections_per_ip,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.ops.contains(.retention)) {
|
||||
if (state.retention_days) |days| days.setRetentionDays(self.cfg.logging.retention_days);
|
||||
}
|
||||
|
||||
if (self.doh_paths) |prepared| {
|
||||
state.doh_certs.?.publishPathChange(io, prepared);
|
||||
self.doh_paths = null;
|
||||
}
|
||||
if (self.dot_paths) |prepared| {
|
||||
state.dot_certs.?.publishPathChange(io, prepared);
|
||||
self.dot_paths = null;
|
||||
}
|
||||
|
||||
if (self.sink) |prepared| {
|
||||
self.detached_log_file = logging.publishApply(prepared);
|
||||
self.sink = null;
|
||||
if (state.monitor) |monitor| {
|
||||
monitor.setLogDir(io, self.log_dir);
|
||||
} else {
|
||||
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
|
||||
}
|
||||
self.log_dir = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.scheduler)) {
|
||||
if (state.manager) |manager| manager.setSchedule(
|
||||
io,
|
||||
self.cfg.blocklist_update.enabled,
|
||||
self.cfg.blocklist_update.interval_hours,
|
||||
);
|
||||
}
|
||||
|
||||
if (self.queue) |prepared| {
|
||||
state.logger.?.publish(io, prepared);
|
||||
self.queue = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes, frees and reconciles after the publish. Runs on the writing
|
||||
/// task, never on a reader's release path.
|
||||
pub fn retire(self: *Plan, io: std.Io) void {
|
||||
const gpa = self.state.gpa;
|
||||
|
||||
if (self.retired_proxies) |text| gpa.free(text);
|
||||
if (self.retired_cache) |old| {
|
||||
old.deinit();
|
||||
gpa.destroy(old);
|
||||
}
|
||||
if (self.retired_limiter) |old| {
|
||||
old.deinit();
|
||||
gpa.destroy(old);
|
||||
}
|
||||
logging.retireApply(io, self.detached_log_file);
|
||||
|
||||
if (self.published_upstream) {
|
||||
if (self.state.events) |store| {
|
||||
const owner = self.state.upstreams.?;
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
upstream_owner.reconcileReport(
|
||||
store,
|
||||
io,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
generation.report().notes,
|
||||
self.upstream_previous_keys,
|
||||
);
|
||||
}
|
||||
// A generation no reader held at the swap has no release left to
|
||||
// tear it down, so the publisher does — after the reconciliation,
|
||||
// which reads only the copies taken at prepare.
|
||||
if (self.retired_upstream) |old| old.retire(io);
|
||||
}
|
||||
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
fn certFailure(comptime endpoint: []const u8, err: cert_store.ReloadError) Failure {
|
||||
return switch (err) {
|
||||
error.OutOfMemory => .{ .unavailable = "out of memory loading the certificate" },
|
||||
error.CertUnreadable => .{ .invalid = endpoint ++ ".cert_path: no readable certificate there" },
|
||||
error.KeyUnreadable => .{ .invalid = endpoint ++ ".key_path: no readable key there" },
|
||||
error.KeyMismatch => .{ .invalid = endpoint ++ ": the key at that path does not match the certificate" },
|
||||
// The remaining variants are parse, size and library failures. They name
|
||||
// the pair rather than one of the two files, because which of them was
|
||||
// at fault is exactly what the loader could not decide.
|
||||
else => .{ .invalid = endpoint ++ ": the certificate and key at those paths could not be loaded" },
|
||||
};
|
||||
}
|
||||
|
||||
/// The row set an upstream create, update or delete would leave behind, built
|
||||
/// before the repository write so the candidate generation is the one the
|
||||
/// commit will make true.
|
||||
pub const RowMutation = union(enum) {
|
||||
add: model.UpstreamServer,
|
||||
replace: struct { id: i64, item: model.UpstreamServer },
|
||||
remove: i64,
|
||||
};
|
||||
|
||||
pub fn hypotheticalRows(
|
||||
arena: Allocator,
|
||||
rows: []const upstreams_repo.UpstreamRow,
|
||||
mutation: RowMutation,
|
||||
) Allocator.Error![]const model.UpstreamServer {
|
||||
var out: std.ArrayList(model.UpstreamServer) = .empty;
|
||||
for (rows) |row| {
|
||||
switch (mutation) {
|
||||
.add => {},
|
||||
.replace => |edit| if (row.id == edit.id) {
|
||||
try out.append(arena, edit.item);
|
||||
continue;
|
||||
},
|
||||
.remove => |id| if (row.id == id) continue,
|
||||
}
|
||||
try out.append(arena, .{
|
||||
.url = row.url,
|
||||
.priority = row.priority,
|
||||
.enabled = row.enabled,
|
||||
.tls_name = row.tls_name,
|
||||
});
|
||||
}
|
||||
if (mutation == .add) try out.append(arena, mutation.add);
|
||||
return out.items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
const fixtures = @import("test_fixtures");
|
||||
|
||||
test "a propagated error out of prepare abandons what earlier owners already built" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
|
||||
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
var log_buf: [128]u8 = undefined;
|
||||
const cert_path = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert.pem", .{tmp.sub_path});
|
||||
const key_path = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key.pem", .{tmp.sub_path});
|
||||
const log_path = try std.fmt.bufPrint(&log_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
|
||||
|
||||
var store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
// The cert candidate is built from the store's own allocator and succeeds;
|
||||
// the log-directory generation that follows it comes from the state's, and
|
||||
// that one is out of memory.
|
||||
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
|
||||
var state: server.WebState = .{ .gpa = failing.allocator(), .doh_certs = &store };
|
||||
|
||||
var cfg: model.Config = .{};
|
||||
cfg.doh_server.cert_path = cert_path;
|
||||
cfg.doh_server.key_path = key_path;
|
||||
cfg.logging.output = .file;
|
||||
cfg.logging.file_path = log_path;
|
||||
|
||||
var ops: Set = .initEmpty();
|
||||
ops.insert(.certs_doh);
|
||||
ops.insert(.log_sink);
|
||||
|
||||
var plan: Plan = .init(&state, arena_state.allocator(), cfg, ops);
|
||||
try testing.expectError(error.OutOfMemory, plan.prepare(io));
|
||||
|
||||
// `preparePathChange` holds `reload_mutex` until its publish or its abort.
|
||||
// A prepare that failed after it must have aborted, or every later cert
|
||||
// change and every reload would block here forever.
|
||||
const second = try store.preparePathChange(io, cert_path, key_path);
|
||||
store.abortPathChange(io, second);
|
||||
}
|
||||
|
||||
test "every scalar settings key appears in the table exactly once" {
|
||||
// The old comptime generator walked `model.Config` to produce the key list;
|
||||
// this walks it to prove the hand-written table covers it. A section added
|
||||
// to the model fails to compile here until its keys have owners.
|
||||
comptime {
|
||||
@setEvalBranchQuota(50_000);
|
||||
var counted = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
const key = section_field.name ++ "." ++ field.name;
|
||||
var hits = 0;
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.key, key)) hits += 1;
|
||||
}
|
||||
if (hits != 1) @compileError("the apply table does not list " ++ key ++ " exactly once");
|
||||
counted += 1;
|
||||
}
|
||||
}
|
||||
if (counted != table.len) @compileError("the apply table lists a key the model does not have");
|
||||
}
|
||||
}
|
||||
|
||||
test "the table names no secret" {
|
||||
for (table) |entry| {
|
||||
try testing.expect(!std.mem.eql(u8, entry.key, "web.password"));
|
||||
try testing.expect(!std.mem.eql(u8, entry.key, "web.password_hash"));
|
||||
}
|
||||
}
|
||||
|
||||
test "restart-required is bind and web lifecycle, and nothing else" {
|
||||
for (table) |entry| {
|
||||
var listed = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, entry.key, key)) listed = true;
|
||||
}
|
||||
try testing.expectEqual(entry.operation.needsRestart(), listed);
|
||||
}
|
||||
|
||||
// The full list, so a key silently joining or leaving it is a failure.
|
||||
const expected = [_][]const u8{
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"doh_server.bind",
|
||||
"doh_server.enabled",
|
||||
"doh_server.port",
|
||||
"dot_server.bind",
|
||||
"dot_server.enabled",
|
||||
"dot_server.port",
|
||||
};
|
||||
try testing.expectEqual(expected.len, restart_required_keys.len);
|
||||
for (expected) |key| {
|
||||
var found = false;
|
||||
for (restart_required_keys) |listed| {
|
||||
if (std.mem.eql(u8, listed, key)) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
}
|
||||
}
|
||||
|
||||
test "changed operations follow the values, not the keys a patch named" {
|
||||
const before: model.Config = .{};
|
||||
try testing.expect(changedOperations(before, before).count() == 0);
|
||||
|
||||
var after = before;
|
||||
after.dns.port = 5353;
|
||||
try testing.expect(changedOperations(before, after).eql(Set.initOne(.bind)));
|
||||
|
||||
// Two keys of one owner are one operation.
|
||||
var pair = before;
|
||||
pair.disk = .{ .min_free_mb = 1, .warn_free_mb = 2 };
|
||||
try testing.expect(changedOperations(before, pair).eql(Set.initOne(.disk_thresholds)));
|
||||
|
||||
// A string key compares by bytes.
|
||||
var proxies = before;
|
||||
proxies.web.trusted_proxies = "10.0.0.1";
|
||||
try testing.expect(changedOperations(before, proxies).eql(Set.initOne(.trusted_proxies)));
|
||||
|
||||
// Equal bytes at a different address are not a change.
|
||||
var same_bytes = before;
|
||||
var copy: [7]u8 = undefined;
|
||||
@memcpy(©, "0.0.0.0");
|
||||
same_bytes.dns.bind_ipv4 = ©
|
||||
try testing.expect(changedOperations(before, same_bytes).count() == 0);
|
||||
}
|
||||
|
||||
test "the hypothetical row set is the one the commit will make true" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const rows = [_]upstreams_repo.UpstreamRow{
|
||||
.{ .id = 1, .url = "https://a.example/dns-query", .priority = 1, .enabled = true, .tls_name = "" },
|
||||
.{ .id = 2, .url = "tls://b.example:853", .priority = 2, .enabled = true, .tls_name = "b.example" },
|
||||
};
|
||||
|
||||
const added = try hypotheticalRows(arena, &rows, .{ .add = .{ .url = "https://c.example/dns-query" } });
|
||||
try testing.expectEqual(@as(usize, 3), added.len);
|
||||
try testing.expectEqualStrings("https://c.example/dns-query", added[2].url);
|
||||
|
||||
const removed = try hypotheticalRows(arena, &rows, .{ .remove = 1 });
|
||||
try testing.expectEqual(@as(usize, 1), removed.len);
|
||||
try testing.expectEqualStrings("tls://b.example:853", removed[0].url);
|
||||
|
||||
const replaced = try hypotheticalRows(arena, &rows, .{
|
||||
.replace = .{ .id = 2, .item = .{ .url = "tls://z.example:853", .tls_name = "z.example" } },
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 2), replaced.len);
|
||||
try testing.expectEqualStrings("tls://z.example:853", replaced[1].url);
|
||||
}
|
||||
@@ -124,11 +124,17 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
|
||||
.cookie => |cookie| {
|
||||
log.info("web login accepted for {f}", .{request.client_addr});
|
||||
var buf: [cookie_buf_len]u8 = undefined;
|
||||
// The live table's TTL, not `state.web`'s boot snapshot: a
|
||||
// settings apply changes the TTL in place.
|
||||
const ttl_s = if (state.sessions) |sessions|
|
||||
sessions.ttlSeconds(io)
|
||||
else
|
||||
model.sessionTtlSeconds(state.web);
|
||||
const header = http_util.formatSetCookie(
|
||||
&buf,
|
||||
auth.cookie_name,
|
||||
&cookie,
|
||||
model.sessionTtlSeconds(state.web),
|
||||
ttl_s,
|
||||
) catch return error.OutOfMemory;
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.authenticated = true,
|
||||
|
||||
+19
-12
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
|
||||
const disk_monitor = @import("../../storage/disk_monitor.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const logger_controller = @import("../../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../../storage/logger.zig");
|
||||
const metrics = @import("../metrics.zig");
|
||||
const pause_mod = @import("../../server/pause.zig");
|
||||
@@ -229,20 +230,25 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
input.disk_free_bytes = monitor.gauges().free_bytes;
|
||||
}
|
||||
|
||||
if (state.pool) |pool| {
|
||||
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
|
||||
const count = metrics.poolSnapshot(pool, io, &raw);
|
||||
input.upstreams_total = @intCast(count);
|
||||
for (raw[0..count]) |entry| {
|
||||
if (entry.available) input.upstreams_available += 1;
|
||||
if (state.upstreams) |owner| {
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
if (generation.pool) |pool| {
|
||||
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
|
||||
const count = metrics.poolSnapshot(pool, io, &raw);
|
||||
input.upstreams_total = @intCast(count);
|
||||
for (raw[0..count]) |entry| {
|
||||
if (entry.available) input.upstreams_available += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logger) |logger| {
|
||||
input.queries_dropped = logger.queries_dropped.load(.monotonic);
|
||||
input.last_drop_s = logger.lastDropSeconds();
|
||||
input.writer_failed = logger.writer_failed.load(.monotonic);
|
||||
input.gate_episode = logger.gateEpisode();
|
||||
if (state.logger) |controller| {
|
||||
const reading = controller.sample(io);
|
||||
input.queries_dropped = reading.queries_dropped;
|
||||
input.last_drop_s = reading.last_drop_s;
|
||||
input.writer_failed = reading.writer_failed;
|
||||
input.gate_episode = reading.gate_episode;
|
||||
}
|
||||
|
||||
if (state.pause) |paused| input.pause_until = paused.until.load(.monotonic);
|
||||
@@ -492,13 +498,14 @@ test "collect reads the logger's counters and the pause flag" {
|
||||
query_logger.queries_dropped.store(4, .monotonic);
|
||||
query_logger.last_drop_s.store(1_700_000_000, .monotonic);
|
||||
query_logger.writer_failed.store(true, .monotonic);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
|
||||
var paused: pause_mod.Pause = .{};
|
||||
paused.pauseFor(0, null);
|
||||
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.logger = &query_logger,
|
||||
.logger = log_owner.over(&query_logger),
|
||||
.pause = &paused,
|
||||
};
|
||||
const input = collect(&state, io);
|
||||
|
||||
@@ -163,7 +163,11 @@ pub fn Resource(comptime desc: anytype) type {
|
||||
if (!isNull(@TypeOf(desc.remove))) {
|
||||
const Remove = @TypeOf(desc.remove);
|
||||
if (removeTakesArena(Remove)) {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
|
||||
if (removeIsFallible(Remove)) {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
|
||||
} else {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
|
||||
}
|
||||
} else {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
|
||||
}
|
||||
@@ -227,10 +231,12 @@ pub fn Resource(comptime desc: anytype) type {
|
||||
}
|
||||
|
||||
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
|
||||
desc.remove(state, io, request.arena, request.id.?)
|
||||
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
|
||||
desc.remove(state, io, request.id.?)
|
||||
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
|
||||
try desc.remove(state, io, request.arena, request.id.?)
|
||||
else
|
||||
desc.remove(state, io, request.id.?);
|
||||
desc.remove(state, io, request.arena, request.id.?);
|
||||
|
||||
if (failure) |value| return respondFailure(request, value, remove_what);
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
@@ -246,6 +252,14 @@ fn removeTakesArena(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
|
||||
}
|
||||
|
||||
/// A delete decision that builds a candidate for the running server allocates
|
||||
/// while it does so, so it may run out of memory. The two shapes are checked
|
||||
/// exactly, so a `remove` that returns some other error set is still a compile
|
||||
/// error naming what it should have been.
|
||||
fn removeIsFallible(comptime T: type) bool {
|
||||
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
|
||||
}
|
||||
|
||||
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
|
||||
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
|
||||
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
|
||||
|
||||
+90
-104
@@ -1,11 +1,14 @@
|
||||
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
|
||||
//! `settings` table (ruling 16).
|
||||
//!
|
||||
//! Everything here is restart-required this milestone, and the response says so
|
||||
//! for every key: what changes live is the resource endpoints and the pause,
|
||||
//! not a setting. The list is generated from `model.Config` itself, so a
|
||||
//! section added to the model appears here without anyone remembering to add
|
||||
//! it.
|
||||
//! Every key applies live except the two that create or destroy a socket —
|
||||
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
|
||||
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
|
||||
//! `restart_required` in the response is that pair of key groups and nothing
|
||||
//! else. `apply.zig` holds the table that decides which owner a key belongs to
|
||||
//! and drives the prepare → commit → publish → retire the write contract asks
|
||||
//! for; a section added to the model fails to compile until its keys have
|
||||
//! owners there.
|
||||
//!
|
||||
//! `web.password` is write-only and `web.password_hash` is neither readable nor
|
||||
//! directly writable. A PUT carrying `web.password` hashes it with the import
|
||||
@@ -23,6 +26,7 @@ const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apply = @import("apply.zig");
|
||||
const auth = @import("../auth.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
@@ -42,58 +46,13 @@ const log = std.log.scoped(.web_api);
|
||||
/// always fits the copy `applyLogin` takes.
|
||||
const hash_buf_len = auth.LiveHash.max_len;
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash
|
||||
/// nxdns never computed.
|
||||
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
const isHidden = apply.isHidden;
|
||||
const isWriteOnly = apply.isWriteOnly;
|
||||
const isScalarSection = apply.isScalarSection;
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned.
|
||||
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the restart-required table (ruling 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
|
||||
/// them are restart-required this milestone, so the table is the key list and
|
||||
/// the flag is implied by membership.
|
||||
pub const restart_required_keys: []const []const u8 = &keys;
|
||||
|
||||
const keys = blk: {
|
||||
var list: [countKeys()][]const u8 = undefined;
|
||||
var index = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
list[index] = section_field.name ++ "." ++ field.name;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
break :blk list;
|
||||
};
|
||||
|
||||
fn countKeys() usize {
|
||||
var count = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
|
||||
/// two operations milestone 35 executes. The apply table is the authority.
|
||||
pub const restart_required_keys = apply.restart_required_keys;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the patch a PUT carries
|
||||
@@ -184,32 +143,6 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the patch names a key whose change waits for a restart. The key
|
||||
/// table above is the authority, so a key that stops being restart-required
|
||||
/// stops raising the flag without anyone remembering this function exists.
|
||||
/// A patch carrying `web.password` alone touches no such key: the new hash is
|
||||
/// installed live (ruling 17 of milestone 16).
|
||||
fn touchesRestartRequiredKey(patch: Patch) bool {
|
||||
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
|
||||
if (@field(patch, section_field.name)) |section| {
|
||||
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
|
||||
if (@field(section, field.name) != null) {
|
||||
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isRestartRequired(comptime key: []const u8) bool {
|
||||
@setEvalBranchQuota(10_000);
|
||||
for (keys) |listed| {
|
||||
if (std.mem.eql(u8, listed, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the patch carries a new password.
|
||||
fn newPassword(patch: Patch) ?[]const u8 {
|
||||
const web = patch.web orelse return null;
|
||||
@@ -339,10 +272,11 @@ fn applyPut(
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
var cfg = stored;
|
||||
|
||||
var bad_key: []const u8 = "";
|
||||
if (!merge(&cfg, patch, &bad_key)) {
|
||||
@@ -366,15 +300,38 @@ fn applyPut(
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
errdefer if (replacement) |hash| state.gpa.free(hash);
|
||||
|
||||
// Prepare: one candidate per owner whose value actually moved, built from
|
||||
// the merged configuration. Nothing is published and no row is written
|
||||
// until every one of them succeeded.
|
||||
const ops = apply.changedOperations(stored, cfg);
|
||||
var plan: apply.Plan = .init(state, arena, cfg, ops);
|
||||
if (try plan.prepare(io)) |failure| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = failure };
|
||||
}
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
|
||||
// After the commit, never before: a validation failure or a write that
|
||||
// rolled back changed nothing, so it owes nobody a restart.
|
||||
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
|
||||
// rolled back changed nothing, so it owes nobody a restart. Only the two
|
||||
// operations milestone 35 executes raise it; everything else is already
|
||||
// live by the time this line runs.
|
||||
var restart_owed = false;
|
||||
var it = ops.iterator();
|
||||
while (it.next()) |operation| {
|
||||
if (operation.needsRestart()) restart_owed = true;
|
||||
}
|
||||
if (restart_owed) state.restart_pending.store(true, .monotonic);
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
@@ -546,11 +503,10 @@ fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config
|
||||
const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
test "the restart-required list is a strict subset of the settings keys" {
|
||||
// `model.toSettings` is the key list this form writes. The restart list is
|
||||
// now the small part of it that milestone 35 owns, so it must be shorter
|
||||
// than the key list and every entry must still be a real key.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -558,17 +514,24 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
try testing.expect(restart_required_keys.len < pairs.items.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
var is_a_setting = false;
|
||||
for (pairs.items) |pair| {
|
||||
if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
|
||||
}
|
||||
try testing.expect(is_a_setting);
|
||||
}
|
||||
|
||||
var found_port = false;
|
||||
var found_ttl = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
|
||||
if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
|
||||
}
|
||||
try testing.expect(found_port);
|
||||
// A live key must not appear: the UI renders no restart affordance for it.
|
||||
try testing.expect(!found_ttl);
|
||||
}
|
||||
|
||||
test "the read shape spells every enum the way the database does" {
|
||||
@@ -806,20 +769,43 @@ test "a rejected patch raises no restart flag" {
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "only a patch naming a restart-required key raises the flag" {
|
||||
var password_only: Patch = .{};
|
||||
password_only.web = .{ .password = "correct horse battery staple" };
|
||||
try testing.expect(!touchesRestartRequiredKey(password_only));
|
||||
test "only a bind or web-lifecycle change raises the flag" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var mixed: Patch = .{};
|
||||
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
|
||||
try testing.expect(touchesRestartRequiredKey(mixed));
|
||||
// A live key, however many of them: nothing to restart for.
|
||||
var live: Patch = .{};
|
||||
live.blocking = .{ .ttl = 30 };
|
||||
live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
var elsewhere: Patch = .{};
|
||||
elsewhere.dns = .{ .port = 5353 };
|
||||
try testing.expect(touchesRestartRequiredKey(elsewhere));
|
||||
// `web.enabled` is the second of the two milestone-35 operations, and it
|
||||
// executes nothing: the row moves and the flag rises.
|
||||
var lifecycle: Patch = .{};
|
||||
lifecycle.web = .{ .enabled = false };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expect(!touchesRestartRequiredKey(.{}));
|
||||
test "a patch that changes nothing applies nothing" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
// The stored port is 53, and the admin form submits every field it read.
|
||||
// Rewriting a value to itself is not a change and must not raise the flag.
|
||||
var same: Patch = .{};
|
||||
same.dns = .{ .port = 53 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a password-only put leaves the restart flag alone" {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! `/api/upstreams` — the resolvers nxdns forwards to.
|
||||
//!
|
||||
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
|
||||
//! mutable resource that is NOT live. The pool builds its clients, its health
|
||||
//! state and its TLS material at startup, so an upstream added, edited or
|
||||
//! removed here takes effect at the next restart. The response says so through
|
||||
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
|
||||
//! has one banner and one meaning for it.
|
||||
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
|
||||
//! live like the rest of them: an upstream added, edited or removed here is
|
||||
//! applied in-process. The row set the write will leave behind is built into a
|
||||
//! candidate generation BEFORE the write, so a set that cannot produce clients
|
||||
//! is refused with nothing changed; the candidate is published after the commit
|
||||
//! and the displaced generation retires once the exchanges holding it finish.
|
||||
//! `restart_required` in the response is therefore `false`, and stays in the
|
||||
//! shape so the UI has one field with one meaning across every mutation.
|
||||
//!
|
||||
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
|
||||
//! empty for every other scheme, and the validator refuses it there.
|
||||
@@ -13,6 +15,8 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apply = @import("apply.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
@@ -38,6 +42,34 @@ const Created = union(enum) { id: i64, fail: Failure };
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Prepares the generation the row set after `mutation` would produce, on a
|
||||
/// caller that already holds `config_lock`. Returns the plan for the caller to
|
||||
/// publish or abandon.
|
||||
fn planFor(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
database: *db.Db,
|
||||
mutation: apply.RowMutation,
|
||||
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
|
||||
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
|
||||
|
||||
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
|
||||
if (try plan.prepareUpstreams(io, servers)) |failure| {
|
||||
plan.abandon(io);
|
||||
return .{ .fail = failure };
|
||||
}
|
||||
return .{ .plan = plan };
|
||||
}
|
||||
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
@@ -48,14 +80,20 @@ fn applyCreate(
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = upstreams_repo.insertUpstreamRow(database, item);
|
||||
state.config_lock.unlock(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
// Ruling 12: the pool is built at startup, so the row now stored governs
|
||||
// nothing until the next one. Stored after the insert, never before — a
|
||||
// rejected url or a conflict owes no restart.
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
|
||||
plan.abandon(io);
|
||||
return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
@@ -85,16 +123,27 @@ fn applyUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err|
|
||||
var plan = switch (try planFor(state, io, arena, database, .{
|
||||
.replace = .{ .id = id, .item = item },
|
||||
})) {
|
||||
.fail => |failure| return failure,
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err| {
|
||||
plan.abandon(io);
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||
/// startup — so allowing it here would only produce a box that will not boot.
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -109,19 +158,28 @@ fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?
|
||||
},
|
||||
}
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err|
|
||||
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
|
||||
.fail => |failure| return failure,
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err| {
|
||||
plan.abandon(io);
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return null;
|
||||
}
|
||||
|
||||
const Remaining = union(enum) { missing, count: usize };
|
||||
|
||||
fn countEnabledExcept(
|
||||
database: *@import("../../storage/db.zig").Db,
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
) @import("../../storage/db.zig").Error!Remaining {
|
||||
) db.Error!Remaining {
|
||||
const rows = try upstreams_repo.listUpstreamRows(database, arena);
|
||||
var found = false;
|
||||
var left: usize = 0;
|
||||
@@ -166,7 +224,7 @@ pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
.restart_required = false,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
@@ -186,7 +244,7 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
.restart_required = false,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -219,7 +277,7 @@ test "a created upstream is stored" {
|
||||
try testing.expectEqualStrings("", row.tls_name);
|
||||
}
|
||||
|
||||
test "an upstream change never announces a reload" {
|
||||
test "an upstream change never announces a reload or a restart" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
@@ -251,7 +309,7 @@ test "a url the validator refuses never reaches the database" {
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
|
||||
}
|
||||
|
||||
test "a refused upstream write owes no restart, and an accepted one does" {
|
||||
test "no upstream write owes a restart, refused or accepted" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
@@ -264,11 +322,14 @@ test "a refused upstream write owes no restart, and an accepted one does" {
|
||||
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
|
||||
try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
// Milestone 34: an accepted write is applied in-process, so it owes no
|
||||
// restart either. `restart_pending` now has exactly two sources, and
|
||||
// neither of them is here.
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a duplicate url is a conflict" {
|
||||
@@ -287,7 +348,7 @@ test "the last enabled upstream cannot be deleted" {
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
|
||||
|
||||
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
@@ -296,7 +357,7 @@ test "the last enabled upstream cannot be deleted" {
|
||||
});
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
@@ -357,6 +418,6 @@ test "an id no upstream holds is a 404 on both update and delete" {
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
|
||||
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
|
||||
);
|
||||
}
|
||||
|
||||
+37
-16
@@ -34,6 +34,7 @@ const events_mod = @import("../storage/events.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const logging = @import("../platform/logging.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
@@ -187,28 +188,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
if (state.handler) |handler| {
|
||||
sample.dns = dnsCounters(&handler.stats);
|
||||
|
||||
if (handler.cache) |cache| {
|
||||
// The pointer is loaded under the same mutex a query loads it under,
|
||||
// never before it: a live `cache.size` or `dns.rate_limit` change
|
||||
// frees the object it displaced as soon as the swap returns.
|
||||
{
|
||||
handler.cache_mutex.lockUncancelable(io);
|
||||
defer handler.cache_mutex.unlock(io);
|
||||
sample.cache = .{
|
||||
if (handler.cache) |cache| sample.cache = .{
|
||||
.stats = cache.stats,
|
||||
.entries = cache.len(),
|
||||
.memory_bytes = cache.memoryBytes(),
|
||||
};
|
||||
}
|
||||
|
||||
if (handler.limiter) |limiter| {
|
||||
{
|
||||
handler.limiter_mutex.lockUncancelable(io);
|
||||
defer handler.limiter_mutex.unlock(io);
|
||||
sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
|
||||
if (handler.limiter) |limiter| {
|
||||
sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logger) |logger| sample.logger = .{
|
||||
.queries_dropped = logger.queries_dropped.load(.monotonic),
|
||||
.rows_written = logger.rows_written.load(.monotonic),
|
||||
.batches_gated = logger.batches_gated.load(.monotonic),
|
||||
};
|
||||
if (state.logger) |controller| {
|
||||
const reading = controller.sample(io);
|
||||
sample.logger = .{
|
||||
.queries_dropped = reading.queries_dropped,
|
||||
.rows_written = reading.rows_written,
|
||||
.batches_gated = reading.batches_gated,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.tracker) |tracker| sample.tracker = .{
|
||||
.stats = tracker.snapshotStats(io),
|
||||
@@ -259,7 +268,11 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
|
||||
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
|
||||
|
||||
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
if (state.upstreams) |owner| {
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
if (generation.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
}
|
||||
|
||||
return sample;
|
||||
}
|
||||
@@ -700,18 +713,23 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const local_tables = @import("../server/local_tables.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
/// Backs `testHandler`'s owner. File-scope because the handler is returned by
|
||||
/// value and its `*Owner` has to outlive the return; nothing here exchanges, so
|
||||
/// the generation behind it is never acquired.
|
||||
var unreachable_upstream: upstream_owner.Borrowed = .{};
|
||||
|
||||
/// A handler with no upstream reachable: every test here reads counters and
|
||||
/// never runs a query.
|
||||
fn testHandler() dns_handler.Handler {
|
||||
return .{
|
||||
.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 } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1600,9 +1618,12 @@ test "collect reads the live counters of the components it is given" {
|
||||
var queue_buf: [4]logger_mod.Entry = undefined;
|
||||
var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
query_logger.rows_written.store(90, .monotonic);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
|
||||
var tracker: clients.Tracker = .init(30);
|
||||
var retention: retention_mod.Retention = .init(.{});
|
||||
var tracker_days: retention_mod.RetentionDays = .init(30);
|
||||
var tracker: clients.Tracker = .init(&tracker_days);
|
||||
var retention_days: retention_mod.RetentionDays = .init(30);
|
||||
var retention: retention_mod.Retention = .init(&retention_days);
|
||||
var tables: local_tables.LocalTables = .empty;
|
||||
var names: client_names.Resolver = .init(&tables);
|
||||
names.stats.no_zone = 4;
|
||||
@@ -1611,7 +1632,7 @@ test "collect reads the live counters of the components it is given" {
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.handler = &handler,
|
||||
.logger = &query_logger,
|
||||
.logger = log_owner.over(&query_logger),
|
||||
.tracker = &tracker,
|
||||
.client_names = &names,
|
||||
.retention = &retention,
|
||||
|
||||
+30
-19
@@ -26,9 +26,11 @@ info:
|
||||
Prometheus scrape can never be throttled; `/api/queries/live` is
|
||||
exempt because one long-lived stream is bounded by
|
||||
`web.sse_max_connections_per_ip` instead.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones,
|
||||
clients and client prefixes take effect live. Upstreams and
|
||||
`/api/settings` are restart-required.
|
||||
- Mutations take effect live, including upstreams and `/api/settings`:
|
||||
the owner of every changed setting is told in-process by the write
|
||||
that changes it. The exceptions are the keys that create or destroy a
|
||||
socket — the listener addresses and ports, and `web.enabled` — which
|
||||
`/api/settings` reports in its `restart_required` list.
|
||||
- nxdns runs under one of two configuration authorities. Started with
|
||||
`--config=<file>`, that file is the sole declarative source, and every
|
||||
operation that writes configuration answers 403 with the same error
|
||||
@@ -1544,7 +1546,7 @@ paths:
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
post:
|
||||
summary: Add an upstream
|
||||
description: Restart-required; the running pool is not changed.
|
||||
description: Applies live; the resolver pool is rebuilt in-process.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1553,7 +1555,7 @@ paths:
|
||||
$ref: "#/components/schemas/UpstreamInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Created; takes effect on restart.
|
||||
description: Created; live at once.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1599,7 +1601,7 @@ paths:
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
put:
|
||||
summary: Update an upstream
|
||||
description: Restart-required; the running pool is not changed.
|
||||
description: Applies live; the resolver pool is rebuilt in-process.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1608,7 +1610,7 @@ paths:
|
||||
$ref: "#/components/schemas/UpstreamInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated; takes effect on restart.
|
||||
description: Updated; live at once.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1636,7 +1638,7 @@ paths:
|
||||
description: The last enabled upstream cannot be removed (409).
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted; takes effect on restart.
|
||||
description: Deleted; live at once.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
@@ -1701,10 +1703,10 @@ paths:
|
||||
summary: Read the scalar settings
|
||||
description: |
|
||||
Every stored settings key, plus the derived `web.auth_enabled`.
|
||||
`restart_required` lists every key, because all scalar settings are
|
||||
restart-required this milestone; live behavior comes from the
|
||||
resource endpoints and `/api/pause`. Passwords and hashes are never
|
||||
serialized.
|
||||
`restart_required` lists only the keys that create or destroy a
|
||||
socket — the DNS, web, DoH and DoT bind addresses, ports and
|
||||
enabled flags. Every other setting is applied in-process by the
|
||||
write that changes it. Passwords and hashes are never serialized.
|
||||
responses:
|
||||
"200":
|
||||
description: The settings and the restart-required key list.
|
||||
@@ -2756,8 +2758,12 @@ components:
|
||||
enabled: { type: boolean }
|
||||
tls_name: { type: string }
|
||||
restart_required:
|
||||
description: |
|
||||
Always false: an upstream write rebuilds the resolver pool
|
||||
in-process. The field stays in the shape so every mutation
|
||||
response answers the same question the same way.
|
||||
type: boolean
|
||||
enum: [true]
|
||||
enum: [false]
|
||||
|
||||
Pause:
|
||||
type: object
|
||||
@@ -2922,9 +2928,12 @@ components:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
Every `section.field` key that needs a restart to take effect —
|
||||
currently all of them. Whether a restart is *owed* right now is
|
||||
process state, and lives on `/api/config/status`.
|
||||
The `section.field` keys that need a restart to take effect: the
|
||||
DNS, web, DoH and DoT bind addresses, ports and enabled flags,
|
||||
and nothing else. Every key absent from this list is applied
|
||||
in-process by the write that changes it. Whether a restart is
|
||||
*owed* right now is process state, and lives on
|
||||
`/api/config/status`.
|
||||
|
||||
ConfigStatus:
|
||||
type: object
|
||||
@@ -2963,9 +2972,11 @@ components:
|
||||
type: boolean
|
||||
description: |
|
||||
True once this process has committed a configuration change that
|
||||
takes effect only at the next start — an upstream write or a
|
||||
settings key. Nothing clears it but process exit, and it is
|
||||
never persisted, so a false after a restart is the truth.
|
||||
takes effect only at the next start: a DNS, web, DoH or DoT bind
|
||||
address, port or enabled flag. No other write raises it —
|
||||
everything else, upstreams included, is applied in-process.
|
||||
Nothing clears it but process exit, and it is never persisted,
|
||||
so a false after a restart is the truth.
|
||||
|
||||
SettingsPatch:
|
||||
type: object
|
||||
|
||||
+158
-5
@@ -36,11 +36,11 @@ const events_mod = @import("../storage/events.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const listener_core = @import("../server/listener.zig");
|
||||
const local_tables_mod = @import("../server/local_tables.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const manager_mod = @import("../filter/manager.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const pause_mod = @import("../server/pause.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const query_sink = @import("../server/query_sink.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const router = @import("router.zig");
|
||||
@@ -115,9 +115,75 @@ pub const Authority = union(enum) {
|
||||
managed_file: []const u8,
|
||||
};
|
||||
|
||||
/// `web.trusted_proxies`, live. The boot text is borrowed from the loaded
|
||||
/// configuration; every replacement is an owned, immutable generation that a
|
||||
/// reader holds a shared lock on for as long as it borrows the text.
|
||||
///
|
||||
/// Taking the exclusive lock is what drains the readers: once `install`
|
||||
/// returns, nothing holds the generation it hands back, so the caller can free
|
||||
/// it. The free itself belongs to the caller and not to this type, because a
|
||||
/// publish must not do work that a retire owns.
|
||||
pub const LiveProxies = struct {
|
||||
lock: std.Io.RwLock = .init,
|
||||
text: []const u8 = "",
|
||||
owned: bool = false,
|
||||
|
||||
pub fn init(boot_text: []const u8) LiveProxies {
|
||||
return .{ .text = boot_text };
|
||||
}
|
||||
|
||||
/// The reader's hold. Release it, and do not retain `text` afterwards.
|
||||
pub const Handle = struct {
|
||||
text: []const u8,
|
||||
live: *LiveProxies,
|
||||
|
||||
pub fn release(self: Handle, io: std.Io) void {
|
||||
self.live.lock.unlockShared(io);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn acquire(self: *LiveProxies, io: std.Io) Handle {
|
||||
self.lock.lockSharedUncancelable(io);
|
||||
return .{ .text = self.text, .live = self };
|
||||
}
|
||||
|
||||
/// Takes ownership of `prepared`, which must be a `gpa` allocation, and
|
||||
/// returns the generation it replaced for the caller to free — or null
|
||||
/// when what it replaced was the borrowed boot text.
|
||||
pub fn install(self: *LiveProxies, io: std.Io, prepared: []const u8) ?[]const u8 {
|
||||
self.lock.lockUncancelable(io);
|
||||
defer self.lock.unlock(io);
|
||||
|
||||
const retired: ?[]const u8 = if (self.owned) self.text else null;
|
||||
self.text = prepared;
|
||||
self.owned = true;
|
||||
return retired;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LiveProxies, gpa: Allocator) void {
|
||||
if (self.owned) gpa.free(self.text);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// The long-lived collaborators `upstream_owner.build` needs, which are the
|
||||
/// composition root's and not the request's: the shared HTTP client every DoH
|
||||
/// leaf borrows and the one certificate bundle every DoT leaf verifies against.
|
||||
pub const UpstreamBuild = struct {
|
||||
http: *std.http.Client,
|
||||
bundle: *std.crypto.Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
};
|
||||
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
/// The live `web.trusted_proxies`. `web` above is the boot configuration
|
||||
/// and goes stale the moment `PUT /api/settings` changes the list, exactly
|
||||
/// as it does for the password: the request path reads this holder and
|
||||
/// never `web.trusted_proxies`. The composition root seeds it from the boot
|
||||
/// value, and whoever owns the `WebState` calls `proxies.deinit`.
|
||||
proxies: LiveProxies = .{},
|
||||
|
||||
/// Defaults to `.database`: a `WebState` nobody told about a managed file
|
||||
/// governs nothing declaratively, which is the safe reading — the mutation
|
||||
@@ -141,14 +207,28 @@ pub const WebState = struct {
|
||||
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
|
||||
client_names: ?*client_names.Resolver = null,
|
||||
manager: ?*manager_mod.Manager = null,
|
||||
pool: ?*pool_mod.Pool = null,
|
||||
/// The published upstream generation. A metrics or health scrape pins one
|
||||
/// for the length of its read, so a `replace` cannot free the pool it is
|
||||
/// copying out of.
|
||||
upstreams: ?*upstream_owner.Owner = null,
|
||||
/// What building a replacement upstream generation needs beyond the rows
|
||||
/// themselves. Null in a state whose upstream owner is a test's borrowed
|
||||
/// one: there is then nothing to build, and an upstream mutation applies to
|
||||
/// the database alone.
|
||||
upstream_build: ?UpstreamBuild = null,
|
||||
monitor: ?*disk_monitor.Monitor = null,
|
||||
/// The local records and forward zones the DNS path reads. The
|
||||
/// local-records and forward-zones handlers rebuild and swap them
|
||||
/// (ruling 12).
|
||||
local_tables: ?*local_tables_mod.LocalTables = null,
|
||||
logger: ?*logger_mod.Logger = null,
|
||||
/// The query logger's controller, not a `Logger`: a resize replaces the
|
||||
/// generation, and the counters a scrape reads are the controller's.
|
||||
logger: ?*logger_controller.Controller = null,
|
||||
retention: ?*retention_mod.Retention = null,
|
||||
/// The one `logging.retention_days` cell both prune passes read. Separate
|
||||
/// from `retention` above, which is one of the two readers: a settings
|
||||
/// apply stores here and moves both of them together.
|
||||
retention_days: ?*retention_mod.RetentionDays = null,
|
||||
sessions: ?*auth.Sessions = null,
|
||||
/// The password hash every auth decision reads. `web` above is the boot
|
||||
/// configuration and goes stale the moment `PUT /api/settings` changes the
|
||||
@@ -530,7 +610,15 @@ pub const Server = struct {
|
||||
|
||||
const peer = address.NetAddress.fromIp(conn.peer);
|
||||
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
|
||||
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
|
||||
// The hold ends with the verdict, before dispatch: a settings PUT takes
|
||||
// the exclusive lock from inside its own request, so a hold that lasted
|
||||
// the request would be that request waiting on itself.
|
||||
const verdict = blk: {
|
||||
const proxies = self.state.proxies.acquire(io);
|
||||
defer proxies.release(io);
|
||||
break :blk clientAddr(proxies.text, peer, forwarded_for);
|
||||
};
|
||||
const client_addr = switch (verdict) {
|
||||
.addr => |addr| addr,
|
||||
.bad_forwarded_for => {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
@@ -882,3 +970,68 @@ test "a trusted-proxy element that is not an IP literal trusts nobody" {
|
||||
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
|
||||
try testing.expect(!trustsPeer("", peer));
|
||||
}
|
||||
|
||||
/// Every generation this test installs trusts `10.0.0.1` and nothing else, so
|
||||
/// a reader that ever disagrees read a generation that was already freed.
|
||||
const proxy_generations = [_][]const u8{
|
||||
"10.0.0.1",
|
||||
"10.0.0.1, 10.0.0.2",
|
||||
"10.0.0.1,fd00::1,10.0.0.3",
|
||||
" 10.0.0.1 ",
|
||||
};
|
||||
|
||||
fn readProxiesRepeatedly(live: *LiveProxies, io: std.Io, rounds: usize, disagreed: *bool) void {
|
||||
const peer = ip("10.0.0.1");
|
||||
const stranger = ip("198.51.100.7");
|
||||
for (0..rounds) |_| {
|
||||
const held = live.acquire(io);
|
||||
defer held.release(io);
|
||||
if (!trustsPeer(held.text, peer)) disagreed.* = true;
|
||||
if (trustsPeer(held.text, stranger)) disagreed.* = true;
|
||||
}
|
||||
}
|
||||
|
||||
test "trusted proxies are replaced under concurrent request-path reads" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var live: LiveProxies = .init(proxy_generations[0]);
|
||||
defer live.deinit(testing.allocator);
|
||||
|
||||
var disagreed = false;
|
||||
var reader = try io.concurrent(readProxiesRepeatedly, .{ &live, io, 2_000, &disagreed });
|
||||
|
||||
for (0..2_000) |i| {
|
||||
const prepared = try testing.allocator.dupe(u8, proxy_generations[i % proxy_generations.len]);
|
||||
// Publish, then retire: `install` returns only once no reader holds
|
||||
// what it replaced, which is what makes this free safe.
|
||||
if (live.install(io, prepared)) |retired| testing.allocator.free(retired);
|
||||
}
|
||||
|
||||
reader.await(io);
|
||||
try testing.expect(!disagreed);
|
||||
}
|
||||
|
||||
test "the boot text is borrowed and the first install is what starts owning" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var boot_text: [8]u8 = "10.0.0.1".*;
|
||||
var live: LiveProxies = .init(&boot_text);
|
||||
defer live.deinit(testing.allocator);
|
||||
|
||||
// Nothing to retire: the boot text belongs to the loaded configuration.
|
||||
const first = try testing.allocator.dupe(u8, "10.0.0.2");
|
||||
try testing.expect(live.install(io, first) == null);
|
||||
|
||||
const second = try testing.allocator.dupe(u8, "10.0.0.3");
|
||||
const retired = live.install(io, second).?;
|
||||
try testing.expectEqualStrings("10.0.0.2", retired);
|
||||
testing.allocator.free(retired);
|
||||
|
||||
const held = live.acquire(io);
|
||||
defer held.release(io);
|
||||
try testing.expectEqualStrings("10.0.0.3", held.text);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user