milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
+18
-18
@@ -99,22 +99,22 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 3: Silent failures and swallowed errors (11 findings)
|
||||
|
||||
- **[medium] src/filter/manager.zig:541 — loadSource swallows error.Canceled into a per-source `.load_failed` status.**
|
||||
- **[medium — CLOSED m16] src/filter/manager.zig:541 — loadSource swallows error.Canceled into a per-source `.load_failed` status.**
|
||||
Every other catch site in the file (roughly ten) propagates Canceled per the milestone-3 invariant "error.Canceled must never be recorded at all"; here a cancellation during a reload's file reads records a bogus "Canceled" load failure, consumes the one-shot cancellation signal, and publishes a snapshot with that source excluded. Fix: one more catch arm per readFileAlloc call (`error.Canceled => return error.Canceled`; Manager.Error already has the member).
|
||||
|
||||
- **[medium] src/platform/tls_server.zig:430 — BIO callbacks discard the concrete transport error, including cancellation.**
|
||||
- **[medium — CLOSED m16] src/platform/tls_server.zig:430 — BIO callbacks discard the concrete transport error, including cancellation.**
|
||||
net_reader.err/net_writer.err (which hold Reset/Timeout/Canceled) are never read; everything collapses to MBEDTLS_ERR_NET_RECV/SEND_FAILED, so a routine idle-budget cancel surfaces as a warn-level "mbedtls_ssl_read failed" and peer resets are indistinguishable from timeouts. The client side (dot_client's concreteRead/concreteWrite) solved exactly this. Fix: capture the stashed causes in the BIO callbacks and fold them into the error surface — note the ReadError set must be widened, not just the field captured.
|
||||
|
||||
- **[medium] src/web/server.zig:616 — a Cookie header over 1024 bytes silently degrades to unauthenticated.**
|
||||
- **[medium — CLOSED m16] src/web/server.zig:616 — a Cookie header over 1024 bytes silently degrades to unauthenticated.**
|
||||
copyHeader returns "" with no log; under the documented reverse-proxy-on-shared-domain deployment (foreign cookies riding along), every request 401s and the operator sees an unexplained login loop with zero diagnostics anywhere on the request path. Fix: extract only the nxdns_session pair from an oversized header (cookieValue already parses), or at minimum log the size-drop (no secret-logging conflict).
|
||||
|
||||
- **[medium] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.**
|
||||
- **[medium — CLOSED m16] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.**
|
||||
The refocus-after-30s refetch shifts the newest-100 boundary up while `extra` starts strictly below the old cursor; the missing rows are in neither, and the stale cursorOverride means load-more never heals it. On a live DNS server the trigger is routine. Fix: useInfiniteQuery (or disable background refetch while extra is non-empty), or detect the discontinuity and reset the accumulation.
|
||||
|
||||
- **[low] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.**
|
||||
Verification narrowed the blast radius: the pool's select harness means shutdown cancellation is handled correctly despite this, so the reachable impact is rare mid-exchange local-resource errors (e.g. ENOBUFS) recorded as peer faults against a healthy upstream — still a stated spec-invariant violation, fixed once for DoT and left in DoH. Fix: the same unwrap the other two transports carry.
|
||||
|
||||
- **[low] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
|
||||
- **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
|
||||
Reachable today via a race between startupPass and a web-inserted source; the milestone-5 policy says every non-ok state "is recorded and surfaced, not swallowed". Fix: warn on the fall-through or have refreshSourceLocked insert the missing entry.
|
||||
|
||||
- **[low] src/web/http_util.zig:335 — static header-array overflow reported as OutOfMemory.**
|
||||
@@ -166,13 +166,13 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 5: Protocol robustness (5 findings)
|
||||
|
||||
- **[medium] src/cache/dns_cache.zig:110 — truncated (TC=1) responses are cacheable and cached for full TTL.**
|
||||
- **[medium — CLOSED m16] src/cache/dns_cache.zig:110 — truncated (TC=1) responses are cacheable and cached for full TTL.**
|
||||
classify checks rcode and ancount but never `flags.tc`, and validateResponse doesn't either; a misbehaving upstream returning TC=1 over DoH/DoT/forward-TCP gets its partial answer cached and served (TC bit intact, even over TCP — a protocol violation that can loop retrying clients) for up to 86400s. RFC 2181 §9 forbids this; no spec declares it out of scope. Fix: reject tc in classify, arguably also in validateResponse.
|
||||
|
||||
- **[medium] src/filter/parsers.zig:66 — the format sniffer misclassifies hosts files with `##` banner comments as ABP.**
|
||||
- **[medium — CLOSED m16] src/filter/parsers.zig:66 — the format sniffer misclassifies hosts files with `##` banner comments as ABP.**
|
||||
hasAbpMarker runs before isComment and matches `##` unanchored with no comment guard (unlike the deliberately guarded `$` branch two lines later). Reproduced with the literal URLhaus banner style: the whole file parses as ABP, `0.0.0.0` enters the compiled domain set, and — worse than the original finding — hosts lines with inline reference comments containing URLs are silently dropped (real blocking loss). Fix caveat from verification: guarding the element-hiding branch with !isComment is a circular no-op; instead anchor the `##` match per the As-built note, or treat `##` at line start on a `#`-initial line as a banner.
|
||||
|
||||
- **[medium] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.**
|
||||
- **[medium — CLOSED m16] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.**
|
||||
Per the WHATWG spec a non-200 response fails the connection permanently after one error event, so the 3-consecutive-errors threshold is unreachable on exactly the 429/401 paths it was built for: the UI shows "Reconnecting…" forever, the capped state and session probe are dead, and the spec's required "too many live viewers + retry" state is violated. FakeEventSource has no readyState/reconnect semantics, so tests pass — the mocked-network class again. Fix: expose readyState on EventSourceLike, treat CLOSED in the error handler as permanent failure, and run the session probe there.
|
||||
|
||||
- **[low] src/filter/fetcher.zig:169 — TLS error classification by @errorName prefix string-matching.**
|
||||
@@ -183,39 +183,39 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
|
||||
|
||||
## Theme 6: Concurrency, locking and lifecycle coupling (6 findings)
|
||||
|
||||
- **[medium] src/filter/manager.zig:609 — writer_lock held across every download of a refresh pass, stalling all web mutations' reloads.** *(merged: web/handlers/blocklists.zig:171, the same debt from the manual-refresh-endpoint angle)*
|
||||
- **[medium — CLOSED m16] src/filter/manager.zig:609 — writer_lock held across every download of a refresh pass, stalling all web mutations' reloads.** *(merged: web/handlers/blocklists.zig:171, the same debt from the manual-refresh-endpoint angle)*
|
||||
refreshAll holds writer_lock uncancelably across N × 300s download budgets plus compiles; every mutation handler ends in Manager.reload on the same lock, so an unrelated rule save parks uncancelably (on a request path with deliberately no timeout, occupying one of 64 slots) until the pass ends. The spec's serialization rationale covers only readers-never-wait; writer-vs-writer latency is unaddressed emergent behavior. DNS serving is unaffected and the stalled edit is not lost. Fix: download+compile to tmp files outside the lock, take it only for publish+DB write+reload — or a 202-with-poll job model for the manual endpoint.
|
||||
|
||||
- **[medium] src/storage/retention.zig:95 — VACUUM runs even when the disk monitor says critical.**
|
||||
- **[medium — CLOSED m16] src/storage/retention.zig:95 — VACUUM runs even when the disk monitor says critical.**
|
||||
The logger and blocklist refresher gate on `writesAllowed()`; the seventh-pass VACUUM — the most expensive write the program makes, needing roughly DB-size free space — fires unconditionally, transiently draining the exact filesystem the monitor watches, then failing SQLITE_FULL with no retry for seven daily passes. Prune and checkpoint should keep running (they free space). Fix: pass the optional monitor into runOnce the way the two sibling tasks already do, gating only the vacuum step.
|
||||
|
||||
- **[medium] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.**
|
||||
- **[medium — CLOSED m16] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.**
|
||||
Socket shutdown cannot wake a task parked on the per-slot Event; only the heartbeat write fails, up to 15s later — measured: the SSE integration teardown sits exactly 15s in group.await. Production is masked by the cancel path, but the documented "shutdown unblocks every connection" contract is violated. Fix: Hub.close(io) setting a shutdown flag and signaling every active slot's event, called from beginShutdown.
|
||||
|
||||
- **[low] src/web/handlers/groups.zig:70 — reload runs outside config_lock in five handlers but inside it in local.zig; the unlocked variant is safe only via an undocumented Manager invariant.**
|
||||
Safety depends on Manager.reload re-reading the database under its own writer_lock — a cross-module fact stated nowhere at the call sites; changing reload to accept pre-read rows (the shape swapLocalTables already has) would silently regress five handlers with the exact ordering bug the milestone-8 review already fixed once in local.zig. Fix: document the re-read requirement at mutations.reload, or hold config_lock uniformly and document the cost.
|
||||
|
||||
- **[low] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.**
|
||||
- **[low — CLOSED m16] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.**
|
||||
Stalls every mutation handler and the settings GET for the hash duration on the Pi 5, contradicting the lock discipline auth.zig documents and the login path follows. Hashing depends only on the parsed patch; the LiveHash generation check already handles the concurrent-install race. Fix: hash before acquiring the lock.
|
||||
|
||||
- **[low] src/server/doh_server.zig:66 — idle_timeout only bounds the handshake; idle keep-alive clients pin DoH slots until restart.**
|
||||
- **[low — CLOSED m16] src/server/doh_server.zig:66 — idle_timeout only bounds the handshake; idle keep-alive clients pin DoH slots until restart.**
|
||||
The web-listener precedent the spec cites assumes clients eventually close; DoH stubs hold keep-alives by design, and with no TCP keepalive a vanished peer pins a slot permanently — while DoT on the same LAN reclaims after 10s. Fix: extend the race to receiveHead the way DoT races readPrefix, or at least rename the misleading option.
|
||||
|
||||
## Theme 7: Observability debt (6 findings)
|
||||
|
||||
- **[medium] src/server/udp_server.zig:38 — UDP/53 and TCP/53 listener stats are written and never read anywhere.**
|
||||
- **[medium — CLOSED m16] src/server/udp_server.zig:38 — UDP/53 and TCP/53 listener stats are written and never read anywhere.**
|
||||
dropped_no_slot/dropped_oversize increment with no metric, API field, or log line (errors log only at debug below the default level), while the DoT/DoH siblings export equivalent counters to /metrics. The module doc sells "dropped and counted" as the failure surface; the surface is unobservable. Fix: give both the same snapshotStats() + metric families the TLS listeners have.
|
||||
|
||||
- **[medium] src/local/forward_client.zig:45 — ForwardClient.Stats are instrumented but discarded in production.**
|
||||
- **[medium — CLOSED m16] src/local/forward_client.zig:45 — ForwardClient.Stats are instrumented but discarded in production.**
|
||||
The doc claims the foreign-datagram counter prevents "an unrecorded failure mode"; the only production caller builds a stack-local client per query and drops it, so the spoofing signal does not exist and truncation/failure counters never reach /metrics. Fix: aggregate into handler-owned atomics like the rest of the pipeline, or delete the Stats struct.
|
||||
|
||||
- **[medium] src/server/dot_server.zig:313 — a timed-out handshake counts as tls_handshake_failures on DoT but idle_timeouts on DoH.**
|
||||
- **[medium — CLOSED m16] src/server/dot_server.zig:313 — a timed-out handshake counts as tls_handshake_failures on DoT but idle_timeouts on DoH.**
|
||||
The DoT classification is the recorded spec ruling; DoH drifted in the copy, and since DoH requests have no timeout, its idle_timeouts metric can only ever mean handshake stalls — the same exported name carries disjoint semantics per listener, corrupting cross-listener dashboards. Fix: align DoH's .timed_out to tls_handshake_failures per the ruling.
|
||||
|
||||
- **[medium] src/platform/tls_server.zig:311 — warn-level logging for routine peer misbehavior contradicts the module's own debug ruling.**
|
||||
- **[medium — CLOSED m16] src/platform/tls_server.zig:311 — warn-level logging for routine peer misbehavior contradicts the module's own debug ruling.**
|
||||
A peer TCP drop is deliberately debug on the read path, then the close_notify against the dead socket warns; probes that send bytes or reject the cert warn per event, and .tls_server is not in the dedup scope set, so peer-driven warns can evict genuine warnings from the rotating log. Fix: apply the read path's peer-vs-local triage to close/handshake reporting, or add the scope to the dedup set.
|
||||
|
||||
- **[medium] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.**
|
||||
- **[medium — CLOSED m16] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.**
|
||||
Designed for background reclamation (preallocated stale_keys so sweep never allocates), but only the DNS-side limiter got scheduled in runMaintenance. Once 4096 distinct addresses are seen, the table stays full forever and every unknown-address request pays an O(4096) scan under the limiter mutex. Fix: add it to the maintenance loop, or delete sweep and own inline-eviction-only.
|
||||
|
||||
- **[low] src/server/handler.zig:255 — two tracker-mutex acquisitions per query (scan + full stats copy) purely to mirror one counter.**
|
||||
|
||||
@@ -318,6 +318,14 @@ from the rotating log.
|
||||
- Add `.tls_server` to `isDedupScope` (logging.zig:103-108) and to its
|
||||
membership test (:637-644).
|
||||
|
||||
**Scope widened during implementation (orchestrator ruling):** the level
|
||||
choice also applies to the `ssl_read` and `ssl_write` report sites, not only
|
||||
the two named ones. Ruling 15's "cause stashed for logging" wording requires
|
||||
the read site to consume the level, and an unconditional warn on the write
|
||||
half would keep the same spam vector open through `close` → `flush` against
|
||||
a dead peer. Peer-class causes are decided by pure helpers (`isPeerCause`,
|
||||
`causeLevel`); `check` still passes warn.
|
||||
|
||||
### 17. The query log heals its own gaps
|
||||
|
||||
`web/src/features/queries/QueryLogPage.tsx`: the hand-rolled accumulation
|
||||
|
||||
@@ -1675,7 +1675,9 @@ and the session text disagree, this section wins.
|
||||
**S1 parsers + wildcard.** `validate` counts labels before the 253-byte length check, so
|
||||
`TooManyLabels` is reachable. A hosts line with a sink address and no name is `.unsupported`.
|
||||
`||example.com` without `^` is `.wildcard` with `covers_apex = true`. `detectFormat` treats `$` as a
|
||||
marker only on non-comment lines; `||`, `@@` and element-hiding separators are matched anchored.
|
||||
marker only on non-comment lines; `||` and `@@` are matched anchored. Element-hiding separators are
|
||||
matched positionally since milestone 16 ruling 5: at offset 0 a selector must follow the separator;
|
||||
past offset 0 a domain-list character must precede it — so a `## Title` banner is not element hiding.
|
||||
`looksLikeIpLiteral` lives in `parsers.zig` and is shared with `parser_hosts.zig`.
|
||||
`wildcard.matches` is total on unvalidated input: the label splitter returns null above `max_labels`
|
||||
and `matches` returns false — no assert, because `parsers.zig` is the S9.3 fuzz root.
|
||||
|
||||
+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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { EventSourceLike } from "./useLiveQueries";
|
||||
import { EVENT_SOURCE_CLOSED, type EventSourceLike } from "./useLiveQueries";
|
||||
|
||||
const CONNECTING = 0;
|
||||
const OPEN = 1;
|
||||
|
||||
/** Test double for the injected EventSource constructor. */
|
||||
export class FakeEventSource implements EventSourceLike {
|
||||
readonly url: string;
|
||||
closed = false;
|
||||
readyState: number = CONNECTING;
|
||||
private listeners = new Map<string, Array<(event: { data?: unknown }) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
@@ -18,9 +22,20 @@ export class FakeEventSource implements EventSourceLike {
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
}
|
||||
|
||||
emit(type: string, event: { data?: unknown } = {}): void {
|
||||
if (type === "open") this.readyState = OPEN;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-200 response: the browser closes the source, then dispatches one
|
||||
* error event and never retries.
|
||||
*/
|
||||
failFatal(): void {
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
this.emit("error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,47 @@ test("repeated errors without open hit the cap state; retry reconnects", () => {
|
||||
expect(hook.result.current.status).toBe("open");
|
||||
});
|
||||
|
||||
test("a fatal rejection caps on the first error event and probes the session", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
await act(async () => {});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a fatal rejection with an expired session redirects to login", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||
const { sources } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a transient error leaves the source open and still takes three to cap", () => {
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD - 1; i++) {
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("retrying");
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
expect(probeSession).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a successful open resets the consecutive error count", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => sources[0]!.emit("error"));
|
||||
|
||||
@@ -10,8 +10,12 @@ export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
|
||||
close(): void;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/** `EventSource.CLOSED`: the browser gave up and will not retry. */
|
||||
export const EVENT_SOURCE_CLOSED = 2;
|
||||
|
||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||
|
||||
export interface LiveQueriesOptions {
|
||||
@@ -22,10 +26,11 @@ export interface LiveQueriesOptions {
|
||||
probeSession?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
// The SSE cap rejection (429) is invisible to EventSource beyond a bare
|
||||
// `error` event; this many consecutive errors without an intervening `open`
|
||||
// (the browser retries every 3s per the server's `retry: 3000`) stops the
|
||||
// stream and surfaces a manual-retry state.
|
||||
// A transient drop is invisible to EventSource beyond a bare `error` event;
|
||||
// this many consecutive errors without an intervening `open` (the browser
|
||||
// retries every 3s per the server's `retry: 3000`) stops the stream and
|
||||
// surfaces a manual-retry state. A non-200 response instead fails the source
|
||||
// permanently after one error event, and is handled by readyState below.
|
||||
export const CAP_ERROR_THRESHOLD = 3;
|
||||
|
||||
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
||||
@@ -121,16 +126,27 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
setRows(bufferRef.current);
|
||||
});
|
||||
|
||||
const giveUp = () => {
|
||||
es.close();
|
||||
setStatus("capped");
|
||||
// EventSource cannot surface a 401; an expired session looks
|
||||
// identical to the cap. Probe once on entering capped so the
|
||||
// user lands on login instead of a misleading capped message.
|
||||
probeSession().catch(handleUnauthorized);
|
||||
};
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
if (esRef.current !== es) return;
|
||||
// A 429 or 401 closes the source outright — no retry follows, so
|
||||
// the consecutive-error counter would never reach its threshold.
|
||||
if (es.readyState === EVENT_SOURCE_CLOSED) {
|
||||
errorsRef.current = CAP_ERROR_THRESHOLD;
|
||||
giveUp();
|
||||
return;
|
||||
}
|
||||
errorsRef.current += 1;
|
||||
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
|
||||
es.close();
|
||||
setStatus("capped");
|
||||
// EventSource cannot surface a 401; an expired session looks
|
||||
// identical to the cap. Probe once on entering capped so the
|
||||
// user lands on login instead of a misleading capped message.
|
||||
probeSession().catch(handleUnauthorized);
|
||||
giveUp();
|
||||
} else {
|
||||
setStatus("retrying");
|
||||
}
|
||||
|
||||
@@ -63,11 +63,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryLogPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
@@ -222,6 +228,47 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(20, "n20.example"), row(19, "n19.example")], next_before: 19 },
|
||||
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21 },
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesQuery } from "@/lib/queries";
|
||||
import { handleUnauthorized } from "@/lib/queryClient";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { qtypeName } from "./qtype";
|
||||
|
||||
@@ -12,6 +11,10 @@ const inputClass =
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
@@ -77,25 +80,20 @@ export default function QueryLogPage() {
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||
const [extra, setExtra] = useState<QueryRow[]>([]);
|
||||
const [cursorOverride, setCursorOverride] = useState<number | null | undefined>(undefined);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
const base = useQuery({ ...queriesQuery(applied), placeholderData: keepPreviousData });
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
|
||||
const rows: QueryRow[] = [...(base.data?.queries ?? []), ...extra];
|
||||
const nextBefore = cursorOverride !== undefined ? cursorOverride : (base.data?.next_before ?? null);
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const filterActive = Object.keys(applied).length > 0;
|
||||
|
||||
function resetAccumulation() {
|
||||
generation.current += 1;
|
||||
setExtra([]);
|
||||
setCursorOverride(undefined);
|
||||
setLoadingMore(false);
|
||||
setMoreError(null);
|
||||
}
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -109,7 +107,6 @@ export default function QueryLogPage() {
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -119,32 +116,11 @@ export default function QueryLogPage() {
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (nextBefore === null || loadingMore || base.isPlaceholderData) return;
|
||||
const startedGeneration = generation.current;
|
||||
setLoadingMore(true);
|
||||
setMoreError(null);
|
||||
api.getQueries({ ...applied, before: nextBefore })
|
||||
.then((page) => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setExtra((prev) => [...prev, ...page.queries]);
|
||||
setCursorOverride(page.next_before);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof api.ApiError && error.status === 401) {
|
||||
handleUnauthorized(error);
|
||||
return;
|
||||
}
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setMoreError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setLoadingMore(false);
|
||||
});
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,16 +212,16 @@ export default function QueryLogPage() {
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<p className="text-sm text-zinc-500">
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{nextBefore === null ? " — end of log" : ""}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{nextBefore !== null && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore || base.isPlaceholderData}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
className={buttonClass}
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+15
-4
@@ -1,4 +1,4 @@
|
||||
import { queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import type {
|
||||
BlocklistInput,
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PausePost,
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
RuleInput,
|
||||
SettingsPatch,
|
||||
UpstreamInput,
|
||||
@@ -20,7 +21,7 @@ export const queryKeys = {
|
||||
version: ["version"] as const,
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
queries: (filter: QueriesFilter) => ["queries", filter] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: ["upstream-health"] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
groups: ["groups"] as const,
|
||||
@@ -54,8 +55,18 @@ export const timeseriesQuery = (period: Period = "24h") =>
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const queriesQuery = (filter: QueriesFilter = {}) =>
|
||||
queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
|
||||
// Keyset pagination on `next_before` (handlers/queries.zig). A background
|
||||
// refetch replays every page in cursor order, so newly logged rows shift the
|
||||
// whole window instead of opening a gap between page 1 and page 2.
|
||||
export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKeys.queriesInfinite(filter),
|
||||
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
||||
api.getQueries(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (last: QueriesPage) => last.next_before ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const upstreamHealthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
groupsQuery,
|
||||
healthQuery,
|
||||
localRecordsQuery,
|
||||
queriesQuery,
|
||||
queriesInfiniteQuery,
|
||||
rulesQuery,
|
||||
settingsQuery,
|
||||
statsQuery,
|
||||
@@ -107,7 +107,7 @@ const dashboardRoute = createRoute({
|
||||
const queriesRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(queriesQuery({})),
|
||||
loader: ({ context }) => context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery({})),
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user