milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+4 -3
View File
@@ -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| {
+10 -3
View File
@@ -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 => {},
}
}
+66 -14
View File
@@ -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
// ---------------------------------------------------------------------------
+137
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+192 -15
View File
@@ -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)
// ---------------------------------------------------------------------------