milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
+108
-20
@@ -534,6 +534,35 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
};
|
||||
defer if (tcp4) |*s| s.deinit(gpa, io);
|
||||
|
||||
// Ruling 13: `/metrics` sums each transport's listeners into one family, so
|
||||
// the web state carries pointers to whichever of the four came up. The
|
||||
// arrays are declared here rather than beside `web_state` because a
|
||||
// listener that failed to bind is not in them; `group.cancel` below runs
|
||||
// before this frame is released, so no web task can outlive them.
|
||||
var udp_listeners: [2]*udp_server.UdpServer = undefined;
|
||||
var udp_count: usize = 0;
|
||||
if (udp6) |*s| {
|
||||
udp_listeners[udp_count] = s;
|
||||
udp_count += 1;
|
||||
}
|
||||
if (udp4) |*s| {
|
||||
udp_listeners[udp_count] = s;
|
||||
udp_count += 1;
|
||||
}
|
||||
web_state.udp_listeners = udp_listeners[0..udp_count];
|
||||
|
||||
var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
|
||||
var tcp_count: usize = 0;
|
||||
if (tcp6) |*s| {
|
||||
tcp_listeners[tcp_count] = s;
|
||||
tcp_count += 1;
|
||||
}
|
||||
if (tcp4) |*s| {
|
||||
tcp_listeners[tcp_count] = s;
|
||||
tcp_count += 1;
|
||||
}
|
||||
web_state.tcp_listeners = tcp_listeners[0..tcp_count];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// run
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -558,11 +587,11 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
|
||||
const gate: ?*disk_monitor.Monitor = &monitor;
|
||||
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
|
||||
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db });
|
||||
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate });
|
||||
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
|
||||
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
|
||||
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
||||
try group.concurrent(io, runMaintenance, .{ &h, io });
|
||||
try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });
|
||||
|
||||
// Started last (ruling 26), canceled by the same `group.cancel`; its inner
|
||||
// connection group is canceled, not awaited (ruling 4), so an idle
|
||||
@@ -699,37 +728,57 @@ fn bindDot(
|
||||
// background maintenance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sweeps the cache and the rate-limiter table. Both are guarded by mutexes the
|
||||
/// handler owns, because the handler is what contends for them; the sweeps live
|
||||
/// here because walking a whole table is not work a query should pay for.
|
||||
/// Sweeps the cache, the DNS rate-limiter table and the API rate-limiter table.
|
||||
/// The first two are guarded by mutexes the handler owns, because the handler is
|
||||
/// what contends for them; the sweeps live here because walking a whole table is
|
||||
/// not work a query should pay for. The API limiter needs the same schedule for
|
||||
/// the same reason: its table holds 4096 addresses, and once it is full every
|
||||
/// unknown address pays an eviction scan.
|
||||
///
|
||||
/// The locks are taken cancelably: unlike `handle`, this loop has an error
|
||||
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
|
||||
/// the query path holds a lock should not wait for it.
|
||||
fn runMaintenance(h: *handler.Handler, io: std.Io) std.Io.Cancelable!void {
|
||||
fn runMaintenance(
|
||||
h: *handler.Handler,
|
||||
api: ?*api_limiter.ApiLimiter,
|
||||
io: std.Io,
|
||||
) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(maintenance_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
|
||||
if (h.cache) |cache| {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
try maintenanceOnce(h, api, io);
|
||||
}
|
||||
}
|
||||
|
||||
/// One sweep of each table. Separate from the loop so a test can run a pass
|
||||
/// without waiting out `maintenance_interval_s`.
|
||||
fn maintenanceOnce(
|
||||
h: *handler.Handler,
|
||||
api: ?*api_limiter.ApiLimiter,
|
||||
io: std.Io,
|
||||
) std.Io.Cancelable!void {
|
||||
if (h.cache) |cache| {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// The API limiter takes its own mutex, unlike the two above, which are the
|
||||
// handler's. Nothing here holds a lock across the call.
|
||||
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1278,3 +1327,42 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
const value = addr orelse return;
|
||||
w.print(" {s} {f}", .{ which, value }) catch {};
|
||||
}
|
||||
|
||||
const test_address = @import("platform/address.zig");
|
||||
|
||||
test "one maintenance pass drops the api limiter's stale buckets" {
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var limiter = try api_limiter.ApiLimiter.init(std.testing.allocator, .{
|
||||
.rate_per_min = 60,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// A bucket last touched a full window ago has refilled to capacity, so a
|
||||
// fresh bucket would answer identically and the sweep may drop it. The
|
||||
// pass reads the real `.awake` clock, so the bucket is aged by dating the
|
||||
// request rather than by waiting.
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
const window_ns = @as(i96, api_limiter.window_seconds) * std.time.ns_per_s;
|
||||
const client: test_address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 10 } };
|
||||
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
|
||||
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
|
||||
|
||||
var h: handler.Handler = .{
|
||||
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.blocking = .{ .mode = .zero, .ttl = 5 },
|
||||
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
};
|
||||
try maintenanceOnce(&h, &limiter, io);
|
||||
|
||||
// Before ruling 12 the sweep had no production caller, so the table kept
|
||||
// this bucket until the process restarted.
|
||||
try std.testing.expectEqual(@as(u32, 0), limiter.trackedClients(io));
|
||||
|
||||
// A limiter the app did not build is not a reason for the pass to fail.
|
||||
try maintenanceOnce(&h, null, io);
|
||||
}
|
||||
|
||||
Vendored
+28
@@ -103,8 +103,15 @@ pub const Class = struct {
|
||||
///
|
||||
/// `negative_ttl_max == 0` disables negative caching outright: the response is
|
||||
/// not stored at all (milestone-6 ruling 6).
|
||||
///
|
||||
/// A TC=1 response is never cached, whatever its rcode. It is a partial message
|
||||
/// whose tail the sender dropped, and RFC 2181 §9 forbids keeping one: served
|
||||
/// from the cache the TC bit reaches a client that has no truncation to recover
|
||||
/// from, and a client that retries over TCP is answered with the same truncated
|
||||
/// bytes again.
|
||||
pub fn classify(response: []const u8, negative_ttl_max: u32) ?Class {
|
||||
const p = packet.parse(response) catch return null;
|
||||
if (p.header.flags.tc) return null;
|
||||
const rcode = p.header.flags.rcode;
|
||||
|
||||
if (rcode == .no_error and p.header.ancount > 0) {
|
||||
@@ -643,6 +650,27 @@ test "classify refuses a zero ttl and unparsable bytes" {
|
||||
try testing.expectEqual(@as(?Class, null), classify(response[0 .. response.len - 1], 3600));
|
||||
}
|
||||
|
||||
/// The TC bit is bit 9 of the flags word, which is the second 16-bit word of
|
||||
/// the header.
|
||||
fn setTruncated(bytes: []u8) []u8 {
|
||||
const flags = std.mem.readInt(u16, bytes[2..4], .big);
|
||||
std.mem.writeInt(u16, bytes[2..4], flags | 0x0200, .big);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test "classify refuses a truncated response whatever its rcode" {
|
||||
var buf: [512]u8 = undefined;
|
||||
const answer = try buildAnswer(&buf, &.{300});
|
||||
try testing.expectEqual(@as(u32, 300), classify(answer, 3600).?.ttl_seconds);
|
||||
try testing.expectEqual(@as(?Class, null), classify(setTruncated(answer), 3600));
|
||||
|
||||
// The negative path reads the same bit: a truncated NXDOMAIN is a partial
|
||||
// message too.
|
||||
var nx_buf: [nxdomain_bytes.len]u8 = undefined;
|
||||
@memcpy(&nx_buf, nxdomain_bytes);
|
||||
try testing.expectEqual(@as(?Class, null), classify(setTruncated(&nx_buf), 3600));
|
||||
}
|
||||
|
||||
test "classify caches NXDOMAIN with the SOA minimum" {
|
||||
const class = classify(nxdomain_bytes, 3600).?;
|
||||
try testing.expectEqual(@as(u32, 600), class.ttl_seconds);
|
||||
|
||||
@@ -27,6 +27,7 @@ const migrations = @import("../storage/migrations.zig");
|
||||
const context = @import("../storage/repositories/context.zig");
|
||||
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
||||
const local_repo = @import("../storage/repositories/local_repo.zig");
|
||||
const rules_repo = @import("../storage/repositories/rules_repo.zig");
|
||||
const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
||||
|
||||
const compiler = @import("compiler.zig");
|
||||
@@ -351,7 +352,15 @@ const Env = struct {
|
||||
// fixtures: the loopback http server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Route = enum(u8) { body, redirect, not_found, oversize, chunked };
|
||||
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall };
|
||||
|
||||
/// How long the `stall` route holds a reply open when nothing releases it.
|
||||
///
|
||||
/// The cases that use it prove a writer ran *beside* a parked download rather
|
||||
/// than behind it, so nothing depends on this number being large. It exists so
|
||||
/// that a regression fails the run in a second or two instead of hanging it,
|
||||
/// which is what an unbounded stall against a single lock would do.
|
||||
const stall_budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1_500), .clock = .awake };
|
||||
|
||||
const redirect_path = "/redirected.txt";
|
||||
|
||||
@@ -396,6 +405,16 @@ const HttpFixture = struct {
|
||||
/// reports a buffered part as written and would otherwise hide a fixture
|
||||
/// that sent everything in one go.
|
||||
flushed_parts: std.atomic.Value(u32),
|
||||
/// Set by the `stall` route once the reply is open and the body has
|
||||
/// stopped. A test that waits for this knows the refresh is inside its
|
||||
/// download and not on its way there.
|
||||
stall_reached: std.Io.Event,
|
||||
/// Set by the test to let the `stall` route finish its reply.
|
||||
stall_release: std.Io.Event,
|
||||
/// Whether the `stall` route has stopped waiting — by release or by
|
||||
/// `stall_budget` expiring. A writer that returns while this is still false
|
||||
/// returned with the download unfinished, which is the whole claim.
|
||||
stall_resumed: std.atomic.Value(bool),
|
||||
|
||||
fn init(io: std.Io, body: []const u8) !HttpFixture {
|
||||
const local: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
@@ -404,6 +423,9 @@ const HttpFixture = struct {
|
||||
.body = body,
|
||||
.route = .init(@intFromEnum(Route.body)),
|
||||
.flushed_parts = .init(0),
|
||||
.stall_reached = .unset,
|
||||
.stall_release = .unset,
|
||||
.stall_resumed = .init(false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -435,14 +457,14 @@ const HttpFixture = struct {
|
||||
var http: std.http.Server = .init(&reader.interface, &writer.interface);
|
||||
|
||||
var request = http.receiveHead() catch continue;
|
||||
self.respond(&request) catch continue;
|
||||
self.respond(io, &request) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reply closes the connection. A keep-alive reply would leave the
|
||||
/// fetcher holding the connection open while this server waits to accept a
|
||||
/// second one that never comes (milestone-5 spec, S9 note from S8).
|
||||
fn respond(self: *HttpFixture, request: *std.http.Server.Request) !void {
|
||||
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
|
||||
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
|
||||
.body => try request.respond(self.body, .{ .keep_alive = false }),
|
||||
.redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
|
||||
@@ -463,9 +485,35 @@ const HttpFixture = struct {
|
||||
.extra_headers = &.{.{ .name = "content-length", .value = oversize_length }},
|
||||
}),
|
||||
.chunked => try self.respondChunked(request),
|
||||
.stall => try self.respondStalled(io, request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the reply, sends the first third of the body and then stops until
|
||||
/// `stall_release` is set or `stall_budget` runs out. The fetcher is inside
|
||||
/// its body pump for the whole of that, which is what a slow upstream looks
|
||||
/// like to a refresh pass — and what the refresh used to hold the writer
|
||||
/// lock across.
|
||||
fn respondStalled(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
|
||||
var send_buf: [4096]u8 = undefined;
|
||||
var stream = try request.respondStreaming(&send_buf, .{
|
||||
.respond_options = .{ .keep_alive = false },
|
||||
});
|
||||
const parts = thirds(self.body);
|
||||
try stream.writer.writeAll(parts[0]);
|
||||
try stream.flush();
|
||||
|
||||
self.stall_reached.set(io);
|
||||
// A timeout and a cancellation are both "stop waiting": the writes
|
||||
// below then either finish the reply or fail into `serve`'s `continue`.
|
||||
self.stall_release.waitTimeout(io, .{ .duration = stall_budget }) catch {};
|
||||
self.stall_resumed.store(true, .release);
|
||||
|
||||
try stream.writer.writeAll(parts[1]);
|
||||
try stream.writer.writeAll(parts[2]);
|
||||
try stream.end();
|
||||
}
|
||||
|
||||
/// Streams the body in three flushed parts instead of one `respond`
|
||||
/// (milestone-15 ruling 7). Every other arm sends ~130 bytes in a single
|
||||
/// write, which the fetcher consumes in one read: the loop that 35f2324
|
||||
@@ -1167,6 +1215,163 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
try testing.expect(decision.blocked);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10c–10d: a refresh in flight against the writers beside it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RefreshAllTask = struct {
|
||||
mgr: *manager.Manager,
|
||||
/// Read by the test to tell "the pass is still downloading" from "the pass
|
||||
/// has finished". Atomic because the two run on different tasks.
|
||||
done: std.atomic.Value(bool) = .init(false),
|
||||
result: manager.Manager.Error!void = {},
|
||||
|
||||
fn run(self: *RefreshAllTask, io: std.Io) void {
|
||||
self.result = self.mgr.refreshAll(io);
|
||||
self.done.store(true, .release);
|
||||
}
|
||||
};
|
||||
|
||||
const RefreshSourceTask = struct {
|
||||
mgr: *manager.Manager,
|
||||
row: sources_repo.SourceRow,
|
||||
result: manager.Manager.Error!bool = false,
|
||||
|
||||
fn run(self: *RefreshSourceTask, io: std.Io) void {
|
||||
self.result = self.mgr.refreshSource(io, self.row);
|
||||
}
|
||||
};
|
||||
|
||||
/// One block rule in the `default` group, written the way the web handler
|
||||
/// writes it.
|
||||
fn insertBlockRule(database: *db.Db, pattern: []const u8) !void {
|
||||
const group_id = (try groups_repo.groupId(database, "default")) orelse
|
||||
return error.TestGroupMissing;
|
||||
var group_ids: context.IdMap = .empty;
|
||||
defer group_ids.deinit(testing.allocator);
|
||||
try group_ids.put(testing.allocator, "default", group_id);
|
||||
|
||||
try rules_repo.insertRule(database, .{
|
||||
.group = "default",
|
||||
.pattern = pattern,
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
}, .{ .group_ids = &group_ids });
|
||||
}
|
||||
|
||||
test "10c: a rule save completes while a refresh is parked in its download" {
|
||||
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);
|
||||
fixture.setRoute(.stall);
|
||||
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);
|
||||
const id = try seedSource(&env.database, url);
|
||||
try env.mgr.reload(io);
|
||||
|
||||
var task: RefreshAllTask = .{ .mgr = &env.mgr };
|
||||
var tasks: std.Io.Group = .init;
|
||||
defer tasks.cancel(io);
|
||||
try tasks.concurrent(io, RefreshAllTask.run, .{ &task, io });
|
||||
|
||||
// The pass is now inside the download, holding the refresh lock and
|
||||
// nothing else.
|
||||
try fixture.stall_reached.wait(io);
|
||||
|
||||
// What the web does on every rule save: write the row, then reload. Behind
|
||||
// one lock this waits out the download — on a request path with no timeout,
|
||||
// occupying one of 64 web slots.
|
||||
try insertBlockRule(&env.database, "blocked.example.com");
|
||||
try env.mgr.reload(io);
|
||||
|
||||
// Not a duration: the reply is still open, so the download this reload
|
||||
// returned across has not finished and cannot have been waited out. Behind
|
||||
// one lock the reload would have returned only after `stall_budget` gave
|
||||
// the reply up.
|
||||
const ran_beside = !fixture.stall_resumed.load(.acquire) and !task.done.load(.acquire);
|
||||
fixture.stall_release.set(io);
|
||||
|
||||
try tasks.await(io);
|
||||
try task.result;
|
||||
try testing.expect(ran_beside);
|
||||
|
||||
// Both writers landed: the rule is enforced and the refresh finished the
|
||||
// download the reload ran across.
|
||||
const decision, _ = try env.evaluate("blocked.example.com");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(matcher.Reason.rule_block_exact, decision.reason);
|
||||
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
|
||||
}
|
||||
|
||||
test "10d: a source deleted mid-refresh does not take the refresh's temporary files" {
|
||||
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);
|
||||
fixture.setRoute(.stall);
|
||||
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);
|
||||
const id = try seedSource(&env.database, url);
|
||||
try env.mgr.reload(io);
|
||||
|
||||
// A `SourceRow` borrows the strings this list owns, and the refresh task
|
||||
// reads them for as long as it runs. The list outlives the task.
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
var task: RefreshSourceTask = .{ .mgr = &env.mgr, .row = try rows.byUrl(url) };
|
||||
|
||||
var tasks: std.Io.Group = .init;
|
||||
defer tasks.cancel(io);
|
||||
try tasks.concurrent(io, RefreshSourceTask.run, .{ &task, io });
|
||||
|
||||
try fixture.stall_reached.wait(io);
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
var raw_buf: [64]u8 = undefined;
|
||||
const raw_name = try std.fmt.bufPrint(&raw_buf, "{d}.raw.tmp", .{id});
|
||||
try dir.access(io, raw_name, .{});
|
||||
|
||||
// The `DELETE /api/blocklists/{id}` sequence: drop the row, then sweep the
|
||||
// directory. The row is gone, so nothing in the sweep itself would spare
|
||||
// this source's raw download — only the refresh lock does, by making the
|
||||
// sweep wait for the pass instead of running across it.
|
||||
try sources_repo.deleteSource(&env.database, id);
|
||||
try env.mgr.pruneOrphans(io);
|
||||
|
||||
try tasks.await(io);
|
||||
// True only if the compile found the raw file the download had written.
|
||||
try testing.expect(try task.result);
|
||||
|
||||
// The sweep did run, once the refresh was out of the way: the compiled
|
||||
// files of a source with no row are gone, and so is every temporary.
|
||||
var it = dir.iterate();
|
||||
while (try it.next(io)) |entry| {
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp"));
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".list"));
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".wild"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11–12: local records, from the database to the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+333
-80
@@ -24,16 +24,28 @@
|
||||
//! socket and no file, so the uncancelable lock forms are used: a lock this
|
||||
//! code takes is always released within a few instructions.
|
||||
//!
|
||||
//! A second lock, `writer_lock`, serializes the writers against each other:
|
||||
//! `reload`, `refreshSource`, `refreshAll`, the startup pass and
|
||||
//! `pruneOrphans`. Two concurrent reloads would otherwise compute the same
|
||||
//! generation and each destroy a snapshot the other had just published, and two
|
||||
//! concurrent refreshes would share the fetcher's buffers and, for one source,
|
||||
//! the same `.raw.tmp` / `.list.tmp` / `.wild.tmp` paths. It is held across
|
||||
//! downloads and compiles, so it is a plain mutex rather than the RCU lock:
|
||||
//! readers must never wait behind a refresh. The public entry points take it;
|
||||
//! the `*Locked` bodies assume it and never take it again, because it is not
|
||||
//! reentrant.
|
||||
//! Two more locks serialize the writers, and they divide the work by how long
|
||||
//! it takes.
|
||||
//!
|
||||
//! `writer_lock` covers what a writer does to the *published* state: build a
|
||||
//! snapshot, install the compiled files, write the runtime columns, record a
|
||||
//! status. Two concurrent reloads would otherwise compute the same generation
|
||||
//! and each destroy a snapshot the other had just published. Every section it
|
||||
//! guards is bounded by local work — a read of the compiled files at worst —
|
||||
//! so a web mutation that ends in `reload` never waits out a download.
|
||||
//!
|
||||
//! `refresh_lock` covers what a writer does *before* it has anything to
|
||||
//! publish: the download of one source, at up to 300 s each, and the compile
|
||||
//! that follows it. It also covers blocklist-directory maintenance, because
|
||||
//! those stages are the only writers of `.raw.tmp` / `.list.tmp` /
|
||||
//! `.wild.tmp` and `pruneOrphans` must not sweep the temporaries of a refresh
|
||||
//! that is still running. Two concurrent refreshes would share the fetcher's
|
||||
//! buffers and, for one source, the same temporary paths.
|
||||
//!
|
||||
//! **Lock ordering: `refresh_lock` is never acquired while `writer_lock` is
|
||||
//! held.** A path that needs both takes `refresh_lock` first. The public entry
|
||||
//! points take what they need; the `*Locked` bodies assume it and never take
|
||||
//! it again, because neither mutex is reentrant.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -251,8 +263,21 @@ pub const Manager = struct {
|
||||
total_budget: std.Io.Clock.Duration,
|
||||
|
||||
lock: std.Io.RwLock,
|
||||
/// Serializes the writers against each other. Never taken by a reader.
|
||||
/// Serializes everything that changes the published state — the snapshot,
|
||||
/// the compiled files, the runtime columns, the status table — against
|
||||
/// every other writer. Never taken by a reader.
|
||||
///
|
||||
/// Lock ordering: `refresh_lock` is never acquired while this is held. A
|
||||
/// caller that needs both takes `refresh_lock` first.
|
||||
writer_lock: std.Io.Mutex,
|
||||
/// Serializes refresh passes against each other, and against the
|
||||
/// blocklist-directory maintenance in `pruneOrphans`. Held across a
|
||||
/// download and a compile, which `writer_lock` deliberately is not, so an
|
||||
/// unrelated `reload` never waits out a 300-second fetch.
|
||||
///
|
||||
/// Lock ordering: this is taken first, and never while `writer_lock` is
|
||||
/// held.
|
||||
refresh_lock: std.Io.Mutex,
|
||||
current: ?*matcher.Snapshot,
|
||||
generation: u64,
|
||||
statuses: []SourceStatus,
|
||||
@@ -301,6 +326,7 @@ pub const Manager = struct {
|
||||
.total_budget = total_budget,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
.refresh_lock = .init,
|
||||
.current = null,
|
||||
.generation = 0,
|
||||
.statuses = &.{},
|
||||
@@ -538,14 +564,21 @@ pub const Manager = struct {
|
||||
// failing append: `bodies` owns each one from the moment it is read.
|
||||
try bodies.ensureUnusedCapacity(self.gpa, 2);
|
||||
|
||||
// `error.Canceled` is the one-shot signal that this task is being torn
|
||||
// down, and it is consumed by whoever catches it. Recording it as a
|
||||
// load failure would spend it on a status row that reads "Canceled",
|
||||
// publish a snapshot with this source missing, and let the shutdown
|
||||
// carry on as if nothing had asked it to stop.
|
||||
const list_bytes = dir.readFileAlloc(io, list_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
|
||||
if (err == error.OutOfMemory) return error.OutOfMemory;
|
||||
if (err == error.Canceled) return error.Canceled;
|
||||
return loadFailure(row, list_name, err);
|
||||
};
|
||||
bodies.appendAssumeCapacity(list_bytes);
|
||||
|
||||
const wild_bytes = dir.readFileAlloc(io, wild_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
|
||||
if (err == error.OutOfMemory) return error.OutOfMemory;
|
||||
if (err == error.Canceled) return error.Canceled;
|
||||
return loadFailure(row, wild_name, err);
|
||||
};
|
||||
bodies.appendAssumeCapacity(wild_bytes);
|
||||
@@ -580,11 +613,13 @@ pub const Manager = struct {
|
||||
/// before it refreshes anything, which is why it is the entry point Phase 8
|
||||
/// and the scheduler use.
|
||||
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
self.refresh_lock.lockUncancelable(io);
|
||||
defer self.refresh_lock.unlock(io);
|
||||
return self.refreshSourceLocked(io, row);
|
||||
}
|
||||
|
||||
/// Assumes `refresh_lock`. Takes `writer_lock` itself, for the publish half
|
||||
/// alone.
|
||||
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
|
||||
// The previous entry describes the compiled files that are still on
|
||||
// disk, and a failed refresh leaves them serving. Starting from a blank
|
||||
@@ -594,41 +629,6 @@ pub const Manager = struct {
|
||||
status.setUrl(row.url);
|
||||
status.last_attempt = std.Io.Clock.real.now(io).toSeconds();
|
||||
|
||||
const replaced = try self.refreshOne(io, row, &status);
|
||||
self.commitStatus(io, status);
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/// Every enabled source, one at a time, then one `reload`. A failing source
|
||||
/// never stops the pass: it would hide every source behind it.
|
||||
///
|
||||
/// This returns an error only when nothing could be done at all — out of
|
||||
/// memory, an unreachable database, an unusable blocklist directory. A
|
||||
/// source that failed to download or compile is a successful pass with a
|
||||
/// non-`ok` status.
|
||||
pub fn refreshAll(self: *Manager, io: std.Io) Error!void {
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
|
||||
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
|
||||
defer rows.deinit(self.gpa);
|
||||
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
||||
|
||||
try self.syncStatuses(io, rows.items);
|
||||
|
||||
for (rows.items) |row| {
|
||||
if (!row.enabled) continue;
|
||||
_ = try self.refreshSourceLocked(io, row);
|
||||
}
|
||||
return self.reloadLocked(io);
|
||||
}
|
||||
|
||||
fn refreshOne(
|
||||
self: *Manager,
|
||||
io: std.Io,
|
||||
row: sources_repo.SourceRow,
|
||||
status: *SourceStatus,
|
||||
) Error!bool {
|
||||
var dir = try self.openDir(io, .{});
|
||||
defer dir.close(io);
|
||||
|
||||
@@ -647,12 +647,80 @@ pub const Manager = struct {
|
||||
defer self.deleteQuietly(io, dir, list_tmp);
|
||||
defer self.deleteQuietly(io, dir, wild_tmp);
|
||||
|
||||
// The half that takes the time: one download of up to `total_budget`
|
||||
// and one compile of everything it returned. `refresh_lock` alone is
|
||||
// held here, so a rule save, a settings change or any other web
|
||||
// mutation that ends in `reload` runs beside it instead of behind it.
|
||||
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, list_tmp, wild_tmp);
|
||||
|
||||
// The half that publishes. The compiled files, the runtime columns and
|
||||
// the status entry land under one `writer_lock`, so a reload never
|
||||
// reads new files beside a status entry describing the previous ones.
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
|
||||
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, list_tmp, wild_tmp);
|
||||
self.commitStatus(io, status);
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/// Every enabled source, one at a time, then one `reload`. A failing source
|
||||
/// never stops the pass: it would hide every source behind it.
|
||||
///
|
||||
/// This returns an error only when nothing could be done at all — out of
|
||||
/// memory, an unreachable database, an unusable blocklist directory. A
|
||||
/// source that failed to download or compile is a successful pass with a
|
||||
/// non-`ok` status.
|
||||
pub fn refreshAll(self: *Manager, io: std.Io) Error!void {
|
||||
self.refresh_lock.lockUncancelable(io);
|
||||
defer self.refresh_lock.unlock(io);
|
||||
|
||||
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
|
||||
defer rows.deinit(self.gpa);
|
||||
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
||||
|
||||
try self.syncStatuses(io, rows.items);
|
||||
|
||||
for (rows.items) |row| {
|
||||
if (!row.enabled) continue;
|
||||
_ = try self.refreshSourceLocked(io, row);
|
||||
}
|
||||
// `reload` takes `writer_lock`, which the pass has been careful not to
|
||||
// hold: the order is `refresh_lock` first, always.
|
||||
return self.reload(io);
|
||||
}
|
||||
|
||||
/// What the fetch-and-compile half of a refresh produced. `.failed` needs
|
||||
/// no publish and has already recorded why in the status entry.
|
||||
const Prepared = union(enum) {
|
||||
failed,
|
||||
compiled: struct {
|
||||
format: parsers.Format,
|
||||
result: compiler.Result,
|
||||
},
|
||||
};
|
||||
|
||||
/// Downloads one source and compiles it into the temporary files.
|
||||
///
|
||||
/// Assumes `refresh_lock` and must not be called with `writer_lock` held:
|
||||
/// this is the part that takes seconds, and nothing here touches the
|
||||
/// published state.
|
||||
fn prepareRefresh(
|
||||
self: *Manager,
|
||||
io: std.Io,
|
||||
dir: std.Io.Dir,
|
||||
row: sources_repo.SourceRow,
|
||||
status: *SourceStatus,
|
||||
raw_name: []const u8,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
) Error!Prepared {
|
||||
self.download(io, dir, raw_name, row) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
self.reportFetchFailure(row, status, err);
|
||||
return false;
|
||||
return .failed;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -661,7 +729,7 @@ pub const Manager = struct {
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
self.reportCompileFailure(row, status, err);
|
||||
return false;
|
||||
return .failed;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -670,15 +738,38 @@ pub const Manager = struct {
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
self.reportCompileFailure(row, status, err);
|
||||
return false;
|
||||
return .failed;
|
||||
},
|
||||
};
|
||||
|
||||
if (rejectedWithoutEntries(result.counts)) {
|
||||
self.reportEmptyCompile(row, status, result.counts);
|
||||
return false;
|
||||
return .failed;
|
||||
}
|
||||
|
||||
return .{ .compiled = .{ .format = format, .result = result } };
|
||||
}
|
||||
|
||||
/// Installs what `prepareRefresh` produced and writes the runtime columns.
|
||||
/// Returns `true` only when the compiled files were replaced.
|
||||
///
|
||||
/// Assumes `writer_lock`. Reading the files on disk belongs here and not in
|
||||
/// the half above: they are published under this lock.
|
||||
fn publishRefresh(
|
||||
self: *Manager,
|
||||
io: std.Io,
|
||||
dir: std.Io.Dir,
|
||||
row: sources_repo.SourceRow,
|
||||
status: *SourceStatus,
|
||||
prepared: Prepared,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
) Error!bool {
|
||||
const compiled = switch (prepared) {
|
||||
.failed => return false,
|
||||
.compiled => |value| value,
|
||||
};
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
|
||||
// Recompiling identical content into new files would invalidate the
|
||||
@@ -688,7 +779,9 @@ pub const Manager = struct {
|
||||
// them, and skipping the rewrite here would leave filtering off for
|
||||
// good.
|
||||
if (row.checksum) |stored| {
|
||||
if (std.mem.eql(u8, stored, &result.checksum) and self.diskBodiesMatch(io, dir, row.id, stored)) {
|
||||
if (std.mem.eql(u8, stored, &compiled.result.checksum) and
|
||||
self.diskBodiesMatch(io, dir, row.id, stored))
|
||||
{
|
||||
try sources_repo.updateSourceStats(self.database, row.id, .{
|
||||
.last_updated = now,
|
||||
.domain_count = row.domain_count,
|
||||
@@ -696,17 +789,17 @@ pub const Manager = struct {
|
||||
.skipped_regex_count = row.skipped_regex_count,
|
||||
.checksum = stored,
|
||||
});
|
||||
status.succeed(now, result.counts);
|
||||
status.succeed(now, compiled.result.counts);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const header: Header = .{
|
||||
.url = row.url,
|
||||
.format = format,
|
||||
.format = compiled.format,
|
||||
.fetched_at = now,
|
||||
.counts = result.counts,
|
||||
.checksum = &result.checksum,
|
||||
.counts = compiled.result.counts,
|
||||
.checksum = &compiled.result.checksum,
|
||||
};
|
||||
self.publish(io, dir, row.id, header, list_tmp, wild_tmp) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
@@ -719,12 +812,12 @@ pub const Manager = struct {
|
||||
|
||||
try sources_repo.updateSourceStats(self.database, row.id, .{
|
||||
.last_updated = now,
|
||||
.domain_count = result.counts.domains,
|
||||
.wildcard_count = result.counts.wildcards,
|
||||
.skipped_regex_count = result.counts.skipped_regex,
|
||||
.checksum = &result.checksum,
|
||||
.domain_count = compiled.result.counts.domains,
|
||||
.wildcard_count = compiled.result.counts.wildcards,
|
||||
.skipped_regex_count = compiled.result.counts.skipped_regex,
|
||||
.checksum = &compiled.result.checksum,
|
||||
});
|
||||
status.succeed(now, result.counts);
|
||||
status.succeed(now, compiled.result.counts);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1034,8 +1127,8 @@ pub const Manager = struct {
|
||||
/// it — let alone the server. Cancellation is the one outcome that
|
||||
/// propagates, because it means shutdown.
|
||||
///
|
||||
/// Taken from outside every `*Locked` body: `pruneOrphans` takes
|
||||
/// `writer_lock` itself and the mutex is not reentrant.
|
||||
/// Taken from outside every `*Locked` body: `pruneOrphans` takes both
|
||||
/// writer mutexes itself and neither is reentrant.
|
||||
fn sweepOrphans(self: *Manager, io: std.Io) std.Io.Cancelable!void {
|
||||
self.pruneOrphans(io) catch |err| switch (err) {
|
||||
error.Canceled => return error.Canceled,
|
||||
@@ -1069,13 +1162,13 @@ pub const Manager = struct {
|
||||
}
|
||||
|
||||
fn startupPass(self: *Manager, io: std.Io) Error!void {
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
self.refresh_lock.lockUncancelable(io);
|
||||
defer self.refresh_lock.unlock(io);
|
||||
|
||||
// Ahead of the gate on purpose: loading the compiled files that already
|
||||
// exist is a read. A full disk must not cost the household its
|
||||
// filtering as well as its downloads.
|
||||
try self.reloadLocked(io);
|
||||
try self.reload(io);
|
||||
|
||||
if (self.refreshGated()) return;
|
||||
|
||||
@@ -1090,7 +1183,7 @@ pub const Manager = struct {
|
||||
if (!self.needsRefresh(io, row, now)) continue;
|
||||
if (try self.refreshSourceLocked(io, row)) refreshed = true;
|
||||
}
|
||||
if (refreshed) try self.reloadLocked(io);
|
||||
if (refreshed) try self.reload(io);
|
||||
}
|
||||
|
||||
fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool {
|
||||
@@ -1128,10 +1221,19 @@ pub const Manager = struct {
|
||||
/// `<data_dir>/blocklists/` if nothing has yet, and an empty directory
|
||||
/// sweeps to nothing.
|
||||
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void {
|
||||
// Every path that writes a temporary holds this lock too, so the sweep
|
||||
// never reads a directory a refresh is halfway through. The temporaries
|
||||
// it can see therefore belong to a finished or a dead refresh, and only
|
||||
// those of a source with no row are removed.
|
||||
// `refresh_lock` first, and for the reason it exists: the download and
|
||||
// the compile are the only writers of `.raw.tmp`, `.list.tmp` and
|
||||
// `.wild.tmp`, and they hold it for as long as they run. Without it
|
||||
// here, a source deleted through the API would sweep the temporaries of
|
||||
// a refresh still writing them — the row is gone, so nothing else in
|
||||
// this function would spare them — and the pass would fail on a raw
|
||||
// file that vanished under it.
|
||||
//
|
||||
// `writer_lock` second, in the one order this file ever takes them,
|
||||
// because the rows this reads and the compiled files it deletes are
|
||||
// what a reload is reading.
|
||||
self.refresh_lock.lockUncancelable(io);
|
||||
defer self.refresh_lock.unlock(io);
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
|
||||
@@ -1247,6 +1349,12 @@ pub const Manager = struct {
|
||||
entry.loaded = loaded;
|
||||
return;
|
||||
}
|
||||
// No entry of that id: the source was added or deleted between the
|
||||
// table this pass started from and this commit. The outcome is lost
|
||||
// either way — the next reload rebuilds the table from the rows — but
|
||||
// a failure that disappears without a line is the one thing milestone
|
||||
// 5 says never happens.
|
||||
log.warn("blocklist source {d}: no status entry to record the refresh outcome in", .{status.id});
|
||||
}
|
||||
|
||||
/// One id per group, in `listGroups` order.
|
||||
@@ -1485,10 +1593,10 @@ const source_file_suffixes = [_][]const u8{ ".list.tmp", ".wild.tmp", ".raw.tmp"
|
||||
/// The three temporaries count. A refresh that dies between writing one and
|
||||
/// renaming it leaves a file no later refresh reuses and no `defer` reaches, so
|
||||
/// excluding them from the sweep means nothing ever removes them. Matching them
|
||||
/// is safe because `pruneOrphans` holds `writer_lock` for its whole body: every
|
||||
/// path that creates a temporary runs under that same lock, so no refresh is in
|
||||
/// flight while the sweep reads the directory, and a temporary the sweep sees
|
||||
/// belonging to a source that still has a row is kept regardless.
|
||||
/// is safe because `pruneOrphans` holds `refresh_lock` for its whole body:
|
||||
/// every path that creates a temporary runs under that same lock, so no refresh
|
||||
/// is in flight while the sweep reads the directory, and a temporary the sweep
|
||||
/// sees belonging to a source that still has a row is kept regardless.
|
||||
fn sourceFileId(file_name: []const u8) ?i64 {
|
||||
for (source_file_suffixes) |suffix| {
|
||||
if (!std.mem.endsWith(u8, file_name, suffix)) continue;
|
||||
@@ -1509,8 +1617,9 @@ fn containsId(rows: []const sources_repo.SourceRow, id: i64) bool {
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Everything here runs against a `:memory:` database and touches no file. Real
|
||||
// files, real HTTP and real swaps are the integration suite's (S9).
|
||||
// Everything here runs against a `:memory:` database. Only the cancellation
|
||||
// cases below reach a file, and they reach a `testing.tmpDir` — real HTTP and
|
||||
// real swaps under load are the integration suite's (S9).
|
||||
|
||||
const testing = std.testing;
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
@@ -1527,8 +1636,9 @@ fn testManager(database: *db.Db, fetcher_ptr: *fetcher.Fetcher) !Manager {
|
||||
return Manager.init(
|
||||
testing.allocator,
|
||||
database,
|
||||
// No test in this file reaches the filesystem: `acquire` answers before
|
||||
// any directory is touched, and the header helpers are pure.
|
||||
// `acquire` answers before any directory is touched and the header
|
||||
// helpers are pure, so this directory is never opened unless a test
|
||||
// replaces it with one of its own.
|
||||
.{ .dir = std.Io.Dir.cwd() },
|
||||
fetcher_ptr,
|
||||
.{},
|
||||
@@ -1623,6 +1733,149 @@ test "statusSnapshot on an empty manager copies nothing" {
|
||||
try testing.expectEqual(@as(usize, 0), manager.statusSnapshot(io, &out));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a canceled read of a compiled file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The one file open a `cancelingIo` turns into `error.Canceled`, and the io it
|
||||
/// hands every other open to.
|
||||
///
|
||||
/// A `std.Io` carries its implementation's `userdata`, so a patched vtable entry
|
||||
/// cannot smuggle a receiver of its own through it and has to read its
|
||||
/// configuration from here. The test runner runs the tests of one binary in
|
||||
/// sequence, so one instance is enough.
|
||||
var canceling_read: struct {
|
||||
inner: std.Io = undefined,
|
||||
/// The file-name suffix whose open is canceled.
|
||||
suffix: []const u8 = "",
|
||||
} = .{};
|
||||
|
||||
/// `inner` with the open of every file whose name ends in `suffix` replaced by
|
||||
/// `error.Canceled`. `vtable` is the caller's storage for the patched copy and
|
||||
/// must outlive the returned io.
|
||||
fn cancelingIo(inner: std.Io, suffix: []const u8, vtable: *std.Io.VTable) std.Io {
|
||||
canceling_read = .{ .inner = inner, .suffix = suffix };
|
||||
vtable.* = inner.vtable.*;
|
||||
vtable.dirOpenFile = cancelingOpenFile;
|
||||
return .{ .userdata = inner.userdata, .vtable = vtable };
|
||||
}
|
||||
|
||||
fn cancelingOpenFile(
|
||||
userdata: ?*anyopaque,
|
||||
dir: std.Io.Dir,
|
||||
sub_path: []const u8,
|
||||
options: std.Io.Dir.OpenFileOptions,
|
||||
) std.Io.File.OpenError!std.Io.File {
|
||||
if (std.mem.endsWith(u8, sub_path, canceling_read.suffix)) return error.Canceled;
|
||||
return canceling_read.inner.vtable.dirOpenFile(userdata, dir, sub_path, options);
|
||||
}
|
||||
|
||||
test "a canceled compiled-file read cancels the reload instead of recording it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var mgr = try testManager(&database, &f);
|
||||
defer mgr.deinit(io);
|
||||
mgr.paths = .{ .dir = tmp.dir };
|
||||
|
||||
const url = "https://lists.example/hosts.txt";
|
||||
try sources_repo.insertBlocklistSource(&database, .{ .url = url, .name = "example" }, .{});
|
||||
var rows = try sources_repo.listSourceRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer sources_repo.freeSourceRows(testing.allocator, rows.items);
|
||||
const id = rows.items[0].id;
|
||||
|
||||
const list_body = "aaa.example.com\n";
|
||||
const wild_body = "";
|
||||
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
|
||||
defer dir.close(io);
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
try dir.writeFile(io, .{ .sub_path = compiledName(&list_buf, id, ".list"), .data = list_body });
|
||||
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_body });
|
||||
try sources_repo.updateSourceStats(&database, id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 1,
|
||||
.wildcard_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body),
|
||||
});
|
||||
|
||||
// The baseline every assertion below is against: one clean reload, one
|
||||
// status entry that says so.
|
||||
try mgr.reload(io);
|
||||
var out: [4]SourceStatus = undefined;
|
||||
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
|
||||
try testing.expectEqual(State.ok, out[0].state);
|
||||
try testing.expect(out[0].loaded);
|
||||
const published = mgr.generation;
|
||||
|
||||
// Both catch sites, in the order `loadSource` reads the two files. A
|
||||
// cancellation is consumed by whoever catches it, so folding it into a load
|
||||
// failure would spend the shutdown signal and leave a status row reading
|
||||
// "Canceled" behind.
|
||||
for ([_][]const u8{ ".list", ".wild" }) |suffix| {
|
||||
var vtable: std.Io.VTable = undefined;
|
||||
const canceling = cancelingIo(io, suffix, &vtable);
|
||||
try testing.expectError(error.Canceled, mgr.reload(canceling));
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
|
||||
try testing.expectEqual(State.ok, out[0].state);
|
||||
try testing.expectEqual(@as(usize, 0), out[0].errorText().len);
|
||||
try testing.expect(out[0].loaded);
|
||||
// Nothing was published either: the snapshot the reload never built
|
||||
// cannot have replaced the one still serving.
|
||||
try testing.expectEqual(published, mgr.generation);
|
||||
}
|
||||
}
|
||||
|
||||
test "committing a status for an id the table has no entry for is not silent" {
|
||||
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 mgr = try testManager(&database, &f);
|
||||
defer mgr.deinit(io);
|
||||
|
||||
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
|
||||
try mgr.syncStatuses(io, &rows);
|
||||
|
||||
// The unknown id: a source inserted through the API after this pass built
|
||||
// its table, or deleted before the pass reached its commit. The outcome
|
||||
// has nowhere to go, and the warning is the only trace it leaves. The log
|
||||
// sink cannot be installed under the test runner — it would eat the
|
||||
// harness's own output — so what is asserted here is that the miss is
|
||||
// survivable and changes nothing.
|
||||
var stranger: SourceStatus = .{ .id = 42 };
|
||||
stranger.fail(.fetch_failed, "Timeout");
|
||||
mgr.commitStatus(io, stranger);
|
||||
|
||||
var out: [4]SourceStatus = undefined;
|
||||
try testing.expectEqual(@as(usize, 1), mgr.statusSnapshot(io, &out));
|
||||
try testing.expectEqual(@as(i64, 1), out[0].id);
|
||||
try testing.expectEqual(State.never_fetched, out[0].state);
|
||||
|
||||
// The same commit against an id the table does know still lands.
|
||||
var known: SourceStatus = .{ .id = 1 };
|
||||
known.fail(.fetch_failed, "Timeout");
|
||||
mgr.commitStatus(io, known);
|
||||
_ = mgr.statusSnapshot(io, &out);
|
||||
try testing.expectEqual(State.fetch_failed, out[0].state);
|
||||
try testing.expectEqualStrings("Timeout", out[0].errorText());
|
||||
}
|
||||
|
||||
test "the header writer produces the documented text" {
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
||||
+104
-1
@@ -85,13 +85,43 @@ pub fn isComment(line: []const u8) bool {
|
||||
|
||||
/// The element-hiding separators, which may also follow a domain list
|
||||
/// (`example.com##.ad-banner`).
|
||||
///
|
||||
/// Where the separator sits decides, because `#` is also the hosts comment
|
||||
/// marker and a hosts file banner is drawn out of the same two characters. A
|
||||
/// `##` counts only where an element-hiding rule can put one: at the start of
|
||||
/// the line with a selector behind it, or straight after the domain list it
|
||||
/// applies to. `## Title`, `####` and `see ## below` are therefore text, and a
|
||||
/// hosts file that opens with a banner keeps sniffing as hosts.
|
||||
///
|
||||
/// Guarding this with `isComment` instead would decide nothing: `isComment`
|
||||
/// asks this function.
|
||||
pub fn isElementHiding(line: []const u8) bool {
|
||||
for ([_][]const u8{ "##", "#@#", "#?#", "#$#", "#%#" }) |marker| {
|
||||
if (std.mem.find(u8, line, marker) != null) return true;
|
||||
var from: usize = 0;
|
||||
while (std.mem.find(u8, line[from..], marker)) |offset| {
|
||||
const at = from + offset;
|
||||
if (separatorStartsRule(line, at, marker.len)) return true;
|
||||
from = at + 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the separator of `marker_len` bytes at `at` is a rule's separator
|
||||
/// rather than two characters of prose.
|
||||
fn separatorStartsRule(line: []const u8, at: usize, marker_len: usize) bool {
|
||||
if (at == 0) {
|
||||
// A generic rule carries its selector here. A banner carries a space,
|
||||
// another `#`, or nothing at all.
|
||||
if (line.len == marker_len) return false;
|
||||
const after = line[marker_len];
|
||||
return after != '#' and !std.ascii.isWhitespace(after);
|
||||
}
|
||||
// A domain list ends where the separator begins, with no space between.
|
||||
const before = line[at - 1];
|
||||
return before != '#' and !std.ascii.isWhitespace(before);
|
||||
}
|
||||
|
||||
fn hasAbpMarker(line: []const u8) bool {
|
||||
if (std.mem.startsWith(u8, line, "||")) return true;
|
||||
if (std.mem.startsWith(u8, line, "@@")) return true;
|
||||
@@ -188,6 +218,79 @@ test "detectFormat is not fooled by a dollar sign in a comment" {
|
||||
try testing.expectEqual(Format.hosts, detectFormat(sample));
|
||||
}
|
||||
|
||||
/// The banner style a hosts list published for malware URLs opens with: a rule
|
||||
/// of `#` characters around a titled header block. Every line of it contains
|
||||
/// `##`, and reading those as element hiding used to sniff the whole file as
|
||||
/// ABP — which put `0.0.0.0` in the domain set and dropped every hosts line
|
||||
/// that carried an inline comment.
|
||||
const urlhaus_banner_sample =
|
||||
\\################################################################
|
||||
\\# URLhaus Malicious Hosts File #
|
||||
\\# Last updated: 2026-08-05 06:05:04 (UTC) #
|
||||
\\# #
|
||||
\\# Terms Of Use: https://urlhaus.abuse.ch/api/ #
|
||||
\\################################################################
|
||||
\\0.0.0.0 bad1.example.com # https://urlhaus.abuse.ch/url/1/
|
||||
\\0.0.0.0 bad2.example.net # https://urlhaus.abuse.ch/url/2/
|
||||
\\0.0.0.0 bad3.example.org # https://urlhaus.abuse.ch/url/3/
|
||||
\\
|
||||
;
|
||||
|
||||
test "detectFormat reads a hosts file behind a hash banner as hosts" {
|
||||
try testing.expectEqual(Format.hosts, detectFormat(urlhaus_banner_sample));
|
||||
|
||||
// The lines the banner is made of are comments, so the sample the format
|
||||
// is decided from is the three hosts lines alone.
|
||||
var it = std.mem.splitScalar(u8, urlhaus_banner_sample, '\n');
|
||||
while (it.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
if (line[0] != '#') continue;
|
||||
try testing.expect(isComment(line));
|
||||
try testing.expect(!isElementHiding(line));
|
||||
}
|
||||
}
|
||||
|
||||
test "detectFormat still recognizes a generic element-hiding rule" {
|
||||
const sample =
|
||||
\\##.ad-banner
|
||||
\\example.com
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.abp, detectFormat(sample));
|
||||
try testing.expect(isElementHiding("##.ad-banner"));
|
||||
}
|
||||
|
||||
test "detectFormat recognizes element hiding after a domain list" {
|
||||
const sample =
|
||||
\\example.com##.ad
|
||||
\\other.example.net
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.abp, detectFormat(sample));
|
||||
try testing.expect(isElementHiding("example.com##.ad"));
|
||||
}
|
||||
|
||||
test "detectFormat recognizes an exception separator after a domain" {
|
||||
const sample =
|
||||
\\example.com#@#.sponsored
|
||||
\\other.example.net
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.abp, detectFormat(sample));
|
||||
try testing.expect(isElementHiding("example.com#@#.sponsored"));
|
||||
}
|
||||
|
||||
test "a comment line that mentions a separator stays a comment" {
|
||||
const line = "# the ##.ad rules live in the other list";
|
||||
try testing.expect(isComment(line));
|
||||
try testing.expect(!isElementHiding(line));
|
||||
|
||||
// A bare separator and a rule of hashes are text as well.
|
||||
try testing.expect(!isElementHiding("##"));
|
||||
try testing.expect(!isElementHiding("####"));
|
||||
try testing.expect(!isElementHiding("## Title"));
|
||||
}
|
||||
|
||||
test "parseLine dispatches to the hosts parser" {
|
||||
const line = parseLine(.hosts, "0.0.0.0 ads.example.com");
|
||||
try testing.expectEqual(Kind.domain, line.kind);
|
||||
|
||||
@@ -102,10 +102,12 @@ pub fn boundedScopeName(scope_name: []const u8) []const u8 {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PLAN §11.6 rate-limits exactly the scopes whose failures repeat once per
|
||||
/// query; every other scope logs unconditionally.
|
||||
/// query; every other scope logs unconditionally. `.tls_server` joins them
|
||||
/// (milestone-16 ruling 16): its warnings are peer-driven, so an unhappy client
|
||||
/// could otherwise evict genuine warnings from the rotating log.
|
||||
pub fn isDedupScope(comptime scope: @EnumLiteral()) bool {
|
||||
return scope == .doh_client or scope == .dot_client or
|
||||
scope == .pool or scope == .forward_client;
|
||||
scope == .pool or scope == .forward_client or scope == .tls_server;
|
||||
}
|
||||
|
||||
pub fn buildKey(
|
||||
@@ -651,13 +653,16 @@ test "enabled admits at and above the threshold only" {
|
||||
try testing.expect(enabled(.debug, .debug));
|
||||
}
|
||||
|
||||
test "isDedupScope selects exactly the four upstream scopes" {
|
||||
test "isDedupScope selects exactly the four upstream scopes and tls_server" {
|
||||
try testing.expect(isDedupScope(.doh_client));
|
||||
try testing.expect(isDedupScope(.dot_client));
|
||||
try testing.expect(isDedupScope(.pool));
|
||||
try testing.expect(isDedupScope(.forward_client));
|
||||
try testing.expect(isDedupScope(.tls_server));
|
||||
try testing.expect(!isDedupScope(.default));
|
||||
try testing.expect(!isDedupScope(.cache));
|
||||
try testing.expect(!isDedupScope(.dot_server));
|
||||
try testing.expect(!isDedupScope(.doh_server));
|
||||
}
|
||||
|
||||
test "buildKey separates the scope from the message" {
|
||||
|
||||
+215
-11
@@ -212,6 +212,12 @@ pub const ServerStream = struct {
|
||||
read_code: c_int,
|
||||
/// Most recent negative Mbed TLS code behind `error.WriteFailed`.
|
||||
write_code: c_int,
|
||||
/// The transport failure behind the last failed `bioRecv`. Mbed TLS only
|
||||
/// forwards its own generic code, so without this stash a routine idle
|
||||
/// cancel and a peer reset are indistinguishable to the caller.
|
||||
recv_cause: ?anyerror,
|
||||
/// The transport failure behind the last failed `bioSend`.
|
||||
send_cause: ?anyerror,
|
||||
|
||||
pub const ReadError = error{
|
||||
/// The peer closed the transport without sending close_notify. Any
|
||||
@@ -220,6 +226,9 @@ pub const ServerStream = struct {
|
||||
TlsConnectionTruncated,
|
||||
/// Mbed TLS rejected the record; `read_code` holds its code.
|
||||
TlsFailed,
|
||||
/// nxdns canceled the read — an idle budget expiring or a shutdown,
|
||||
/// never a TLS fault. Kept distinct so callers do not count it.
|
||||
Canceled,
|
||||
};
|
||||
|
||||
/// Zero-length transport buffers: Mbed TLS keeps its own record buffers, so
|
||||
@@ -274,6 +283,8 @@ pub const ServerStream = struct {
|
||||
.read_err = null,
|
||||
.read_code = 0,
|
||||
.write_code = 0,
|
||||
.recv_cause = null,
|
||||
.send_cause = null,
|
||||
};
|
||||
|
||||
try check(mbedtls_ssl_setup(self.ssl.ptr, ctx.config.ptr), "ssl_setup", error.SetupFailed);
|
||||
@@ -284,7 +295,9 @@ pub const ServerStream = struct {
|
||||
if (rc == 0) return;
|
||||
if (isRetry(rc)) continue;
|
||||
if (rc == err_conn_eof or rc == err_peer_close_notify) return error.PeerClosed;
|
||||
report("ssl_handshake", rc);
|
||||
// A probe that connects and then drops reaches this on every
|
||||
// attempt, so a peer-driven handshake failure logs at debug.
|
||||
report("ssl_handshake", rc, self.level(self.recv_cause orelse self.send_cause));
|
||||
return error.HandshakeFailed;
|
||||
}
|
||||
}
|
||||
@@ -308,7 +321,9 @@ pub const ServerStream = struct {
|
||||
while (true) {
|
||||
const rc = mbedtls_ssl_close_notify(self.ssl.ptr);
|
||||
if (isRetry(rc)) continue;
|
||||
if (rc != 0) report("ssl_close_notify", rc);
|
||||
// close_notify against a socket the peer already dropped fails
|
||||
// every time; that is the peer's doing, not a fault worth a warn.
|
||||
if (rc != 0) report("ssl_close_notify", rc, self.level(self.send_cause orelse self.recv_cause));
|
||||
break;
|
||||
}
|
||||
mbedtls_ssl_free(self.ssl.ptr);
|
||||
@@ -365,8 +380,9 @@ pub const ServerStream = struct {
|
||||
return self.failRead(error.TlsConnectionTruncated, rc);
|
||||
}
|
||||
if (rc < 0) {
|
||||
report("ssl_read", rc);
|
||||
return self.failRead(error.TlsFailed, rc);
|
||||
const err = readErrorFor(self.recv_cause);
|
||||
report("ssl_read", rc, self.level(self.recv_cause));
|
||||
return self.failRead(err, rc);
|
||||
}
|
||||
r.end += @intCast(rc);
|
||||
return 0;
|
||||
@@ -379,6 +395,10 @@ pub const ServerStream = struct {
|
||||
return error.ReadFailed;
|
||||
}
|
||||
|
||||
fn level(self: *const ServerStream, cause: ?anyerror) std.log.Level {
|
||||
return causeLevel(self.peer_closed, cause);
|
||||
}
|
||||
|
||||
fn writerDrain(w: *Writer, data: []const []const u8, splat: usize) Writer.Error!usize {
|
||||
const self: *ServerStream = @alignCast(@fieldParentPtr("writer_interface", w));
|
||||
|
||||
@@ -408,7 +428,7 @@ pub const ServerStream = struct {
|
||||
if (isRetry(rc)) continue;
|
||||
if (rc < 0) {
|
||||
self.write_code = rc;
|
||||
report("ssl_write", rc);
|
||||
report("ssl_write", rc, self.level(self.send_cause));
|
||||
return error.WriteFailed;
|
||||
}
|
||||
return @intCast(rc);
|
||||
@@ -418,18 +438,30 @@ pub const ServerStream = struct {
|
||||
fn bioSend(bio: ?*anyopaque, buf: [*]const u8, len: usize) callconv(.c) c_int {
|
||||
const self: *ServerStream = @ptrCast(@alignCast(bio.?));
|
||||
const w = &self.net_writer.interface;
|
||||
w.writeAll(buf[0..len]) catch return err_net_send_failed;
|
||||
w.flush() catch return err_net_send_failed;
|
||||
w.writeAll(buf[0..len]) catch return self.sendFailed();
|
||||
w.flush() catch return self.sendFailed();
|
||||
return @intCast(len);
|
||||
}
|
||||
|
||||
/// Mbed TLS only ever sees `err_net_send_failed`, so the concrete cause is
|
||||
/// kept here for the caller and for the log level.
|
||||
fn sendFailed(self: *ServerStream) c_int {
|
||||
if (self.net_writer.err) |cause| self.send_cause = cause;
|
||||
return err_net_send_failed;
|
||||
}
|
||||
|
||||
fn recvFailed(self: *ServerStream) c_int {
|
||||
if (self.net_reader.err) |cause| self.recv_cause = cause;
|
||||
return err_net_recv_failed;
|
||||
}
|
||||
|
||||
fn bioRecv(bio: ?*anyopaque, buf: [*]u8, len: usize) callconv(.c) c_int {
|
||||
const self: *ServerStream = @ptrCast(@alignCast(bio.?));
|
||||
if (len == 0) return 0;
|
||||
var data: [1][]u8 = .{buf[0..len]};
|
||||
const n = self.net_reader.interface.readVec(&data) catch |err| switch (err) {
|
||||
error.EndOfStream => return 0,
|
||||
error.ReadFailed => return err_net_recv_failed,
|
||||
error.ReadFailed => return self.recvFailed(),
|
||||
};
|
||||
// A zero-length transport buffer makes a short read impossible, but the
|
||||
// interface permits it; ask Mbed TLS to come back rather than reporting EOF.
|
||||
@@ -468,14 +500,55 @@ fn isRetry(rc: c_int) bool {
|
||||
|
||||
fn check(rc: c_int, comptime op: []const u8, comptime failure: anytype) @TypeOf(failure)!void {
|
||||
if (rc == 0) return;
|
||||
report(op, rc);
|
||||
// Setup and configuration failures are nxdns's own; no peer can cause them.
|
||||
report(op, rc, .warn);
|
||||
return failure;
|
||||
}
|
||||
|
||||
fn report(comptime op: []const u8, rc: c_int) void {
|
||||
fn report(comptime op: []const u8, rc: c_int, level: std.log.Level) void {
|
||||
var text: [160]u8 = undefined;
|
||||
mbedtls_strerror(rc, &text, text.len);
|
||||
log.warn("mbedtls_{s} failed: {s} ({d})", .{ op, std.mem.sliceTo(&text, 0), rc });
|
||||
const args = .{ op, std.mem.sliceTo(&text, 0), rc };
|
||||
switch (level) {
|
||||
.err => log.err("mbedtls_{s} failed: {s} ({d})", args),
|
||||
.warn => log.warn("mbedtls_{s} failed: {s} ({d})", args),
|
||||
.info => log.info("mbedtls_{s} failed: {s} ({d})", args),
|
||||
.debug => log.debug("mbedtls_{s} failed: {s} ({d})", args),
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure the peer or an nxdns shutdown produced, as opposed to a
|
||||
/// local resource or configuration failure. The names come from
|
||||
/// `std.Io.net.Stream.Reader.Error` and `.Writer.Error`.
|
||||
fn isPeerCause(cause: ?anyerror) bool {
|
||||
const concrete = cause orelse return false;
|
||||
return switch (concrete) {
|
||||
error.Canceled,
|
||||
error.ConnectionResetByPeer,
|
||||
error.ConnectionRefused,
|
||||
error.Timeout,
|
||||
error.SocketUnconnected,
|
||||
error.HostUnreachable,
|
||||
error.NetworkUnreachable,
|
||||
=> true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Peer-driven and shutdown-driven failures log at debug, for the reason the
|
||||
/// truncation path already records: any client can reach these sites at will,
|
||||
/// so a louder level is a log-spam vector. Local and configuration failures —
|
||||
/// which no peer can provoke — keep warn.
|
||||
fn causeLevel(peer_closed: bool, cause: ?anyerror) std.log.Level {
|
||||
if (peer_closed) return .debug;
|
||||
return if (isPeerCause(cause)) .debug else .warn;
|
||||
}
|
||||
|
||||
/// A canceled transport read is nxdns closing the connection, not a TLS fault,
|
||||
/// so it keeps its own error instead of collapsing into `TlsFailed`.
|
||||
fn readErrorFor(cause: ?anyerror) ServerStream.ReadError {
|
||||
const concrete = cause orelse return error.TlsFailed;
|
||||
return if (concrete == error.Canceled) error.Canceled else error.TlsFailed;
|
||||
}
|
||||
|
||||
// -- Mbed TLS 3.6.7 surface ------------------------------------------------
|
||||
@@ -663,6 +736,34 @@ test "the shim agrees with the alignment contexts are allocated at" {
|
||||
try std.testing.expect(nx_sizeof_ctr_drbg_context() > 0);
|
||||
}
|
||||
|
||||
test "readErrorFor keeps a canceled read out of the TLS failure bucket" {
|
||||
try std.testing.expectEqual(ServerStream.ReadError.Canceled, readErrorFor(error.Canceled));
|
||||
try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(error.ConnectionResetByPeer));
|
||||
try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(error.SystemResources));
|
||||
try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(null));
|
||||
}
|
||||
|
||||
test "causeLevel logs peer misbehavior at debug and local faults at warn" {
|
||||
// Ruling 16: close_notify against a socket the peer already dropped.
|
||||
try std.testing.expectEqual(std.log.Level.debug, causeLevel(true, null));
|
||||
try std.testing.expectEqual(std.log.Level.debug, causeLevel(false, error.ConnectionResetByPeer));
|
||||
try std.testing.expectEqual(std.log.Level.debug, causeLevel(false, error.Canceled));
|
||||
try std.testing.expectEqual(std.log.Level.warn, causeLevel(false, error.SystemResources));
|
||||
// No stashed cause means Mbed TLS rejected the record itself.
|
||||
try std.testing.expectEqual(std.log.Level.warn, causeLevel(false, null));
|
||||
}
|
||||
|
||||
test "isPeerCause separates peer and shutdown failures from local ones" {
|
||||
try std.testing.expect(isPeerCause(error.Canceled));
|
||||
try std.testing.expect(isPeerCause(error.ConnectionResetByPeer));
|
||||
try std.testing.expect(isPeerCause(error.Timeout));
|
||||
try std.testing.expect(isPeerCause(error.SocketUnconnected));
|
||||
try std.testing.expect(!isPeerCause(error.SystemResources));
|
||||
try std.testing.expect(!isPeerCause(error.AccessDenied));
|
||||
try std.testing.expect(!isPeerCause(error.NetworkDown));
|
||||
try std.testing.expect(!isPeerCause(null));
|
||||
}
|
||||
|
||||
/// Drops the second half of a PEM document, keeping the header intact so the
|
||||
/// parser fails on the body rather than on a missing "-----BEGIN" line.
|
||||
fn truncate(gpa: std.mem.Allocator, pem: [:0]const u8) ![:0]const u8 {
|
||||
@@ -764,6 +865,109 @@ test "a transport EOF without close_notify reads as a truncated stream" {
|
||||
try server_result;
|
||||
}
|
||||
|
||||
test "a canceled read stashes Canceled, not TlsFailed" {
|
||||
const build_options = @import("build_options");
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const fixtures = @import("test_fixtures");
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var threaded: Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null);
|
||||
defer ctx.deinit(gpa);
|
||||
|
||||
const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) };
|
||||
var server = try listen_address.listen(io, .{ .reuse_address = true });
|
||||
defer server.deinit(io);
|
||||
|
||||
// Set once the server task is about to block in `readIntoBuffer`, so the
|
||||
// cancellation below lands on the read and not on the handshake.
|
||||
var reading: Io.Event = .unset;
|
||||
var server_task = try io.concurrent(expectCanceledRead, .{ gpa, &ctx, io, &server, &reading });
|
||||
|
||||
var client = runIdleClient(io, server.socket.address) catch |err| {
|
||||
server_task.cancel(io) catch {};
|
||||
return err;
|
||||
};
|
||||
defer client.close(io);
|
||||
|
||||
reading.waitUncancelable(io);
|
||||
try server_task.cancel(io);
|
||||
}
|
||||
|
||||
/// Handshakes, then blocks in a plaintext read that only the cancel can end.
|
||||
fn expectCanceledRead(
|
||||
gpa: std.mem.Allocator,
|
||||
ctx: *ServerContext,
|
||||
io: Io,
|
||||
server: *Io.net.Server,
|
||||
reading: *Io.Event,
|
||||
) anyerror!void {
|
||||
var stream = try server.accept(io);
|
||||
defer stream.close(io);
|
||||
|
||||
var read_buffer: [4096]u8 = undefined;
|
||||
var write_buffer: [4096]u8 = undefined;
|
||||
var tls: ServerStream = undefined;
|
||||
try tls.accept(gpa, ctx, io, &stream, &read_buffer, &write_buffer);
|
||||
defer tls.close(gpa);
|
||||
|
||||
reading.set(io);
|
||||
|
||||
var byte: [1]u8 = undefined;
|
||||
try std.testing.expectError(error.ReadFailed, tls.reader().readSliceAll(&byte));
|
||||
try std.testing.expectEqual(ServerStream.ReadError.Canceled, tls.read_err.?);
|
||||
try std.testing.expectEqual(@as(?anyerror, error.Canceled), tls.recv_cause);
|
||||
// A cancel is nxdns's own doing, never the peer ending the stream.
|
||||
try std.testing.expect(!tls.peer_closed);
|
||||
}
|
||||
|
||||
/// A client that completes the handshake and then sends nothing. The caller
|
||||
/// keeps it alive so the server's read has no other way to end.
|
||||
const IdleClient = struct {
|
||||
stream: Io.net.Stream,
|
||||
|
||||
fn close(self: *IdleClient, io: Io) void {
|
||||
self.stream.close(io);
|
||||
}
|
||||
};
|
||||
|
||||
fn runIdleClient(io: Io, address: Io.net.IpAddress) !IdleClient {
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
var stream = try address.connect(io, .{ .mode = .stream });
|
||||
errdefer stream.close(io);
|
||||
|
||||
// The handshake buffers die with this function; the connection outlives it
|
||||
// because nothing more is ever read from or written to it.
|
||||
var transport_read_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
var transport_write_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
var net_reader = stream.reader(io, &transport_read_buffer);
|
||||
var net_writer = stream.writer(io, &transport_write_buffer);
|
||||
|
||||
var entropy: [tls.Client.Options.entropy_len]u8 = undefined;
|
||||
io.random(&entropy);
|
||||
|
||||
var plaintext_read_buffer: [4096]u8 = undefined;
|
||||
var plaintext_write_buffer: [4096]u8 = undefined;
|
||||
|
||||
var client = try tls.Client.init(&net_reader.interface, &net_writer.interface, .{
|
||||
.host = .no_verification,
|
||||
.ca = .no_verification,
|
||||
.read_buffer = &plaintext_read_buffer,
|
||||
.write_buffer = &plaintext_write_buffer,
|
||||
.entropy = &entropy,
|
||||
.realtime_now = Io.Timestamp.now(io, .real),
|
||||
});
|
||||
_ = &client;
|
||||
try net_writer.interface.flush();
|
||||
|
||||
return .{ .stream = stream };
|
||||
}
|
||||
|
||||
fn expectTruncation(
|
||||
gpa: std.mem.Allocator,
|
||||
ctx: *ServerContext,
|
||||
|
||||
+82
-16
@@ -63,10 +63,11 @@ const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };
|
||||
|
||||
pub const Options = struct {
|
||||
max_connections: u16 = default_max_connections,
|
||||
/// The TLS handshake budget. Requests themselves have no timeout, exactly
|
||||
/// like the web listener: the port is LAN-facing and the cancel path is
|
||||
/// what bounds shutdown. Only the handshake — which happens before the
|
||||
/// connection has proven it speaks anything at all — is raced.
|
||||
/// The budget for the TLS handshake and for the wait on the next request
|
||||
/// head of a keep-alive connection (milestone-16 ruling 10). A request
|
||||
/// already being served has no timeout, like the web listener: the port is
|
||||
/// LAN-facing and the cancel path is what bounds shutdown. What is raced is
|
||||
/// every wait on a peer that owes nxdns bytes and has sent none.
|
||||
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
|
||||
};
|
||||
|
||||
@@ -76,6 +77,10 @@ pub const Stats = struct {
|
||||
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
|
||||
accept_errors: std.atomic.Value(u64) = .init(0),
|
||||
tls_handshake_failures: std.atomic.Value(u64) = .init(0),
|
||||
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
|
||||
/// request head on the wire. A stalled handshake counts as a handshake
|
||||
/// failure instead (milestone-16 ruling 9), so this name means only what
|
||||
/// it says.
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
/// Every 4xx answered on `/dns-query` and every miss beside it: the
|
||||
@@ -308,16 +313,15 @@ pub const DohServer = struct {
|
||||
// handshake has in fact succeeded. The flag is written before the
|
||||
// race joins its tasks, so a TLS context that exists is closed on
|
||||
// every path, exactly once.
|
||||
.timed_out => {
|
||||
if (handshook) conn.tls.close(self.gpa);
|
||||
bump(&self.stats.idle_timeouts);
|
||||
return;
|
||||
},
|
||||
.canceled => {
|
||||
if (handshook) conn.tls.close(self.gpa);
|
||||
return;
|
||||
},
|
||||
.failed => {
|
||||
// Milestone-16 ruling 9: a stalled handshake is refused like a broken
|
||||
// one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
|
||||
// wait below, so the two listeners export the same names for the
|
||||
// same events.
|
||||
.timed_out, .failed => {
|
||||
if (handshook) conn.tls.close(self.gpa);
|
||||
bump(&self.stats.tls_handshake_failures);
|
||||
return;
|
||||
@@ -330,11 +334,26 @@ pub const DohServer = struct {
|
||||
var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer());
|
||||
|
||||
while (connection.reader.state == .ready) {
|
||||
var request = connection.receiveHead() catch |err| switch (err) {
|
||||
// Milestone-16 ruling 10: the wait for the next request head is the
|
||||
// one place a vanished keep-alive peer could pin a slot forever, so
|
||||
// it runs under the same budget as the handshake. The body read and
|
||||
// `handleRequest` below stay untimed.
|
||||
var head: ReceiveHeadResult = error.ReadFailed;
|
||||
switch (race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
|
||||
.ok => {},
|
||||
.timed_out => {
|
||||
bump(&self.stats.idle_timeouts);
|
||||
return;
|
||||
},
|
||||
// Cancellation is shutdown; `.failed` here is only the wrapper
|
||||
// failing to start, which costs nothing and counts as nothing.
|
||||
.canceled, .failed => return,
|
||||
}
|
||||
|
||||
var request = head catch |err| switch (err) {
|
||||
// The normal end of a keep-alive connection.
|
||||
error.HttpConnectionClosing => return,
|
||||
// Cancellation and a vanished client both land here; neither is
|
||||
// worth a counter.
|
||||
// A vanished client lands here; not worth a counter.
|
||||
error.ReadFailed => return,
|
||||
error.HttpHeadersOversize,
|
||||
error.HttpRequestTruncated,
|
||||
@@ -573,6 +592,15 @@ fn handshake(
|
||||
handshook.* = true;
|
||||
}
|
||||
|
||||
const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request;
|
||||
|
||||
/// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an
|
||||
/// `anyerror!void` operation, so the request — or the error that replaced it —
|
||||
/// travels through a pointer instead of a return value.
|
||||
fn receiveHeadInto(connection: *http.Server, out: *ReceiveHeadResult) anyerror!void {
|
||||
out.* = connection.receiveHead();
|
||||
}
|
||||
|
||||
/// True when the head frames body bytes on the wire. A bare
|
||||
/// `content-length: 0` frames nothing.
|
||||
fn framesBody(transfer_encoding: http.TransferEncoding, content_length: ?u64) bool {
|
||||
@@ -878,6 +906,10 @@ const Harness = struct {
|
||||
group: std.Io.Group,
|
||||
|
||||
fn start(hx: *Harness) !void {
|
||||
return hx.startWith(.{ .max_connections = 4 });
|
||||
}
|
||||
|
||||
fn startWith(hx: *Harness, options: Options) !void {
|
||||
hx.threaded = .init(testing.allocator, .{});
|
||||
errdefer hx.threaded.deinit();
|
||||
const hio = hx.threaded.io();
|
||||
@@ -904,9 +936,7 @@ const Harness = struct {
|
||||
};
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, .{
|
||||
.max_connections = 4,
|
||||
});
|
||||
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
|
||||
errdefer hx.server.deinit(testing.allocator, hio);
|
||||
|
||||
hx.group = .init;
|
||||
@@ -1462,6 +1492,42 @@ fn twoPostsOneConnection(io: std.Io, remote: net.IpAddress) anyerror!void {
|
||||
try conn.end();
|
||||
}
|
||||
|
||||
/// Long enough for a loopback round trip, short enough that the `bounded`
|
||||
/// budget still fails a server that never reclaims the connection.
|
||||
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake };
|
||||
|
||||
/// One request, then silence on a connection the client keeps open. The server
|
||||
/// owes no answer, so the only thing that can end the read is the idle budget.
|
||||
fn idleAfterOneRequest(io: std.Io, remote: net.IpAddress) anyerror!void {
|
||||
var conn: ClientConn = undefined;
|
||||
try conn.connect(io, remote);
|
||||
defer conn.close(io);
|
||||
|
||||
try sendPost(&conn, doh_client.media_type, query_bytes);
|
||||
const resp = try readResponse(&conn.client.reader);
|
||||
try testing.expectEqual(@as(u16, 200), resp.status);
|
||||
try expectLocalReply(resp.body());
|
||||
|
||||
try expectEof(&conn.client.reader);
|
||||
}
|
||||
|
||||
test "doh: an idle keep-alive connection is reclaimed and counted" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var hx: Harness = undefined;
|
||||
try hx.startWith(.{ .max_connections = 4, .idle_timeout = short_idle });
|
||||
defer hx.stop();
|
||||
|
||||
try bounded(hx.io(), idleAfterOneRequest, .{ hx.io(), hx.addr() });
|
||||
|
||||
const stats = hx.server.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 1), stats.connections);
|
||||
try testing.expectEqual(@as(u64, 1), stats.idle_timeouts);
|
||||
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
|
||||
try testing.expectEqual(@as(u64, 0), stats.bad_requests);
|
||||
}
|
||||
|
||||
test "keep-alive: two requests are answered on one connection" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
|
||||
@@ -140,6 +140,13 @@ pub const Handler = struct {
|
||||
uncloak_blocked: std.atomic.Value(u64) = .init(0),
|
||||
local_answers: std.atomic.Value(u64) = .init(0),
|
||||
forward_zone_answers: std.atomic.Value(u64) = .init(0),
|
||||
/// The three forward-client counters, folded in after every exchange.
|
||||
/// `ForwardClient` is built per query and dropped with the query, so
|
||||
/// these are where its numbers survive. Its `queries` counter is not
|
||||
/// mirrored: `forward_zone_answers` already counts the exchanges.
|
||||
forward_udp_truncated: std.atomic.Value(u64) = .init(0),
|
||||
forward_foreign_datagrams: std.atomic.Value(u64) = .init(0),
|
||||
forward_failures: std.atomic.Value(u64) = .init(0),
|
||||
cache_hits: std.atomic.Value(u64) = .init(0),
|
||||
paused_queries: std.atomic.Value(u64) = .init(0),
|
||||
/// Queries answered before the first snapshot existed, so no group and
|
||||
@@ -396,6 +403,11 @@ const Context = struct {
|
||||
&ctx.scratch.frame,
|
||||
ctx.handler.forward_read_timeout,
|
||||
);
|
||||
// The client lives on this query's stack, so its counters have to move
|
||||
// into the handler's before it goes out of scope — on the failure path
|
||||
// too, which is the one `forward_failures` exists for.
|
||||
defer foldForwardStats(&ctx.handler.stats, client.stats);
|
||||
|
||||
const answer = client.exchange(ctx.io, ctx.query, ctx.response_buf) catch |err| {
|
||||
return switch (transport.group(err)) {
|
||||
.cancellation => .drop,
|
||||
@@ -879,6 +891,15 @@ fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
/// Moves one forward-zone exchange's counters into the handler's. `stats` is a
|
||||
/// plain per-instance struct and stays that way (milestone-16 ruling 14); this
|
||||
/// is the one place it becomes a process-wide number.
|
||||
fn foldForwardStats(into: *Handler.Stats, from: forward_client.ForwardClient.Stats) void {
|
||||
_ = into.forward_udp_truncated.fetchAdd(from.udp_truncated, .monotonic);
|
||||
_ = into.forward_foreign_datagrams.fetchAdd(from.foreign_datagrams, .monotonic);
|
||||
_ = into.forward_failures.fetchAdd(from.failures, .monotonic);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1758,6 +1779,7 @@ fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||
.total_budget = forward_timeout,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
.refresh_lock = .init,
|
||||
.current = snapshot,
|
||||
.generation = 1,
|
||||
.statuses = &.{},
|
||||
|
||||
@@ -265,6 +265,7 @@ fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||
.total_budget = forward_timeout,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
.refresh_lock = .init,
|
||||
.current = snapshot,
|
||||
.generation = 1,
|
||||
.statuses = &.{},
|
||||
|
||||
@@ -60,6 +60,19 @@ pub const Stats = struct {
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the
|
||||
/// `nxdns_tcp_server_*` families. Every counter is exported, including the
|
||||
/// two refusals: a listener that turns clients away at capacity is the thing an
|
||||
/// operator most needs to see, and the module doc promises it is counted.
|
||||
pub const Snapshot = struct {
|
||||
accepted: u64,
|
||||
rejected_at_capacity: u64,
|
||||
rejected_at_shutdown: u64,
|
||||
accept_errors: u64,
|
||||
connection_errors: u64,
|
||||
idle_timeouts: u64,
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches a connection
|
||||
/// slot after it is freed.
|
||||
@@ -162,6 +175,20 @@ pub const TcpServer = struct {
|
||||
return self.server.socket.address;
|
||||
}
|
||||
|
||||
/// The counters, read one at a time. A scrape that lands mid-accept can see
|
||||
/// a connection counted before its outcome is; a lock would buy a
|
||||
/// consistency no consumer needs.
|
||||
pub fn snapshotStats(self: *const TcpServer) Snapshot {
|
||||
return .{
|
||||
.accepted = self.stats.accepted.load(.monotonic),
|
||||
.rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic),
|
||||
.rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic),
|
||||
.accept_errors = self.stats.accept_errors.load(.monotonic),
|
||||
.connection_errors = self.stats.connection_errors.load(.monotonic),
|
||||
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic),
|
||||
};
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *TcpServer, io: std.Io) void {
|
||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
@@ -49,6 +49,19 @@ pub const Stats = struct {
|
||||
send_errors: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the
|
||||
/// `nxdns_udp_server_*` families. Every counter is exported: the module doc
|
||||
/// promises that a dropped datagram is counted, and a count nothing can read is
|
||||
/// not a count.
|
||||
pub const Snapshot = struct {
|
||||
received: u64,
|
||||
dropped_oversize: u64,
|
||||
dropped_no_slot: u64,
|
||||
dropped_handler: u64,
|
||||
receive_errors: u64,
|
||||
send_errors: u64,
|
||||
};
|
||||
|
||||
/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches the slots after
|
||||
/// they are freed.
|
||||
@@ -124,6 +137,20 @@ pub const UdpServer = struct {
|
||||
return self.socket.address;
|
||||
}
|
||||
|
||||
/// The counters, read one at a time. A scrape that lands mid-datagram can
|
||||
/// see a receive counted before its drop is; a lock would buy a consistency
|
||||
/// no consumer needs, and the receive loop takes that lock per datagram.
|
||||
pub fn snapshotStats(self: *const UdpServer) Snapshot {
|
||||
return .{
|
||||
.received = self.stats.received.load(.monotonic),
|
||||
.dropped_oversize = self.stats.dropped_oversize.load(.monotonic),
|
||||
.dropped_no_slot = self.stats.dropped_no_slot.load(.monotonic),
|
||||
.dropped_handler = self.stats.dropped_handler.load(.monotonic),
|
||||
.receive_errors = self.stats.receive_errors.load(.monotonic),
|
||||
.send_errors = self.stats.send_errors.load(.monotonic),
|
||||
};
|
||||
}
|
||||
|
||||
/// Receive loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *UdpServer, io: std.Io) void {
|
||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
@@ -360,7 +360,7 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
|
||||
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
|
||||
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
pass.runOnce(io, log_db.database());
|
||||
pass.runOnce(io, log_db.database(), null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
|
||||
|
||||
+111
-27
@@ -12,6 +12,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
|
||||
@@ -32,6 +33,9 @@ pub const Stats = struct {
|
||||
rows_pruned: u64 = 0,
|
||||
checkpoints: u64 = 0,
|
||||
vacuums: u64 = 0,
|
||||
/// Vacuums the disk monitor refused. The pass still pruned and
|
||||
/// checkpointed, and the vacuum is due again on the next pass.
|
||||
vacuums_gated: u64 = 0,
|
||||
};
|
||||
|
||||
/// The live counters. Atomic because the retention task writes them and the web
|
||||
@@ -42,25 +46,30 @@ const Counters = struct {
|
||||
rows_pruned: std.atomic.Value(u64) = .init(0),
|
||||
checkpoints: std.atomic.Value(u64) = .init(0),
|
||||
vacuums: std.atomic.Value(u64) = .init(0),
|
||||
vacuums_gated: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
pub const Retention = struct {
|
||||
cfg: model.Logging,
|
||||
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 = .{} };
|
||||
return .{ .cfg = cfg, .counters = .{}, .passes_since_vacuum = 0 };
|
||||
}
|
||||
|
||||
/// The four counters, read one at a time. A scrape that lands mid-pass can
|
||||
/// see a pass counted before the rows it pruned are; the alternative is a
|
||||
/// lock on the pass itself, which buys a consistency no consumer needs.
|
||||
/// The counters, read one at a time. A scrape that lands mid-pass can see a
|
||||
/// pass counted before the rows it pruned are; the alternative is a lock on
|
||||
/// the pass itself, which buys a consistency no consumer needs.
|
||||
pub fn snapshotStats(self: *const Retention) Stats {
|
||||
return .{
|
||||
.passes = self.counters.passes.load(.monotonic),
|
||||
.rows_pruned = self.counters.rows_pruned.load(.monotonic),
|
||||
.checkpoints = self.counters.checkpoints.load(.monotonic),
|
||||
.vacuums = self.counters.vacuums.load(.monotonic),
|
||||
.vacuums_gated = self.counters.vacuums_gated.load(.monotonic),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,35 +84,54 @@ pub const Retention = struct {
|
||||
/// on the first failure would still be a day away from its retry.
|
||||
///
|
||||
/// `database` must be a connection no other task uses; see `run`.
|
||||
pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void {
|
||||
const pass = add(&self.counters.passes, 1) + 1;
|
||||
///
|
||||
/// `monitor` gates the vacuum only. Prune and checkpoint free space, so a
|
||||
/// full disk is a reason to run them rather than a reason to skip them,
|
||||
/// while a `VACUUM` rewrites the whole file on the very filesystem the
|
||||
/// monitor watches and fails `SQLITE_FULL` there. A null monitor means no
|
||||
/// gate, which is the shape the logger's writer takes.
|
||||
pub fn runOnce(
|
||||
self: *Retention,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) void {
|
||||
add(&self.counters.passes, 1);
|
||||
const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg);
|
||||
|
||||
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
||||
_ = add(&self.counters.rows_pruned, @intCast(deleted));
|
||||
add(&self.counters.rows_pruned, @intCast(deleted));
|
||||
} else |err| {
|
||||
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||
}
|
||||
|
||||
if (queries_repo.checkpointTruncate(database)) {
|
||||
_ = add(&self.counters.checkpoints, 1);
|
||||
add(&self.counters.checkpoints, 1);
|
||||
} else |err| {
|
||||
log.warn("retention checkpoint failed: {s}", .{@errorName(err)});
|
||||
}
|
||||
|
||||
if (pass % vacuum_every_passes != 0) return;
|
||||
self.passes_since_vacuum += 1;
|
||||
if (self.passes_since_vacuum < vacuum_every_passes) return;
|
||||
|
||||
// The counter is not reset here, so a vacuum the monitor refused is due
|
||||
// again on the very next pass rather than seven passes later.
|
||||
if (monitor) |m| if (!m.writesAllowed()) {
|
||||
add(&self.counters.vacuums_gated, 1);
|
||||
log.warn("retention vacuum skipped: the disk monitor refuses writes", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
if (queries_repo.vacuum(database)) {
|
||||
_ = add(&self.counters.vacuums, 1);
|
||||
add(&self.counters.vacuums, 1);
|
||||
self.passes_since_vacuum = 0;
|
||||
} else |err| {
|
||||
log.warn("retention vacuum failed: {s}", .{@errorName(err)});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the value before the addition, which is what the pass counter
|
||||
/// needs: only this task increments it, so `previous + 1` is this pass's
|
||||
/// number.
|
||||
fn add(counter: *std.atomic.Value(u64), delta: u64) u64 {
|
||||
return counter.fetchAdd(delta, .monotonic);
|
||||
fn add(counter: *std.atomic.Value(u64), delta: u64) void {
|
||||
_ = counter.fetchAdd(delta, .monotonic);
|
||||
}
|
||||
|
||||
/// Daily loop, first pass immediately. Phase 7 starts it.
|
||||
@@ -125,13 +153,18 @@ pub const Retention = struct {
|
||||
/// SQLite's own — WAL plus the `busy_timeout` of `db.zig`'s open options —
|
||||
/// so a pass that still loses a race sees `error.Busy` or `error.Locked`,
|
||||
/// logs at `warn`, and repeats the work on the next interval.
|
||||
pub fn run(self: *Retention, io: std.Io, database: *db.Db) std.Io.Cancelable!void {
|
||||
pub fn run(
|
||||
self: *Retention,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(pass_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
self.runOnce(io, database);
|
||||
self.runOnce(io, database, monitor);
|
||||
try interval.sleep(io);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +219,7 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
|
||||
try writeRows(&database, &.{ now - 40 * day, now - 31 * day, now - 29 * day, now - 60 });
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -210,12 +243,12 @@ test "the cutoff follows retention_days" {
|
||||
try writeRows(&database, &.{now - 3 * day});
|
||||
|
||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||
keeps.runOnce(io, &database);
|
||||
keeps.runOnce(io, &database, 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 });
|
||||
prunes.runOnce(io, &database);
|
||||
prunes.runOnce(io, &database, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
|
||||
}
|
||||
@@ -230,20 +263,71 @@ test "the seventh pass vacuums and the six before it do not" {
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
for (0..6) |_| {
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
}
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints);
|
||||
|
||||
for (0..7) |_| retention.runOnce(io, &database);
|
||||
for (0..7) |_| retention.runOnce(io, &database, null);
|
||||
try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums);
|
||||
}
|
||||
|
||||
test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
// `.critical` is the one state `writesAllowed` refuses on, and it is
|
||||
// published here directly: no real filesystem has to fill up for it.
|
||||
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(.{});
|
||||
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor);
|
||||
|
||||
// Prune and checkpoint ran on every pass; only the vacuum was refused.
|
||||
try testing.expectEqual(@as(u64, vacuum_every_passes), gated.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, vacuum_every_passes), gated.snapshotStats().checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), gated.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated);
|
||||
|
||||
// The vacuum is due again immediately, not seven passes later.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
gated.runOnce(io, &database, &monitor);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated);
|
||||
|
||||
// And the counter reset, so the next six passes vacuum nothing.
|
||||
for (0..vacuum_every_passes - 1) |_| gated.runOnce(io, &database, &monitor);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums);
|
||||
}
|
||||
|
||||
test "a warn state still allows the vacuum" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
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(.{});
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums_gated);
|
||||
}
|
||||
|
||||
test "a pass over an empty database still counts" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -253,7 +337,7 @@ test "a pass over an empty database still counts" {
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||
@@ -277,7 +361,7 @@ test "a failing prune counts the pass and leaves the rows alone" {
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -302,11 +386,11 @@ test "the next pass retries what the failed one could not do" {
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
|
||||
try database.exec("DROP TRIGGER refuse_delete;");
|
||||
retention.runOnce(io, &database);
|
||||
retention.runOnce(io, &database, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().passes);
|
||||
|
||||
@@ -147,9 +147,10 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
///
|
||||
/// After the reload and never part of the response: the row is gone and the
|
||||
/// snapshot has stopped enforcing the list, so bytes still on disk are not a
|
||||
/// failed delete. `Manager.pruneOrphans` takes the manager's writer lock, which
|
||||
/// the reload above has already taken and released — nothing here holds it, and
|
||||
/// `state.config_lock` was released before either.
|
||||
/// failed delete. `Manager.pruneOrphans` takes the manager's refresh lock and
|
||||
/// then its writer lock; the reload above has already taken and released the
|
||||
/// writer lock — nothing here holds either, and `state.config_lock` was
|
||||
/// released before both.
|
||||
fn pruneFiles(state: *server.WebState, io: std.Io) void {
|
||||
const manager = state.manager orelse return;
|
||||
manager.pruneOrphans(io) catch |err| {
|
||||
|
||||
@@ -112,9 +112,16 @@ pub fn stream(
|
||||
if (hub.overflowed(io, id)) break;
|
||||
|
||||
const wake = hub.wait(io, id, heartbeat_interval) catch return;
|
||||
if (wake == .timeout) {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
switch (wake) {
|
||||
// Ruling 11 of milestone 16: the server is shutting down. Returning
|
||||
// without `end` leaves the response unterminated, which is what a
|
||||
// shutdown is; the client reconnects or gives up on its own.
|
||||
.closed => return,
|
||||
.timeout => {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
},
|
||||
.ready => {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! the next start runs — before a single row is written, so a settings PUT
|
||||
//! cannot leave a configuration the server would refuse to boot from.
|
||||
|
||||
const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -283,6 +284,25 @@ pub fn applyPut(
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
|
||||
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
|
||||
// handler does, and its input is the parsed patch alone — nothing under the
|
||||
// lock. Hashing inside the lock stalled every settings read and every other
|
||||
// mutation for its duration. The login path already hashes unlocked
|
||||
// (auth.zig), and `LiveHash`'s generation check closes the install race.
|
||||
const password = newPassword(patch);
|
||||
var new_hash: []const u8 = "";
|
||||
if (password) |plain| {
|
||||
if (plain.len > auth.max_password_len) {
|
||||
return .{ .fail = .{ .invalid = "web.password is too long" } };
|
||||
}
|
||||
const buf = try arena.alloc(u8, hash_buf_len);
|
||||
new_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
else => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
};
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
@@ -300,21 +320,10 @@ pub fn applyPut(
|
||||
) } };
|
||||
}
|
||||
|
||||
// The password never becomes a row. It is hashed here and the hash is what
|
||||
// the merged configuration — and therefore the settings table — carries.
|
||||
const password = newPassword(patch);
|
||||
// The password never becomes a row: the hash made above is what the merged
|
||||
// configuration — and therefore the settings table — carries.
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
if (password) |plain| {
|
||||
if (plain.len > auth.max_password_len) {
|
||||
return .{ .fail = .{ .invalid = "web.password is too long" } };
|
||||
}
|
||||
const buf = try arena.alloc(u8, hash_buf_len);
|
||||
cfg.web.password_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
else => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
};
|
||||
}
|
||||
if (password != null) cfg.web.password_hash = new_hash;
|
||||
cfg.web.password = "";
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
@@ -374,6 +383,7 @@ fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
/// password set through the API and one set through a config import produce the
|
||||
/// same kind of hash.
|
||||
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 {
|
||||
hash_stall.park(io);
|
||||
return std.crypto.pwhash.argon2.strHash(password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
@@ -390,6 +400,48 @@ fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]
|
||||
};
|
||||
}
|
||||
|
||||
/// Holds a hash still so a test can prove another request runs beside it. The
|
||||
/// hash finishing on its own would prove nothing: before ruling 18 a settings
|
||||
/// GET also completed, it merely waited out the hash first. The storage exists
|
||||
/// in a test build only, and `park` reduces to nothing everywhere else — the
|
||||
/// rotation seam's shape (logging.zig).
|
||||
const hash_stall = if (builtin.is_test) struct {
|
||||
var armed: bool = false;
|
||||
var parked: std.Io.Event = .unset;
|
||||
var release: std.Io.Event = .unset;
|
||||
|
||||
fn park(io: std.Io) void {
|
||||
if (!armed) return;
|
||||
parked.set(io);
|
||||
release.waitUncancelable(io);
|
||||
}
|
||||
} else struct {
|
||||
fn park(io: std.Io) void {
|
||||
_ = io;
|
||||
}
|
||||
};
|
||||
|
||||
/// The seam's controls, for the test that proves a settings read runs beside a
|
||||
/// hash in flight. Present in a test build only.
|
||||
pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
pub fn arm() void {
|
||||
hash_stall.parked = .unset;
|
||||
hash_stall.release = .unset;
|
||||
hash_stall.armed = true;
|
||||
}
|
||||
|
||||
/// Returns once a hash is parked on the seam.
|
||||
pub fn waitParked(io: std.Io) void {
|
||||
hash_stall.parked.waitUncancelable(io);
|
||||
}
|
||||
|
||||
/// Lets the parked hash finish and disarms the seam for the next test.
|
||||
pub fn release(io: std.Io) void {
|
||||
hash_stall.armed = false;
|
||||
hash_stall.release.set(io);
|
||||
}
|
||||
} else struct {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -36,6 +36,8 @@ const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const server = @import("server.zig");
|
||||
const tcp_server = @import("../server/tcp_server.zig");
|
||||
const udp_server = @import("../server/udp_server.zig");
|
||||
|
||||
/// The exposition format version, as the 0.0.4 specification writes it.
|
||||
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
|
||||
@@ -133,6 +135,13 @@ pub const Sample = struct {
|
||||
/// disabled or its bind failed, like every other unwired collaborator.
|
||||
doh_listener: ?DohListenerSample = null,
|
||||
dot_listener: ?dot_server.StatsSnapshot = null,
|
||||
/// The plain-DNS listener families (ruling 13). The app binds one listener
|
||||
/// per address family, and both answer the same port for the same reason,
|
||||
/// so their counters are summed into one family rather than labelled: an
|
||||
/// operator asks how much UDP/53 dropped, not how much of it arrived over
|
||||
/// IPv6. Absent when no listener is wired, like every other collaborator.
|
||||
udp_listener: ?udp_server.Snapshot = null,
|
||||
tcp_listener: ?tcp_server.Snapshot = null,
|
||||
upstreams: []const UpstreamSample = &.{},
|
||||
};
|
||||
|
||||
@@ -214,11 +223,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
}
|
||||
if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();
|
||||
|
||||
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);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// Adds one snapshot per listener field by field. Null for an empty slice, so
|
||||
/// an unbound listener omits its family rather than reporting zeros.
|
||||
///
|
||||
/// A `u64` counter cannot realistically overflow the sum of four of them, and
|
||||
/// wrapping addition would be a worse answer than a wrong-looking large one, so
|
||||
/// the addition is the ordinary checked one.
|
||||
fn sumListeners(comptime Snapshot: type, comptime Server: type, listeners: []const *Server) ?Snapshot {
|
||||
if (listeners.len == 0) return null;
|
||||
|
||||
var total: Snapshot = undefined;
|
||||
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
|
||||
@field(total, field.name) = 0;
|
||||
}
|
||||
for (listeners) |listener| {
|
||||
const one = listener.snapshotStats();
|
||||
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
|
||||
@field(total, field.name) += @field(one, field.name);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters {
|
||||
var out: DnsCounters = undefined;
|
||||
inline for (dns_stat_fields, 0..) |field, i| {
|
||||
@@ -339,6 +373,12 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.udp_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
|
||||
}
|
||||
if (sample.tcp_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
|
||||
}
|
||||
if (sample.doh_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener);
|
||||
}
|
||||
@@ -705,6 +745,103 @@ test "an unwired collaborator omits its family rather than reporting zeros" {
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_"));
|
||||
}
|
||||
|
||||
test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
const text = try renderToString(testing.allocator, .{
|
||||
.udp_listener = .{
|
||||
.received = 90,
|
||||
.dropped_oversize = 1,
|
||||
.dropped_no_slot = 2,
|
||||
.dropped_handler = 3,
|
||||
.receive_errors = 4,
|
||||
.send_errors = 5,
|
||||
},
|
||||
.tcp_listener = .{
|
||||
.accepted = 12,
|
||||
.rejected_at_capacity = 6,
|
||||
.rejected_at_shutdown = 7,
|
||||
.accept_errors = 8,
|
||||
.connection_errors = 9,
|
||||
.idle_timeouts = 10,
|
||||
},
|
||||
});
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n"));
|
||||
|
||||
// One family per listener kind, whatever the number of listeners behind it:
|
||||
// the counters are summed, not labelled.
|
||||
try testing.expectEqual(
|
||||
@as(usize, 1),
|
||||
std.mem.count(u8, text, "# TYPE nxdns_udp_server_received_total counter\n"),
|
||||
);
|
||||
}
|
||||
|
||||
test "one family covers all four listeners, summed" {
|
||||
// Only `stats` is read, so the listeners need no socket: `snapshotStats`
|
||||
// loads counters and touches nothing else.
|
||||
var udp6: udp_server.UdpServer = undefined;
|
||||
udp6.stats = .{};
|
||||
udp6.stats.received.store(10, .monotonic);
|
||||
udp6.stats.dropped_no_slot.store(1, .monotonic);
|
||||
|
||||
var udp4: udp_server.UdpServer = undefined;
|
||||
udp4.stats = .{};
|
||||
udp4.stats.received.store(7, .monotonic);
|
||||
udp4.stats.dropped_no_slot.store(2, .monotonic);
|
||||
|
||||
var tcp6: tcp_server.TcpServer = undefined;
|
||||
tcp6.stats = .{};
|
||||
tcp6.stats.accepted.store(4, .monotonic);
|
||||
|
||||
var tcp4: tcp_server.TcpServer = undefined;
|
||||
tcp4.stats = .{};
|
||||
tcp4.stats.accepted.store(5, .monotonic);
|
||||
tcp4.stats.idle_timeouts.store(3, .monotonic);
|
||||
|
||||
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 17), udp.received);
|
||||
try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot);
|
||||
try testing.expectEqual(@as(u64, 0), udp.send_errors);
|
||||
|
||||
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 9), tcp.accepted);
|
||||
try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);
|
||||
|
||||
// No listener at all is a missing family, not a family of zeros.
|
||||
try testing.expectEqual(
|
||||
@as(?udp_server.Snapshot, null),
|
||||
sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{}),
|
||||
);
|
||||
}
|
||||
|
||||
test "the three forward-client counters reach the DNS families" {
|
||||
var dns: DnsCounters = @splat(0);
|
||||
dns[fieldIndex("forward_udp_truncated")] = 2;
|
||||
dns[fieldIndex("forward_foreign_datagrams")] = 3;
|
||||
dns[fieldIndex("forward_failures")] = 4;
|
||||
|
||||
const text = try renderToString(testing.allocator, .{ .dns = dns });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_udp_truncated_total 2\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_foreign_datagrams_total 3\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_failures_total 4\n"));
|
||||
}
|
||||
|
||||
test "listener counters render only for the wired servers" {
|
||||
|
||||
+93
-9
@@ -51,6 +51,8 @@ const query_sink = @import("../server/query_sink.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const router = @import("router.zig");
|
||||
const sse = @import("sse.zig");
|
||||
const tcp_server = @import("../server/tcp_server.zig");
|
||||
const udp_server = @import("../server/udp_server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_server);
|
||||
|
||||
@@ -151,6 +153,10 @@ pub const WebState = struct {
|
||||
/// (milestone-10 ruling 10).
|
||||
doh_listener: ?*doh_server.DohServer = null,
|
||||
dot_listener: ?*dot_server.DotServer = null,
|
||||
/// The plain DNS listeners. The app binds one per family per protocol, so
|
||||
/// `/metrics` sums each family across its slice (milestone-16 ruling 13).
|
||||
udp_listeners: []const *udp_server.UdpServer = &.{},
|
||||
tcp_listeners: []const *tcp_server.TcpServer = &.{},
|
||||
|
||||
/// The web task's own connections (m7 ruling 21) — never the DNS path's.
|
||||
config_db: ?*db.Db = null,
|
||||
@@ -357,6 +363,12 @@ pub const Server = struct {
|
||||
log.debug("web listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// Ruling 11 of milestone 16, before `beginShutdown`: a live-query task
|
||||
// parked in `Hub.wait` is waiting on an event, not on its socket, so
|
||||
// shutting the connection down does not reach it. Without this the
|
||||
// drain below waits out one heartbeat interval per idle stream.
|
||||
if (self.state.hub) |hub| hub.close(io);
|
||||
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
@@ -507,7 +519,7 @@ pub const Server = struct {
|
||||
const raw_path = copied[0..split];
|
||||
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
|
||||
|
||||
const cookie = copyHeader(request, "cookie", &conn.cookie_buf);
|
||||
const cookie = copyCookie(request, &conn.cookie_buf);
|
||||
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
|
||||
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
|
||||
|
||||
@@ -606,18 +618,60 @@ pub const Server = struct {
|
||||
};
|
||||
|
||||
/// Copies one header value into `buf`. A value too long for its budget reads as
|
||||
/// absent: the three headers this applies to are a session cookie, an
|
||||
/// `accept-encoding` and an `if-none-match`, and losing any of them degrades to
|
||||
/// unauthenticated, uncompressed and unconditional — never to a wrong answer.
|
||||
/// absent: the headers this applies to are an `accept-encoding` and an
|
||||
/// `if-none-match`, and losing either degrades to uncompressed and
|
||||
/// unconditional — never to a wrong answer. The cookie header has its own
|
||||
/// copier, because losing it costs the session (ruling 7 of milestone 16).
|
||||
fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 {
|
||||
const value = headerValue(request, name) orelse return "";
|
||||
if (value.len > buf.len) return "";
|
||||
@memcpy(buf[0..value.len], value);
|
||||
return buf[0..value.len];
|
||||
}
|
||||
|
||||
/// The first value sent under `name`, borrowed from the request head.
|
||||
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |header| {
|
||||
if (!std.ascii.eqlIgnoreCase(header.name, name)) continue;
|
||||
if (header.value.len > buf.len) return "";
|
||||
@memcpy(buf[0..header.value.len], header.value);
|
||||
return buf[0..header.value.len];
|
||||
if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
|
||||
}
|
||||
return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Milestone-16 ruling 7. The cookie header is the one budget a foreign party
|
||||
/// can spend: behind a reverse proxy on a shared domain, every other cookie set
|
||||
/// for the domain rides along. Treating the whole header as absent then logs the
|
||||
/// operator out of a working session with nothing in the log to explain it, so
|
||||
/// an oversized header keeps the session pair, drops the rest, and says so.
|
||||
fn copyCookie(request: *http.Server.Request, buf: []u8) []const u8 {
|
||||
const value = headerValue(request, "cookie") orelse return "";
|
||||
if (value.len <= buf.len) {
|
||||
@memcpy(buf[0..value.len], value);
|
||||
return buf[0..value.len];
|
||||
}
|
||||
|
||||
const kept = sessionPairOnly(value, buf);
|
||||
// The session value is a random id and the name is a constant, so neither
|
||||
// the size nor the outcome discloses anything the client did not send.
|
||||
log.debug("cookie header of {d} bytes exceeds the {d} byte budget; {s}", .{
|
||||
value.len,
|
||||
buf.len,
|
||||
if (kept.len == 0) "no session cookie kept" else "kept the session cookie alone",
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// Rewrites an oversized cookie header as just its session pair. Empty when the
|
||||
/// header carries no session cookie, or when even the pair is over budget.
|
||||
fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
|
||||
const session = http_util.cookieValue(value, auth.cookie_name) orelse return "";
|
||||
const len = auth.cookie_name.len + 1 + session.len;
|
||||
if (len > buf.len) return "";
|
||||
|
||||
@memcpy(buf[0..auth.cookie_name.len], auth.cookie_name);
|
||||
buf[auth.cookie_name.len] = '=';
|
||||
@memcpy(buf[auth.cookie_name.len + 1 ..][0..session.len], session);
|
||||
return buf[0..len];
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex, so it is testable without a backend.
|
||||
@@ -697,6 +751,36 @@ test "the over-capacity response is a well formed 503" {
|
||||
try testing.expectEqualStrings(over_capacity_body, over_capacity_response[split + 4 ..]);
|
||||
}
|
||||
|
||||
test "an oversized cookie header keeps its session pair and nothing else" {
|
||||
var buf: [http_util.max_cookie_len]u8 = undefined;
|
||||
var header: std.ArrayList(u8) = .empty;
|
||||
defer header.deinit(testing.allocator);
|
||||
|
||||
try header.appendSlice(testing.allocator, "consent=yes; ");
|
||||
try header.appendSlice(testing.allocator, auth.cookie_name ++ "=abc123; ");
|
||||
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
|
||||
|
||||
const kept = sessionPairOnly(header.items, &buf);
|
||||
try testing.expectEqualStrings(auth.cookie_name ++ "=abc123", kept);
|
||||
try testing.expectEqualStrings("abc123", http_util.cookieValue(kept, auth.cookie_name).?);
|
||||
}
|
||||
|
||||
test "an oversized cookie header with no session pair keeps nothing" {
|
||||
var buf: [http_util.max_cookie_len]u8 = undefined;
|
||||
var header: std.ArrayList(u8) = .empty;
|
||||
defer header.deinit(testing.allocator);
|
||||
|
||||
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
|
||||
|
||||
try testing.expectEqualStrings("", sessionPairOnly(header.items, &buf));
|
||||
}
|
||||
|
||||
test "a session pair too long for the buffer keeps nothing" {
|
||||
var buf: [32]u8 = undefined;
|
||||
const header = auth.cookie_name ++ "=" ++ ("v" ** 64);
|
||||
try testing.expectEqualStrings("", sessionPairOnly(header, &buf));
|
||||
}
|
||||
|
||||
test "an unconfigured password leaves every route open" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
+76
-6
@@ -29,14 +29,18 @@ pub const ring_capacity = 64;
|
||||
|
||||
pub const SubscriberId = enum(u8) { _ };
|
||||
|
||||
/// What `wait` returns: an entry (or the overflow flag) is ready, or the
|
||||
/// caller's timeout passed and it owes the client a heartbeat.
|
||||
pub const Wake = enum { ready, timeout };
|
||||
/// What `wait` returns: an entry (or the overflow flag) is ready, the caller's
|
||||
/// timeout passed and it owes the client a heartbeat, or the hub is closing and
|
||||
/// the subscriber must end its response now.
|
||||
pub const Wake = enum { ready, timeout, closed };
|
||||
|
||||
pub const Hub = struct {
|
||||
/// Guards every field of every slot. `publish` runs on the DNS hot path,
|
||||
/// so the critical section is copies and flag writes only.
|
||||
mutex: std.Io.Mutex,
|
||||
/// Milestone-16 ruling 11. Set once, never cleared: a hub that is closing
|
||||
/// belongs to a server that is going away.
|
||||
closing: bool,
|
||||
slots: [max_subscribers]Slot,
|
||||
|
||||
const Slot = struct {
|
||||
@@ -56,6 +60,7 @@ pub const Hub = struct {
|
||||
/// The ring storage stays undefined: `len` says which slots hold entries.
|
||||
pub fn init(self: *Hub) void {
|
||||
self.mutex = .init;
|
||||
self.closing = false;
|
||||
for (&self.slots) |*slot| {
|
||||
slot.active = false;
|
||||
slot.overflowed = false;
|
||||
@@ -99,6 +104,22 @@ pub const Hub = struct {
|
||||
slot.head = 0;
|
||||
}
|
||||
|
||||
/// Milestone-16 ruling 11. Ends every live stream.
|
||||
///
|
||||
/// Without this, a graceful drain waits out one heartbeat interval per idle
|
||||
/// subscriber: shutting the sockets down does not reach a task parked inside
|
||||
/// `wait`, which is waiting on an event, not on the peer. Called before the
|
||||
/// web listener begins its shutdown.
|
||||
pub fn close(self: *Hub, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.closing = true;
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.active) slot.event.set(io);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `entry` into every live ring and wakes its subscriber. Called
|
||||
/// once per logged query.
|
||||
pub fn publish(self: *Hub, io: std.Io, entry: Entry) void {
|
||||
@@ -140,8 +161,9 @@ pub const Hub = struct {
|
||||
return self.slotOf(id).overflowed;
|
||||
}
|
||||
|
||||
/// Blocks until something is ready for this subscriber or `timeout`
|
||||
/// passes; `.timeout` is the heartbeat's cue.
|
||||
/// Blocks until something is ready for this subscriber, `timeout` passes,
|
||||
/// or the hub closes; `.timeout` is the heartbeat's cue and `.closed` ends
|
||||
/// the stream.
|
||||
///
|
||||
/// The event is reset under the mutex and only while the ring is empty, so
|
||||
/// a `publish` that lands between the check and the wait sets the event
|
||||
@@ -157,6 +179,10 @@ pub const Hub = struct {
|
||||
timeout: std.Io.Clock.Duration,
|
||||
) std.Io.Cancelable!Wake {
|
||||
self.mutex.lockUncancelable(io);
|
||||
if (self.closing) {
|
||||
self.mutex.unlock(io);
|
||||
return .closed;
|
||||
}
|
||||
const slot = self.slotOf(id);
|
||||
if (slot.len > 0 or slot.overflowed) {
|
||||
self.mutex.unlock(io);
|
||||
@@ -169,7 +195,13 @@ pub const Hub = struct {
|
||||
error.Timeout => return .timeout,
|
||||
error.Canceled => |e| return e,
|
||||
};
|
||||
return .ready;
|
||||
|
||||
// `close` wakes the same event a publish does, so the flag is what tells
|
||||
// the two apart. Reading it here rather than on the next call through
|
||||
// keeps the drain from writing one more frame into a dying connection.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return if (self.closing) .closed else .ready;
|
||||
}
|
||||
|
||||
fn slotOf(self: *Hub, id: SubscriberId) *Slot {
|
||||
@@ -401,6 +433,44 @@ test "an overflow wakes a waiting subscriber" {
|
||||
try testing.expect(hub.overflowed(io, id));
|
||||
}
|
||||
|
||||
test "close wakes a parked subscriber instead of leaving it on the heartbeat" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const hub = try newHub(testing.allocator);
|
||||
defer testing.allocator.destroy(hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
// Far longer than the 15 s heartbeat the handler passes, so a pass here
|
||||
// cannot come from the timeout arm.
|
||||
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(600), .clock = .awake };
|
||||
var future = try io.concurrent(Hub.wait, .{ hub, io, id, long });
|
||||
|
||||
hub.close(io);
|
||||
|
||||
try testing.expectEqual(Wake.closed, try future.await(io));
|
||||
}
|
||||
|
||||
test "wait on a closed hub returns at once, with no entry pending" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const hub = try newHub(testing.allocator);
|
||||
defer testing.allocator.destroy(hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
hub.close(io);
|
||||
|
||||
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(600), .clock = .awake };
|
||||
try testing.expectEqual(Wake.closed, try hub.wait(io, id, long));
|
||||
}
|
||||
|
||||
test "publishing while subscribers come and go reaches only the live ones" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -62,6 +62,7 @@ const handlers_live = @import("handlers/live.zig");
|
||||
const handlers_lookup = @import("handlers/lookup.zig");
|
||||
const handlers_pause = @import("handlers/pause.zig");
|
||||
const handlers_queries = @import("handlers/queries.zig");
|
||||
const handlers_settings = @import("handlers/settings.zig");
|
||||
const handlers_stats = @import("handlers/stats.zig");
|
||||
const handlers_upstream_health = @import("handlers/upstream_health.zig");
|
||||
const handlers_version = @import("handlers/version.zig");
|
||||
@@ -147,7 +148,9 @@ const Conn = struct {
|
||||
extra_header: ?[]const u8,
|
||||
body: ?[]const u8,
|
||||
) !void {
|
||||
var buf: [2048]u8 = undefined;
|
||||
// Room for an over-budget cookie header (ruling 7 of milestone 16) and
|
||||
// still under the server's 8 KiB maximum request head.
|
||||
var buf: [6144]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target });
|
||||
if (extra_header) |line| try w.print("{s}\r\n", .{line});
|
||||
@@ -814,6 +817,19 @@ test "the certs reload payload with both endpoints disabled parses strictly" {
|
||||
|
||||
const test_password = "correct horse battery staple";
|
||||
|
||||
/// Hashes on an `Io` of its own, before the environment exists, so nothing
|
||||
/// mutates a `WebState` the server tasks are already reading.
|
||||
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer hash_threaded.deinit();
|
||||
return std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, buf, hash_threaded.io());
|
||||
}
|
||||
|
||||
fn authMatrix(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
|
||||
@@ -871,21 +887,8 @@ test "W10 auth on: password-hashed environment enforces the session matrix" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// Hashed before the environment exists, so nothing mutates a `WebState`
|
||||
// the server tasks are reading.
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, &hash_buf, hash_threaded.io()) catch |err| {
|
||||
hash_threaded.deinit();
|
||||
return err;
|
||||
};
|
||||
hash_threaded.deinit();
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
@@ -923,6 +926,77 @@ test "W10 auth off: an empty hash leaves every route open" {
|
||||
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oversized cookie headers (ruling 7 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
|
||||
/// reverse proxy on a shared domain does.
|
||||
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
|
||||
var index: usize = 0;
|
||||
while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
|
||||
try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
|
||||
}
|
||||
}
|
||||
|
||||
fn oversizedCookie(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
||||
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
||||
|
||||
var session_pair: [256]u8 = undefined;
|
||||
@memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);
|
||||
|
||||
// The session pair buried in the middle of an over-budget header. Before
|
||||
// ruling 7 the whole header read as absent and this 401'd.
|
||||
var with_session: std.ArrayList(u8) = .empty;
|
||||
defer with_session.deinit(env.gpa);
|
||||
try with_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
|
||||
try with_session.appendSlice(env.gpa, "; ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try testing.expect(with_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", with_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// The same size of header carrying no session pair stays unauthenticated:
|
||||
// the degradation keeps the session, it does not open the door.
|
||||
var without_session: std.ArrayList(u8) = .empty;
|
||||
defer without_session.deinit(env.gpa);
|
||||
try without_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &without_session);
|
||||
try testing.expect(without_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", without_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 401), response.status);
|
||||
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
||||
}
|
||||
|
||||
test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, oversizedCookie, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rate limiting (ruling 19)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1023,6 +1097,58 @@ test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" {
|
||||
try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env });
|
||||
}
|
||||
|
||||
/// Opens a live stream and reads the preamble, so the subscriber task is in
|
||||
/// its wait loop by the time this returns.
|
||||
fn openLiveStream(io: std.Io, env: *Env, conn: *Conn, seen: *std.ArrayList(u8)) anyerror!void {
|
||||
try conn.connect(io, env.addr);
|
||||
// Stays open on success — the caller closes it. Only a failure here leaves
|
||||
// a socket for this to reclaim.
|
||||
errdefer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/queries/live", null, null);
|
||||
const head = try conn.receiveHead();
|
||||
try testing.expectEqual(@as(u16, 200), head.status);
|
||||
try conn.readChunkedUntil(seen, env.gpa, "retry: 3000");
|
||||
}
|
||||
|
||||
test "W10 shutdown with a live stream open does not wait out a heartbeat" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// The teardown frees the environment's own `Io`, so the clock that times it
|
||||
// has to be somebody else's.
|
||||
var clock_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer clock_threaded.deinit();
|
||||
const clock_io = clock_threaded.io();
|
||||
|
||||
var env = try Env.create(gpa, .{});
|
||||
|
||||
var conn: Conn = undefined;
|
||||
var seen: std.ArrayList(u8) = .empty;
|
||||
defer seen.deinit(gpa);
|
||||
|
||||
// Not a `defer`: the teardown is what this test measures, so it runs below
|
||||
// rather than after the assertion.
|
||||
bounded(env.io(), default_budget, openLiveStream, .{ env.io(), env, &conn, &seen }) catch |err| {
|
||||
env.destroy();
|
||||
return err;
|
||||
};
|
||||
|
||||
// Closed before the teardown because the teardown frees the `Io` this
|
||||
// socket belongs to. It does not weaken the test: the subscriber task is
|
||||
// parked on a hub event, not on this socket, so closing the client end
|
||||
// does not wake it — only `Hub.close` does (ruling 11 of milestone 16).
|
||||
conn.close(env.io());
|
||||
|
||||
const started = std.Io.Clock.awake.now(clock_io);
|
||||
env.destroy();
|
||||
const elapsed = started.durationTo(std.Io.Clock.awake.now(clock_io));
|
||||
|
||||
// Before ruling 11 the drain waited out the full 15 s heartbeat interval.
|
||||
try testing.expect(elapsed.toMilliseconds() < 5_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pagination walk (ruling 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1399,6 +1525,57 @@ test "W10 settings written through the API read back changed" {
|
||||
try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// password hashing outside config_lock (ruling 18 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
}
|
||||
|
||||
fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
|
||||
var put = try io.concurrent(putNewPassword, .{ io, env });
|
||||
errdefer {
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
put.await(io) catch {};
|
||||
}
|
||||
|
||||
handlers_settings.hash_stall_control.waitParked(io);
|
||||
|
||||
// The whole point of the ruling: this read completes while the hash is
|
||||
// still held. Before the fix it blocked on `config_lock` until the hash
|
||||
// finished, and the seam would deadlock the test rather than answer.
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var reader: Conn = undefined;
|
||||
try reader.connect(io, env.addr);
|
||||
defer reader.close(io);
|
||||
|
||||
try reader.request("GET", "/api/settings", null, null);
|
||||
const response = try reader.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
try put.await(io);
|
||||
}
|
||||
|
||||
test "W10 a settings read completes while a password hash is still running" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
handlers_settings.hash_stall_control.arm();
|
||||
try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user