//! The blocklist manager (PLAN §4): the compiled files under //! `/blocklists/`, the refresh that produces them, the metadata //! columns it writes back, the snapshot built from them and the swap that //! publishes it. //! //! This is the only file in this milestone that touches both the database and //! the filesystem. Everything it composes — the parsers, the compiler, the //! domain sets, the rule sets and the snapshot — is pure and testable without //! either. //! //! **The swap is an `std.Io.RwLock`, not a lock-free pointer.** PLAN §7.3 says //! "readers lock-free"; this is a deliberate deviation. Freeing the old //! snapshot without a lock needs epoch-based reclamation or hazard pointers: a //! class of code that is very hard to get right and impossible to test //! convincingly, bought for a household resolver whose target is 100 qps. A //! shared lock held for the microseconds of one `evaluate` costs an uncontended //! atomic pair; the writer takes the exclusive lock only on a swap, which //! happens on refresh. The old snapshot is freed *after* `unlock` returns, and //! the `Handle` API makes "do not retain the pointer" the only shape a caller //! can write. //! //! The same lock guards the status table, which is written from the refresh //! task and read by the API. Both critical sections are short and hold no //! socket and no file, so the uncancelable lock forms are used: a lock this //! code takes is always released within a few instructions. //! //! 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` / `.allow.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; const model = @import("../config/model.zig"); const safe_url = @import("../safe_url.zig"); const db = @import("../storage/db.zig"); const clients_repo = @import("../storage/repositories/clients_repo.zig"); const groups_repo = @import("../storage/repositories/groups_repo.zig"); const rules_repo = @import("../storage/repositories/rules_repo.zig"); const sources_repo = @import("../storage/repositories/sources_repo.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); const events = @import("../storage/events.zig"); const compiler = @import("compiler.zig"); const fetcher = @import("fetcher.zig"); const matcher = @import("matcher.zig"); const parsers = @import("parsers.zig"); const log = std.log.scoped(.blocklist_manager); const Sha256 = std.crypto.hash.sha2.Sha256; /// `SourceStatus.last_error` is fixed-size so the failure path allocates /// nothing. pub const max_error_len: usize = 128; /// `SourceStatus.url` is fixed-size so a copied status borrows nothing. A /// blocklist url longer than this is truncated in the status only; the row /// keeps it whole. /// /// The log form of a url is bounded separately by `safe_url.max_len`. The two /// numbers agree today and answer different questions; neither follows the /// other. pub const max_url_len: usize = 255; /// A compiled body larger than this is refused at load. A source that reaches /// it produced more than `fetcher.max_body_bytes` of names, which cannot /// happen from a download this fetcher performed. pub const max_compiled_bytes: usize = 128 * 1024 * 1024; /// Buffer size for every file stream this file opens. One buffer is live per /// stage, and the stages do not overlap. const io_buf_len: usize = 64 * 1024; /// Holds the sniff sample: `parsers.sample_lines` lines of at most /// `compiler.max_line_len` bytes, each with its newline. A fixed byte window /// would be spent by a handful of legal 4096-byte comment lines and the format /// would then be decided by almost no data. const sample_buf_len: usize = parsers.sample_lines * (compiler.max_line_len + 1); /// `` is at most 20 characters and the longest suffix is `.allow.tmp`. const name_buf_len: usize = 48; /// How one blocklist source is named in a log line: by its row id and its name, /// which are its own identity, and by its redacted url, which says where it /// points and nothing more. /// /// The url used to carry the identity here on its own. It cannot: `safe_url` /// drops the path, because a path segment is a place an operator's token lives, /// and two sources on one host are told apart by exactly that path. The id and /// the name are on the row every one of these lines already holds, they are /// what the API and the web UI show, and neither can leak what the url holds. /// The name is escaped for the same reason the url is — both are database text /// and a newline in either would forge a log line. It carries its own quotes, /// out of `safe_url.quoteText`, because a quote this format string added would /// be a quote the name could close: `ads' (https://decoy.example) --` would then /// read as a source pointing somewhere it does not. const SourceLabel = struct { id: i64, name: []const u8, url: []const u8, fn of(row: sources_repo.SourceRow) SourceLabel { return .{ .id = row.id, .name = row.name, .url = row.url }; } pub fn format(self: SourceLabel, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.print("source {d} {f} {f}", .{ self.id, safe_url.quoteText(self.name), safe_url.redactQuoted(self.url), }); } }; pub const Paths = struct { /// ``, owned by the caller and left open for the manager's life. dir: std.Io.Dir, subdir: []const u8 = "blocklists", }; /// Where one source stands. `.never_fetched` is the state of a source that has /// no compiled files and no stored checksum, which is a fresh install rather /// than a failure. `.no_valid_entries` is a download that compiled cleanly and /// yielded nothing usable — an error page or a compressed body, not a /// blocklist. pub const State = enum { ok, never_fetched, fetch_failed, compile_failed, no_valid_entries, load_failed, /// Whether this state was recorded by a refresh rather than by a load. /// A load outcome never overwrites one: the files a reload just read are /// exactly the files the failed refresh could not replace, and the operator /// still has to see why the update did not land. `SourceStatus.loaded` /// carries the other half — whether the source is filtering at all. pub fn isRefreshFailure(self: State) bool { return switch (self) { .fetch_failed, .compile_failed, .no_valid_entries => true, .ok, .never_fetched, .load_failed => false, }; } }; /// The `blocklist.storage` operations, each its own episode subject. /// /// A fixed set on purpose: several of these fail once per file in a pass, and /// one slot per operation is what turns that into one report per pass instead /// of an unbounded list of them. pub const StorageOp = enum { sweep, directory_read, create_dir, open_dir, delete }; /// One operation's outcome across one pass. `detail` keeps the last failure, /// and `failures` says how many that pass held — the row's `occurrences` counts /// failing passes, so the count belongs in the text. const Aggregate = struct { failures: u32 = 0, succeeded: bool = false, detail: [events.Store.max_detail_len]u8 = @splat(0), detail_len: u16 = 0, fn detailText(self: *const Aggregate) []const u8 { return self.detail[0..self.detail_len]; } }; /// What a locked body observed, held until `Manager.flushDiagnostics` can /// report it with no manager lock held. /// /// Two of this file's operations cannot report from where they stand: /// `publishRefresh` runs under `writer_lock` by contract and `pruneOrphans` /// holds both writer mutexes through its filesystem work. Collect-then-flush is /// what keeps their outcomes without holding a lock across a store call, and /// nothing here can grow: the storage slots are an enum array and a refresh /// outcome rides the status entry the source already has. /// /// The flush still happens inside the lock that serializes passes — a pass /// drains its own outcomes before it releases `refresh_lock` (or, for a /// standalone `reload`, `writer_lock`). What collect-then-flush avoids is /// holding a *manager* lock across a store call, not deferring the report until /// the next pass could merge into it. const Pending = struct { mutex: std.Io.Mutex = .init, storage: std.EnumArray(StorageOp, Aggregate) = .initFill(.{}), /// Null until a pass observes a snapshot outcome at all. snapshot_failed: ?bool = null, snapshot_detail: [events.Store.max_detail_len]u8 = @splat(0), snapshot_detail_len: u16 = 0, }; /// A status is a value with no borrowed memory, so a copy handed to the API /// outlives every reload. The url is held inline for that reason. pub const SourceStatus = struct { id: i64, state: State = .never_fetched, /// Whether this source's compiled files were read into the most recent /// snapshot build — that is, whether it is filtering right now. `state` /// describes the most recent attempt to *produce* those files, which is a /// different fact: a source whose refresh failed keeps serving what the /// refresh did not replace, and reads `.fetch_failed` with `loaded` set. loaded: bool = false, last_attempt: i64 = 0, last_success: i64 = 0, counts: compiler.Counts = .{}, /// A display copy of the source url, truncated at `max_url_len`. The whole /// url is in the `blocklist_sources` row this status shares an `id` with. url: [max_url_len]u8 = @splat(0), url_len: u8 = 0, last_error: [max_error_len]u8 = @splat(0), last_error_len: u8 = 0, /// The diagnostics identity of this source, canonicalized from the WHOLE /// url by `setUrl`. `url` above is a display copy truncated at /// `max_url_len`, and two urls sharing a 255-byte prefix would share one /// episode if that copy were the key. event_key: [events.Store.max_subject_key_len]u8 = @splat(0), event_key_len: u16 = 0, /// Diagnostics accounting for the pass in progress, cleared by every /// `flushDiagnostics`. `pass_outcome` says this source recorded one at all; /// `pass_failures` counts the failing ones, which a pass can hold more than /// one of (a refresh that failed, then the reload that could not load the /// files it did not write). One flush reports one `blocklist.refresh` /// occurrence per source, so `occurrences` counts failing passes rather /// than flushes, and the detail carries how many failures the pass held. /// /// These two fields live in exactly one copy of the status table at a time, /// which is what makes that count right while reloads replace the table /// underneath: a candidate built by `mergeStatuses` carries none of them, /// `installStatuses` folds the live table's in as it swaps, and the flush /// claims an entry by copying it and zeroing both fields in one locked /// step. Copy them anywhere else and the outcome gets reported twice. pass_outcome: bool = false, pass_failures: u16 = 0, pub fn eventKey(self: *const SourceStatus) []const u8 { return self.event_key[0..self.event_key_len]; } pub fn errorText(self: *const SourceStatus) []const u8 { return self.last_error[0..self.last_error_len]; } pub fn urlText(self: *const SourceStatus) []const u8 { return self.url[0..self.url_len]; } fn setUrl(self: *SourceStatus, url: []const u8) void { const kept = @min(url.len, max_url_len); @memcpy(self.url[0..kept], url[0..kept]); @memset(self.url[kept..], 0); self.url_len = @intCast(kept); self.event_key_len = @intCast(events.canonicalKey(url, &self.event_key).len); } fn fail(self: *SourceStatus, state: State, text: []const u8) void { self.pass_failures +|= 1; self.pass_outcome = true; self.state = state; const kept = @min(text.len, max_error_len); @memcpy(self.last_error[0..kept], text[0..kept]); @memset(self.last_error[kept..], 0); self.last_error_len = @intCast(kept); } fn succeed(self: *SourceStatus, at: i64, counts: compiler.Counts) void { self.pass_outcome = true; self.state = .ok; self.counts = counts; self.last_success = at; self.last_error = @splat(0); self.last_error_len = 0; } }; /// The header every compiled file carries, ahead of the body. The `sha256` /// covers the `.list` body, then the `.wild` body, then the `.allow` body, and /// **not** the header, so it stays stable across a refetch of unchanged content /// while `fetched_at` moves. /// /// Each body is followed by a separator byte, so the digest identifies which /// body a name sits in rather than only which names were written. pub const Header = struct { url: []const u8, format: parsers.Format, fetched_at: i64, counts: compiler.Counts, /// 64 lowercase hex characters. checksum: []const u8, pub fn write(self: Header, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.writeAll("# nxdns blocklist\n"); try w.print("# url {s}\n", .{self.url}); try w.print("# format {s}\n", .{@tagName(self.format)}); try w.print("# fetched_at {d}\n", .{self.fetched_at}); try w.print("# domains {d}\n", .{self.counts.domains}); try w.print("# wildcards {d}\n", .{self.counts.wildcards}); try w.print("# exceptions {d}\n", .{self.counts.exceptions}); try w.print("# skipped_regex {d}\n", .{self.counts.skipped_regex}); try w.print("# skipped_unsupported {d}\n", .{self.counts.skipped_unsupported}); try w.print("# invalid {d}\n", .{self.counts.invalid}); try w.print("# sha256 {s}\n", .{self.checksum}); } }; /// The body of a compiled file: everything after the leading `#` lines. A file /// with no header is all body, which is what makes a hand-written fixture a /// legal compiled file. pub fn stripHeader(bytes: []const u8) []const u8 { var rest = bytes; while (rest.len != 0 and rest[0] == '#') { const newline = std.mem.indexOfScalar(u8, rest, '\n') orelse return rest[rest.len..]; rest = rest[newline + 1 ..]; } return rest; } /// The scheduler's two time operations, behind a seam. Validated intervals are /// at least an hour, so a test that used the real clock would either sleep an /// hour or prove nothing; a test installs its own step clock instead. pub const ScheduleClock = struct { ctx: ?*anyopaque = null, /// Seconds on a monotonic clock. Only differences matter. nowFn: *const fn (ctx: ?*anyopaque, io: std.Io) i64, /// Returns when `deadline_s` arrives or `event` is set, whichever comes /// first; a null deadline waits for the event alone. A spurious early /// return is allowed — the caller rechecks both the version and the clock. waitFn: *const fn ( ctx: ?*anyopaque, io: std.Io, event: *std.Io.Event, deadline_s: ?i64, ) std.Io.Cancelable!void, pub const real: ScheduleClock = .{ .nowFn = realNow, .waitFn = realWait }; /// Test seam: the loop only ever exits on shutdown, so a test that wants /// `runScheduler` to run its startup pass and return installs this and /// gets `error.Canceled` at the first park. pub const shutdown_at_first_park: ScheduleClock = .{ .nowFn = realNow, .waitFn = cancelWait }; fn cancelWait(_: ?*anyopaque, _: std.Io, _: *std.Io.Event, _: ?i64) std.Io.Cancelable!void { return error.Canceled; } /// `boot` rather than `awake`: a box that suspends overnight should still /// see its daily interval elapse. fn realNow(_: ?*anyopaque, io: std.Io) i64 { return std.Io.Clock.boot.now(io).toSeconds(); } fn realWait( _: ?*anyopaque, io: std.Io, event: *std.Io.Event, deadline_s: ?i64, ) std.Io.Cancelable!void { const timeout: std.Io.Timeout = if (deadline_s) |seconds| .{ .deadline = .{ .raw = .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s }, .clock = .boot, } } else .none; event.waitTimeout(io, timeout) catch |err| switch (err) { error.Timeout => {}, error.Canceled => return error.Canceled, }; } }; pub const Manager = struct { gpa: Allocator, database: *db.Db, paths: Paths, fetcher: *fetcher.Fetcher, /// Read and written only under `schedule_mutex`; `setSchedule` replaces it /// while the scheduler is parked. update: model.BlocklistUpdate, /// Guards `update`, `schedule_version` and `schedule_anchor_s`. /// /// Lock ordering: innermost. `needsRefresh` takes it while `refresh_lock` /// is held, and nothing that holds it takes another manager lock. schedule_mutex: std.Io.Mutex, /// Bumped by every `setSchedule`. The scheduler reads it before it parks /// and again after it wakes: a change that lands in that window is what the /// recheck catches, so no wake is lost and none is mistaken for a deadline. schedule_version: u64, /// When the last refresh pass that RAN completed, on `ScheduleClock`'s /// clock. Success, failure and a disk-gate skip all advance it — the /// scheduled slot is spent either way and is not retried early. Null until /// the startup pass finishes. schedule_anchor_s: ?i64, /// Sticky once set, so `setSchedule` can never signal into a gap. The loop /// resets it under `schedule_mutex` before it recomputes its deadline. schedule_event: std.Io.Event, schedule_clock: ScheduleClock, /// Bounds one download. `std.http.Client` has no per-request deadline, so /// the fetch runs under `io.concurrent` against a sleep of this length. total_budget: std.Io.Clock.Duration, lock: std.Io.RwLock, /// 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, /// Owns the `statuses` table. The entries themselves borrow nothing. status_arena: std.heap.ArenaAllocator, /// The §11.6 disk gate (ruling 17). Set by the composition root after /// `init` and before `runScheduler` starts; null disables gating, which is /// what every test and `nxdns check` want. Only the scheduler consults it — /// see `refreshGated`. monitor: ?*disk_monitor.Monitor = null, /// The diagnostics store, wired the same way as `monitor` and null /// everywhere else. Never touched while a manager lock is held: see /// `flushDiagnostics`. diagnostics: ?*events.Store = null, /// What the locked bodies observed and could not report from where they /// stood. Bounded by construction — one slot per storage operation, one /// snapshot outcome — and drained by `flushDiagnostics`. pending: Pending = .{}, /// Scheduled refresh passes skipped by the disk gate. The `/api/health` /// rollup reads it through `refreshesGated`. refreshes_gated: std.atomic.Value(u64) = .init(0), pub const Error = error{ OutOfMemory, Canceled, /// A filesystem operation on the blocklist directory failed. The /// concrete cause is logged at `warn` where it happens: this taxonomy /// would otherwise carry two dozen members no caller can act on /// differently. FileSystem, /// The `groups` table changed between listing the groups and reading /// their ids. Retrying the reload is the answer, and the caller is the /// only one that can decide to. GroupSetChanged, } || db.Error || matcher.Snapshot.Error; /// The result is not copyable afterwards: `status_arena` and `lock` are /// addressed through `self`. pub fn init( gpa: Allocator, database: *db.Db, paths: Paths, fetcher_ptr: *fetcher.Fetcher, update: model.BlocklistUpdate, total_budget: std.Io.Clock.Duration, ) Error!Manager { return .{ .gpa = gpa, .database = database, .paths = paths, .fetcher = fetcher_ptr, .update = update, .schedule_mutex = .init, .schedule_version = 0, .schedule_anchor_s = null, .schedule_event = .unset, .schedule_clock = .real, .total_budget = total_budget, .lock = .init, .writer_lock = .init, .refresh_lock = .init, .current = null, .generation = 0, .statuses = &.{}, .status_arena = .init(gpa), }; } pub fn deinit(self: *Manager, io: std.Io) void { self.lock.lockUncancelable(io); const old = self.current; self.current = null; self.statuses = &.{}; self.lock.unlock(io); if (old) |snapshot| destroySnapshot(self.gpa, snapshot); self.status_arena.deinit(); self.* = undefined; } /// Reader side of the swap. The handle holds a shared lock: release it, and /// do not retain `snapshot` afterwards. pub const Handle = struct { snapshot: *const matcher.Snapshot, manager: *Manager, pub fn release(self: Handle, io: std.Io) void { self.manager.lock.unlockShared(io); } }; /// `null` before the first successful `reload`. The caller answers /// SERVFAIL, or forwards unfiltered, on its own policy — this file does not /// decide that. pub fn acquire(self: *Manager, io: std.Io) ?Handle { self.lock.lockSharedUncancelable(io); const snapshot = self.current orelse { self.lock.unlockShared(io); return null; }; return .{ .snapshot = snapshot, .manager = self }; } /// Copies the status table for the API and for `nxdns check`. Returns the /// number of entries written, which is `min(out.len, source count)`. /// /// The copies are self-contained: `SourceStatus` holds its url and its /// error text inline, so the caller may keep them for as long as it likes /// and a concurrent reload cannot pull memory out from under them. pub fn statusSnapshot(self: *Manager, io: std.Io, out: []SourceStatus) usize { self.lock.lockSharedUncancelable(io); defer self.lock.unlockShared(io); const kept = @min(out.len, self.statuses.len); @memcpy(out[0..kept], self.statuses[0..kept]); return kept; } // ----------------------------------------------------------------------- // diagnostics // ----------------------------------------------------------------------- /// Records one storage operation's failure. Callable from anywhere, /// including under both writer mutexes: it touches `pending` only. fn noteStorageFailure( self: *Manager, io: std.Io, op: StorageOp, comptime fmt: []const u8, args: anytype, ) void { if (self.diagnostics == null) return; self.pending.mutex.lockUncancelable(io); defer self.pending.mutex.unlock(io); const slot = self.pending.storage.getPtr(op); slot.failures +|= 1; var w: std.Io.Writer = .fixed(&slot.detail); w.print(fmt, args) catch {}; slot.detail_len = @intCast(w.end); } fn noteStorageSuccess(self: *Manager, io: std.Io, op: StorageOp) void { if (self.diagnostics == null) return; self.pending.mutex.lockUncancelable(io); defer self.pending.mutex.unlock(io); self.pending.storage.getPtr(op).succeeded = true; } /// Records whether a snapshot was published. `reason` null is the post-swap /// success; anything else is the pass that could not publish one. fn noteSnapshot(self: *Manager, io: std.Io, reason: ?[]const u8) void { if (self.diagnostics == null) return; self.pending.mutex.lockUncancelable(io); defer self.pending.mutex.unlock(io); self.pending.snapshot_failed = reason != null; const text = reason orelse ""; const kept = @min(text.len, self.pending.snapshot_detail.len); @memcpy(self.pending.snapshot_detail[0..kept], text[0..kept]); self.pending.snapshot_detail_len = @intCast(kept); } /// Drains `pending` and the status table into the store, holding no manager /// lock across a store call. /// /// Called by every pass that can fill either one, and *before that pass /// releases the lock serializing it* — `refresh_lock` for a refresh pass, /// `writer_lock` for a standalone `reload`. Draining after the release /// would let the next pass record its own outcomes on the same entries /// first, and two failing passes would reach the store as one occurrence. /// The `defer` that calls this is registered after the unlock `defer` for /// that reason; defers run last-registered-first. /// /// It is idempotent: a drained collector reports nothing. pub fn flushDiagnostics(self: *Manager, io: std.Io) void { const store = self.diagnostics orelse return; const now_s = std.Io.Clock.real.now(io).toSeconds(); var storage: std.EnumArray(StorageOp, Aggregate) = undefined; var snapshot_failed: ?bool = null; var snapshot_detail: [events.Store.max_detail_len]u8 = undefined; var snapshot_detail_len: u16 = 0; { self.pending.mutex.lockUncancelable(io); defer self.pending.mutex.unlock(io); storage = self.pending.storage; snapshot_failed = self.pending.snapshot_failed; snapshot_detail = self.pending.snapshot_detail; snapshot_detail_len = self.pending.snapshot_detail_len; self.pending.storage = .initFill(.{}); self.pending.snapshot_failed = null; self.pending.snapshot_detail_len = 0; } var it = storage.iterator(); while (it.next()) |kv| { const op = @tagName(kv.key); if (kv.value.failures != 0) { var buf: [events.Store.max_detail_len]u8 = undefined; const detail = std.fmt.bufPrint(&buf, "{s} ({d} this pass)", .{ kv.value.detailText(), kv.value.failures, }) catch buf[0..]; store.report(io, now_s, .blocklist_storage, op, op, .warning, detail); } else if (kv.value.succeeded) { store.resolve(io, now_s, .blocklist_storage, op); } } if (snapshot_failed) |failed| { if (failed) { store.report( io, now_s, .blocklist_snapshot, snapshot_key, "blocklist snapshot", .@"error", snapshot_detail[0..snapshot_detail_len], ); } else { store.resolve(io, now_s, .blocklist_snapshot, snapshot_key); } } self.flushSourceDiagnostics(io, store, now_s); } /// One `blocklist.refresh` episode per source, from the status table. /// /// The table IS the per-source collection the collect-then-flush rule asks /// for: `prepareRefresh`, `publishRefresh` and the reload's load outcomes /// all write their result into the entry, under locks this cannot take. So /// one entry is copied out at a time under the exclusive lock and the store /// is called with nothing held. /// /// The walk is a drain, not an index scan: a reload can replace the whole /// table between two iterations, and an index into the table it replaced /// would skip or repeat entries. Each round takes the lock, claims the /// first entry that still carries pass accounting by copying it out and /// zeroing the two fields, and reports it with nothing held. Claiming and /// clearing are one locked step, so an outcome is reported once: a table /// swapped in mid-drain carries the entries this flush has not claimed yet, /// and `installStatuses` folded them in for exactly that reason. The drain /// ends when a scan finds nothing left to claim. /// /// The drain reaches only the sources the table still holds, so the sweep /// below is what closes the episode of one that is gone. fn flushSourceDiagnostics(self: *Manager, io: std.Io, store: *events.Store, now_s: i64) void { drain: while (true) { var status: SourceStatus = undefined; { self.lock.lockUncancelable(io); defer self.lock.unlock(io); const claimed = for (self.statuses) |*entry| { if (!entry.pass_outcome) continue; status = entry.*; entry.pass_outcome = false; entry.pass_failures = 0; // A source with no diagnostics identity has nothing to // report under, but its accounting is cleared all the same: // left set, it would make every later scan claim it and the // drain would never end. if (entry.event_key_len == 0) continue; break true; } else false; if (!claimed) break :drain; } if (!status.state.isRefreshFailure() and status.state != .load_failed) { store.resolve(io, now_s, .blocklist_refresh, status.eventKey()); continue; } var buf: [events.Store.max_detail_len]u8 = undefined; const detail = std.fmt.bufPrint(&buf, "{t}: {s} ({d} this pass)", .{ status.state, status.errorText(), status.pass_failures, }) catch buf[0..]; var label_buf: [events.Store.max_subject_label_len]u8 = undefined; const label = std.fmt.bufPrint(&label_buf, "{f}", .{ safe_url.redact(status.urlText()), }) catch &label_buf; store.report(io, now_s, .blocklist_refresh, status.eventKey(), label, .warning, detail); } self.resolveDeletedSources(io, store, now_s); } /// Closes the `blocklist.refresh` episode of a source that no longer exists. /// /// Nothing else can. An episode of this code is closed by its source /// succeeding, and a source deleted through the API or dropped by a config /// import never succeeds again: the drain above walks the status table, the /// deleted source has no entry in it, and the resolved-row pruning never /// touches an active row. Without this the operator keeps a warning about a /// list they removed on purpose, and no restart clears it. /// /// The status table holds every source at every flush site — `refreshAll` /// syncs it before it refreshes anything and a reload rebuilds it from the /// rows — so its keys are exactly the episodes that may stay open. fn resolveDeletedSources(self: *Manager, io: std.Io, store: *events.Store, now_s: i64) void { var storage: [events.Store.max_kept_keys][events.Store.max_subject_key_len]u8 = undefined; var lens: [events.Store.max_kept_keys]u16 = undefined; var len: usize = 0; { // Shared: this reads the table and changes nothing in it. The keys // are copied out because the arena they live in is freed by the // next `installStatuses`, and the store is called below with // nothing held. self.lock.lockSharedUncancelable(io); defer self.lock.unlockShared(io); // An empty table before the first published snapshot means "no // source set has been read yet", not "every source was deleted". // Sweeping on it would close every episode the last run left open, // and the pass that follows would reopen each one as a new episode // with its history reset. if (self.generation == 0) return; for (self.statuses) |*entry| { if (entry.event_key_len == 0) continue; // `resolveExcept` refuses a kept list longer than // `max_kept_keys`, because it canonicalizes onto the stack. // Over that many keyed sources the sweep is skipped whole: the // alternative is a truncated kept list, which would close // episodes that are still true. A source deleted while the // household is over the cap keeps its episode until the count // falls back under it. if (len == storage.len) return; const key = entry.eventKey(); @memcpy(storage[len][0..key.len], key); lens[len] = entry.event_key_len; len += 1; } } var kept: [events.Store.max_kept_keys][]const u8 = undefined; for (0..len) |i| kept[i] = storage[i][0..lens[i]]; store.resolveExcept(io, now_s, .blocklist_refresh, kept[0..len]); } // ----------------------------------------------------------------------- // reload // ----------------------------------------------------------------------- /// Reads the database and every compiled file, builds a snapshot and swaps /// it in. /// /// A source whose compiled files are missing, unreadable or checksum /// mismatched is marked `.load_failed` and left out of the snapshot rather /// than failing the whole reload: one bad file must not cost the operator /// every other list. `runScheduler` refreshes exactly those sources, so the /// state is recorded, surfaced and repaired, never silently accepted. /// /// A body that is present and checksum-clean but malformed fails the build /// (`error.NotSorted`), and the previously published snapshot keeps /// serving: nothing is swapped until the new snapshot exists. The status /// table keeps describing that snapshot too — the table is rebuilt off to /// the side and the load findings are written into it there, so a reload /// that never publishes changes neither. /// /// A standalone reload is its own pass, and `writer_lock` is what serializes /// it against every other writer of the status table. So it flushes inside /// that lock: the load outcomes it wrote at the swap are drained before any /// other pass can add its own to the same entries, which is what keeps two /// failing passes two occurrences instead of one. pub fn reload(self: *Manager, io: std.Io) Error!void { // Must not be entered with `writer_lock` held. self.writer_lock.lockUncancelable(io); defer self.writer_lock.unlock(io); // Registered after the unlock so it runs before it, and `defer` and not // straight-line code after the call: a reload that fails has already // collected the outcomes that explain why, and leaving them pending // would hold them until some later pass flushed them under the wrong // timestamp. defer self.flushDiagnostics(io); return self.reloadLocked(io); } /// `reload` without the flush, for a caller that is inside a pass with a /// flush of its own. One pass flushes once: flushing here as well would /// split the pass's outcomes across two reports. fn reloadCollecting(self: *Manager, io: std.Io) Error!void { // Must not be entered with `writer_lock` held. self.writer_lock.lockUncancelable(io); defer self.writer_lock.unlock(io); try self.reloadLocked(io); } fn reloadLocked(self: *Manager, io: std.Io) Error!void { var rows = try sources_repo.listSourceRows(self.database, self.gpa); defer rows.deinit(self.gpa); defer sources_repo.freeSourceRows(self.gpa, rows.items); // The table this reload will publish, built where no reader can see it. // It is installed in the swap below or freed unpublished, so a reload // that fails leaves the previous table describing the previous // snapshot — including the entry of a source deleted from the database, // which that snapshot still enforces. var candidate: ?StatusTable = try self.buildStatusTable(io, rows.items); errdefer if (candidate) |*table| table.deinit(); var dir = try self.openDir(io, .{}); defer dir.close(io); const sources = try self.gpa.alloc(model.BlocklistSource, rows.items.len); defer self.gpa.free(sources); const source_ids = try self.gpa.alloc(i64, rows.items.len); defer self.gpa.free(source_ids); const compiled = try self.gpa.alloc(?matcher.Snapshot.Compiled, rows.items.len); defer self.gpa.free(compiled); // The file contents outlive the header stripping and are freed once the // snapshot has copied what it needs into its own arena. var bodies: std.ArrayList([]u8) = .empty; defer { for (bodies.items) |body| self.gpa.free(body); bodies.deinit(self.gpa); } // What this reload found, per source. It is applied to the status table // only if the snapshot it describes is published: everything below here // can still fail, and a status table describing a snapshot nobody // serves is worse than one describing the previous one. const outcomes = try self.gpa.alloc(LoadOutcome, rows.items.len); defer self.gpa.free(outcomes); var loaded: usize = 0; for (rows.items, sources, source_ids, compiled, outcomes) |row, *source, *source_id, *slot, *outcome| { source_id.* = row.id; // `is_suggested` is a UI hint the snapshot never reads. source.* = .{ .url = row.url, .name = row.name, .enabled = row.enabled }; outcome.* = if (row.enabled) try self.loadSource(io, dir, row, &bodies) else .disabled; switch (outcome.*) { .loaded => |body| { slot.* = body; loaded += 1; }, // Not loadable and therefore not enforced. Saying so here is // what keeps `Snapshot.build`'s `MissingCompiledSource` for the // case it is meant for: a caller that forgot to read a body. .disabled, .failed => { slot.* = null; source.enabled = false; }, } } var groups = try groups_repo.listGroups(self.database, self.gpa); defer groups.deinit(self.gpa); defer groups_repo.freeGroups(self.gpa, groups.items); const group_ids = try self.groupIds(groups.items); defer self.gpa.free(group_ids); var group_sources = try groups_repo.listGroupSources(self.database, self.gpa); defer group_sources.deinit(self.gpa); defer groups_repo.freeGroupSources(self.gpa, group_sources.items); var rule_rows = try rules_repo.listRules(self.database, self.gpa); defer rule_rows.deinit(self.gpa); defer rules_repo.freeRules(self.gpa, rule_rows.items); var clients = try clients_repo.listClients(self.database, self.gpa); defer clients.deinit(self.gpa); defer clients_repo.freeClients(self.gpa, clients.items); var prefixes = try clients_repo.listClientPrefixes(self.database, self.gpa); defer prefixes.deinit(self.gpa); defer clients_repo.freeClientPrefixes(self.gpa, prefixes.items); // Query names are attacker-supplied, so a fixed seed would make // probe-chain flooding computable offline. var seed_bytes: [8]u8 = undefined; io.random(&seed_bytes); const generation = self.generation + 1; const snapshot = try self.gpa.create(matcher.Snapshot); errdefer self.gpa.destroy(snapshot); snapshot.* = try matcher.Snapshot.build(self.gpa, .{ .groups = groups.items, .group_ids = group_ids, .group_sources = group_sources.items, .sources = sources, .source_ids = source_ids, .rules = rule_rows.items, .clients = clients.items, .prefixes = prefixes.items, .compiled = compiled, .seed = std.mem.readInt(u64, &seed_bytes, .little), .generation = generation, }); // Read before the swap: once `current` points at it, this snapshot // belongs to the readers and to whichever writer replaces it next. const memory_bytes = snapshot.memoryBytes(); // The snapshot, the status table and the load facts land together, so a // reader never sees a status table describing anything but the // published snapshot. self.lock.lockUncancelable(io); const old = self.current; self.current = snapshot; self.generation = generation; applyLoadOutcomes(candidate.?.items, rows.items, outcomes); self.installStatuses(candidate.?); candidate = null; self.lock.unlock(io); // After `unlock`: no reader can still hold the old snapshot here, and // `writer_lock` keeps every other writer out of this sequence. if (old) |previous| destroySnapshot(self.gpa, previous); log.info("blocklist snapshot generation {d}: {d} of {d} sources loaded, {d} bytes", .{ generation, loaded, rows.items.len, memory_bytes, }); self.noteSnapshot(io, null); } /// What one enabled source contributes to the snapshot being built. Nothing /// here touches the status table: the outcome is data until the swap /// commits it. fn loadSource( self: *Manager, io: std.Io, dir: std.Io.Dir, row: sources_repo.SourceRow, bodies: *std.ArrayList([]u8), ) Error!LoadOutcome { const stored = row.checksum orelse // No stored checksum means no successful compile has ever // happened. A fresh install is here on every source. return .{ .failed = .{ .state = .never_fetched, .text = "" } }; var list_buf: [name_buf_len]u8 = undefined; var wild_buf: [name_buf_len]u8 = undefined; var allow_buf: [name_buf_len]u8 = undefined; const list_name = compiledName(&list_buf, row.id, ".list"); const wild_name = compiledName(&wild_buf, row.id, ".wild"); const allow_name = compiledName(&allow_buf, row.id, ".allow"); // Reserved before the reads, so no buffer can be orphaned by a failing // append: `bodies` owns each one from the moment it is read. try bodies.ensureUnusedCapacity(self.gpa, 3); // `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); // A missing `.allow` file is an empty allow body, not a failure. The // digest still covers three bodies, the third of them empty, so a // source whose list carries no `@@` line matches whether its empty // `.allow` file survived or not. const allow_bytes: []const u8 = blk: { const read = dir.readFileAlloc(io, allow_name, self.gpa, .limited(max_compiled_bytes)) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; if (err == error.Canceled) return error.Canceled; if (err == error.FileNotFound) break :blk ""; return loadFailure(row, allow_name, err); }; bodies.appendAssumeCapacity(read); break :blk read; }; const list_body = stripHeader(list_bytes); const wild_body = stripHeader(wild_bytes); const allow_body = stripHeader(allow_bytes); // The checksum covers the three bodies together, so a crash between the // `replace` calls — a new `.list` beside an old `.wild` — is caught // here and refreshed, not served as a half-updated list. if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body, allow_body))) { log.warn("blocklist {f}: compiled files do not match the stored checksum", .{SourceLabel.of(row)}); return .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } }; } return .{ .loaded = .{ .list_body = list_body, .wild_body = wild_body, .allow_body = allow_body, } }; } // ----------------------------------------------------------------------- // refresh // ----------------------------------------------------------------------- /// Downloads, compiles and atomically replaces the compiled files of one /// source, then writes its runtime columns. /// /// Returns `true` only when the compiled files were replaced. Unchanged /// content (equal checksum) and every recorded failure return `false`; the /// reason for a failure is in the status table, not in the return value. /// /// The status entry is found by row id, so a source added since the last /// `reload` has nowhere to record its outcome. `refreshAll` syncs the table /// before it refreshes anything, which is why `POST /api/blocklists/update` /// and the scheduler both enter through `refreshAll`. pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool { self.refresh_lock.lockUncancelable(io); defer self.refresh_lock.unlock(io); // Registered after the unlock, so it runs before it: a pass drains its // own outcomes while it still holds `refresh_lock`. `defer` at all, so // a refresh that fails outright still reports what it collected instead // of leaving it for an unrelated later flush. defer self.flushDiagnostics(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 // status would erase `last_success` and the counters of a list that is // still being enforced. var status: SourceStatus = self.priorStatus(io, row.id) orelse .{ .id = row.id }; status.setUrl(row.url); status.last_attempt = std.Io.Clock.real.now(io).toSeconds(); var dir = try self.openDir(io, .{}); defer dir.close(io); var raw_buf: [name_buf_len]u8 = undefined; var list_tmp_buf: [name_buf_len]u8 = undefined; var wild_tmp_buf: [name_buf_len]u8 = undefined; var allow_tmp_buf: [name_buf_len]u8 = undefined; const raw_name = compiledName(&raw_buf, row.id, ".raw.tmp"); const tmp: TempNames = .{ .list = compiledName(&list_tmp_buf, row.id, ".list.tmp"), .wild = compiledName(&wild_tmp_buf, row.id, ".wild.tmp"), .allow = compiledName(&allow_tmp_buf, row.id, ".allow.tmp"), }; // Installed before the calls that create these files, not after: an // `error.Canceled` or `error.OutOfMemory` returned straight out of // `download` or `compileTo` would outrun a later `defer` and leave a // temporary behind. Deleting a name that was never created is a no-op. defer self.deleteQuietly(io, dir, raw_name); defer self.deleteQuietly(io, dir, tmp.list); defer self.deleteQuietly(io, dir, tmp.wild); defer self.deleteQuietly(io, dir, tmp.allow); // 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, 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, 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); // Inside `refresh_lock`, by being registered after the unlock: see // `refreshSource`. defer self.flushDiagnostics(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. The collecting // variant, because the `defer` above is this pass's one flush. return self.reloadCollecting(io); } /// The three temporary files one refresh compiles into, before the header /// is prepended and each is renamed over the file it replaces. const TempNames = struct { list: []const u8, wild: []const u8, allow: []const u8, }; /// 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, tmp: TempNames, ) 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 .failed; }, }; const format = self.detectFormat(io, dir, raw_name) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return error.Canceled, else => { self.reportCompileFailure(row, status, err); return .failed; }, }; const result = self.compileTo(io, dir, raw_name, format, tmp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return error.Canceled, else => { self.reportCompileFailure(row, status, err); return .failed; }, }; if (rejectedWithoutEntries(result.counts)) { self.reportEmptyCompile(row, status, result.counts); 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, tmp: TempNames, ) 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 // snapshot for nothing. The files on disk must actually carry that // content: if a reload found them corrupt and excluded the source, a // re-download of unchanged upstream bytes is the one chance to repair // them, and skipping the rewrite here would leave filtering off for // good. if (row.checksum) |stored| { if (std.mem.eql(u8, stored, &compiled.result.checksum) and self.diskBodiesMatch(io, dir, row.id, stored)) { // Every count comes from the compile that just ran, not from // the row. The two skip counts have to: a skipped line lands in // no body, so a list that changed only its regex or // browser-syntax lines reaches here with a stale row. The three // written counts equal the row's anyway once the digest is // framed, so reading them from the compile costs nothing and // leaves no field whose freshness rests on an argument about // what the checksum covers. try sources_repo.updateSourceStats(self.database, row.id, .{ .last_updated = now, .domain_count = compiled.result.counts.domains, .wildcard_count = compiled.result.counts.wildcards, .exception_count = compiled.result.counts.exceptions, .skipped_regex_count = compiled.result.counts.skipped_regex, .skipped_unsupported_count = compiled.result.counts.skipped_unsupported, .checksum = stored, }); status.succeed(now, compiled.result.counts); return false; } } const header: Header = .{ .url = row.url, .format = compiled.format, .fetched_at = now, .counts = compiled.result.counts, .checksum = &compiled.result.checksum, }; self.publish(io, dir, row.id, header, tmp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return error.Canceled, else => { self.reportCompileFailure(row, status, err); return false; }, }; try sources_repo.updateSourceStats(self.database, row.id, .{ .last_updated = now, .domain_count = compiled.result.counts.domains, .wildcard_count = compiled.result.counts.wildcards, .exception_count = compiled.result.counts.exceptions, .skipped_regex_count = compiled.result.counts.skipped_regex, .skipped_unsupported_count = compiled.result.counts.skipped_unsupported, .checksum = &compiled.result.checksum, }); status.succeed(now, compiled.result.counts); return true; } /// The body goes to a temporary file, never to memory: `max_body_bytes` is /// 64 MiB and the memory budget has no room for it beside two snapshots. /// /// It takes the whole row rather than the url alone because its two log /// lines name the source by its id and name, which only the row carries. fn download( self: *Manager, io: std.Io, dir: std.Io.Dir, raw_name: []const u8, row: sources_repo.SourceRow, ) !void { const file = try dir.createFile(io, raw_name, .{ .permissions = .fromMode(0o600) }); defer file.close(io); const buffer = try self.gpa.alloc(u8, io_buf_len); defer self.gpa.free(buffer); var fw = file.writer(io, buffer); const result = self.fetchWithin(io, row.url, &fw.interface) catch |err| { // `fetcher.Error.Unexpected` is what a failing sink surfaces as; // the concrete cause is on this writer, which the fetcher does not // own. if (fw.err) |cause| return cause; if (err == error.HttpStatus) { if (self.fetcher.last_status) |status| { log.warn("blocklist {f}: http status {d}", .{ SourceLabel.of(row), @intFromEnum(status) }); } } return err; }; try fw.interface.flush(); // The compile reads this file back; the bytes must be there, not in a // buffer this function is about to drop. try file.sync(io); log.debug("blocklist {f}: downloaded {d} bytes", .{ SourceLabel.of(row), result.bytes_read }); } /// `std.http.Client` has no per-request deadline, so the whole exchange /// races a sleep and the loser is canceled — milestone 3's pattern. fn fetchWithin( self: *Manager, io: std.Io, url: []const u8, w: *std.Io.Writer, ) fetcher.Error!fetcher.Result { var outcomes: [2]Outcome = undefined; var race: std.Io.Select(Outcome) = .init(io, &outcomes); defer race.cancelDiscard(); race.concurrent(.fetch, fetcher.Fetcher.fetch, .{ self.fetcher, io, url, w }) catch |err| switch (err) { error.ConcurrencyUnavailable => return error.SystemResources, }; race.concurrent(.expiry, expire, .{ io, self.total_budget }) catch |err| switch (err) { error.ConcurrencyUnavailable => return error.SystemResources, }; switch (try race.await()) { .fetch => |result| return result, .expiry => |result| { // A canceled sleep means this task is being torn down, not that // the download is slow. try result; return error.Timeout; }, } } fn detectFormat( self: *Manager, io: std.Io, dir: std.Io.Dir, raw_name: []const u8, ) !parsers.Format { const file = try dir.openFile(io, raw_name, .{}); defer file.close(io); const buffers = try self.gpa.alloc(u8, io_buf_len + sample_buf_len); defer self.gpa.free(buffers); var fr = file.reader(io, buffers[0..io_buf_len]); var sample: std.Io.Writer = .fixed(buffers[io_buf_len..]); collectSample(&fr.interface, &sample) catch |err| switch (err) { error.ReadFailed => return fr.err orelse err, // `sample_buf_len` holds every line `collectSample` can emit, so a // full buffer means the sample is complete. error.WriteFailed => {}, }; return parsers.detectFormat(sample.buffered()); } /// Compiles into three plain temporary files. The compiled bodies cannot go /// straight into the final files: the header carries counts that only exist /// once the whole input has been compiled, and the loader requires the /// header first. fn compileTo( self: *Manager, io: std.Io, dir: std.Io.Dir, raw_name: []const u8, format: parsers.Format, tmp: TempNames, ) !compiler.Result { const raw = try dir.openFile(io, raw_name, .{}); defer raw.close(io); const list_file = try dir.createFile(io, tmp.list, .{ .permissions = .fromMode(0o600) }); defer list_file.close(io); const wild_file = try dir.createFile(io, tmp.wild, .{ .permissions = .fromMode(0o600) }); defer wild_file.close(io); const allow_file = try dir.createFile(io, tmp.allow, .{ .permissions = .fromMode(0o600) }); defer allow_file.close(io); const buffers = try self.gpa.alloc(u8, 4 * io_buf_len); defer self.gpa.free(buffers); var fr = raw.reader(io, buffers[0..io_buf_len]); var list_w = list_file.writer(io, buffers[io_buf_len .. 2 * io_buf_len]); var wild_w = wild_file.writer(io, buffers[2 * io_buf_len .. 3 * io_buf_len]); var allow_w = allow_file.writer(io, buffers[3 * io_buf_len ..]); const result = compiler.compile( self.gpa, &fr.interface, format, &list_w.interface, &wild_w.interface, &allow_w.interface, ) catch |err| switch (err) { // `compiler.Error` names the direction; the concrete cause is on // the stream that failed. error.ReadFailed => return fr.err orelse err, error.WriteFailed => return list_w.err orelse (wild_w.err orelse (allow_w.err orelse err)), else => return err, }; try list_w.interface.flush(); try wild_w.interface.flush(); try allow_w.interface.flush(); try list_file.sync(io); try wild_file.sync(io); try allow_file.sync(io); return result; } /// Writes header + body into each final file through `createFileAtomic` + /// `replace`, so a crash mid-write can never leave a half-list that would /// load as a valid, shorter blocklist. fn publish( self: *Manager, io: std.Io, dir: std.Io.Dir, id: i64, header: Header, tmp: TempNames, ) !void { const buffers = try self.gpa.alloc(u8, 2 * io_buf_len); defer self.gpa.free(buffers); var list_buf: [name_buf_len]u8 = undefined; var wild_buf: [name_buf_len]u8 = undefined; var allow_buf: [name_buf_len]u8 = undefined; try publishOne(io, dir, compiledName(&list_buf, id, ".list"), tmp.list, header, buffers); try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), tmp.wild, header, buffers); try publishOne(io, dir, compiledName(&allow_buf, id, ".allow"), tmp.allow, header, buffers); } fn publishOne( io: std.Io, dir: std.Io.Dir, dest: []const u8, body_name: []const u8, header: Header, buffers: []u8, ) !void { const body = try dir.openFile(io, body_name, .{}); defer body.close(io); var af = try dir.createFileAtomic(io, dest, .{ .permissions = .fromMode(0o600), .replace = true, }); defer af.deinit(io); var fr = body.reader(io, buffers[0..io_buf_len]); var fw = af.file.writer(io, buffers[io_buf_len..]); header.write(&fw.interface) catch return fw.err orelse error.WriteFailed; _ = fr.interface.streamRemaining(&fw.interface) catch return fr.err orelse (fw.err orelse error.WriteFailed); fw.interface.flush() catch return fw.err orelse error.WriteFailed; // Before `replace`, which closes the file: the rename must publish // durable bytes, not an empty file with the content still in the page // cache. try af.file.sync(io); try af.replace(io); } /// Whether the compiled files on disk hash to `expected`. A missing, /// unreadable or corrupt file answers false, which sends the caller down /// the rewrite path — the only path that can repair it. /// /// A missing `.allow` file is the one exception, and it is the same one /// `loadSource` makes: it reads as an empty allow body, which is what a list /// with no `@@` line compiles to anyway. Answering false there would rewrite /// such a list on every refresh for no change in content. fn diskBodiesMatch(self: *Manager, io: std.Io, dir: std.Io.Dir, id: i64, expected: []const u8) bool { var list_buf: [name_buf_len]u8 = undefined; var wild_buf: [name_buf_len]u8 = undefined; var allow_buf: [name_buf_len]u8 = undefined; const limit: std.Io.Limit = .limited(max_compiled_bytes); const list_bytes = dir.readFileAlloc(io, compiledName(&list_buf, id, ".list"), self.gpa, limit) catch return false; defer self.gpa.free(list_bytes); const wild_bytes = dir.readFileAlloc(io, compiledName(&wild_buf, id, ".wild"), self.gpa, limit) catch return false; defer self.gpa.free(wild_bytes); const allow_bytes = dir.readFileAlloc(io, compiledName(&allow_buf, id, ".allow"), self.gpa, limit) catch |err| if (err == error.FileNotFound) @as([]u8, &.{}) else return false; defer self.gpa.free(allow_bytes); return compiledBodiesMatch(list_bytes, wild_bytes, allow_bytes, expected); } fn reportFetchFailure( self: *Manager, row: sources_repo.SourceRow, status: *SourceStatus, err: anyerror, ) void { _ = self; log.warn("blocklist {f}: download failed: {s}", .{ SourceLabel.of(row), @errorName(err) }); status.fail(.fetch_failed, @errorName(err)); } fn reportCompileFailure( self: *Manager, row: sources_repo.SourceRow, status: *SourceStatus, err: anyerror, ) void { _ = self; log.warn("blocklist {f}: compile failed: {s}", .{ SourceLabel.of(row), @errorName(err) }); status.fail(.compile_failed, @errorName(err)); } fn reportEmptyCompile( self: *Manager, row: sources_repo.SourceRow, status: *SourceStatus, counts: compiler.Counts, ) void { _ = self; var buf: [max_error_len]u8 = undefined; const text = std.fmt.bufPrint( &buf, "NoValidEntries invalid={d} unsupported={d} long_lines={d}", .{ counts.invalid, counts.skipped_unsupported, counts.long_lines }, ) catch "NoValidEntries"; log.warn("blocklist {f}: {s}", .{ SourceLabel.of(row), text }); status.fail(.no_valid_entries, text); } // ----------------------------------------------------------------------- // scheduling // ----------------------------------------------------------------------- /// Loads at startup, refreshes only what needs it, then sleeps /// `update.interval_hours` between full passes. Returns on /// `error.Canceled`. /// /// A cold restart must not re-download every list and a boot loop must not /// become a download loop, so the startup pass refreshes a source only when /// it has no usable compiled files or its `last_updated` is older than the /// interval. /// /// `update.enabled == false` parks after the startup pass; manual refresh /// through `refreshAll` still works, and a later `setSchedule` wakes the /// loop rather than needing a restart. pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void { // Ahead of the pass, not after it. This is the sweep that collects what // a killed process left behind: a `.raw.tmp` as large as the body the // dead refresh was writing, and the compiled files of a source deleted // while the server was down. Both are bytes the pass below is about to // ask the same filesystem for. try self.sweepOrphans(io); // `startupPass` flushes its own outcomes before it releases // `refresh_lock`, so the only flush left here is the one the failure // note below needs. self.startupPass(io) catch |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("blocklist startup pass failed: {s}", .{@errorName(err)}); self.noteSnapshot(io, @errorName(err)); self.flushDiagnostics(io); }, }; // The startup pass ran, so it anchors the schedule — including when // updates are disabled, so a later enable measures its first interval // from real work rather than from the moment the operator flipped the // switch. self.anchorNow(io); while (true) { // One hold: read the version, reset the sticky event, and take the // schedule the deadline is computed from. A `setSchedule` that // lands after this reset completes the wait below at once, and the // version recheck decides whether the wake meant anything. self.schedule_mutex.lockUncancelable(io); const version = self.schedule_version; self.schedule_event.reset(); const enabled = self.update.enabled; const interval_s = model.updateIntervalSeconds(self.update); const anchor = self.schedule_anchor_s; self.schedule_mutex.unlock(io); const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io); // Disabled parks on the event alone. The task still exits only on // shutdown, exactly as it did when it returned here. const deadline_s: ?i64 = if (enabled) (anchor orelse now_s) + interval_s else null; if (deadline_s == null or now_s < deadline_s.?) { try self.schedule_clock.waitFn(self.schedule_clock.ctx, io, &self.schedule_event, deadline_s); // Either the schedule changed under us or the wait was // spurious; recompute from the top rather than guess. if (self.scheduleVersion(io) != version) continue; if (deadline_s == null) continue; if (self.schedule_clock.nowFn(self.schedule_clock.ctx, io) < deadline_s.?) continue; } try self.scheduledPass(io); self.anchorNow(io); } } /// Installs a new blocklist-update schedule and wakes the scheduler. The /// anchor is untouched: the next refresh is due one NEW interval after the /// last pass that ran, which the loop refreshes immediately when that /// moment is already past. pub fn setSchedule(self: *Manager, io: std.Io, enabled: bool, interval_hours: u16) void { self.schedule_mutex.lockUncancelable(io); self.update = .{ .enabled = enabled, .interval_hours = interval_hours }; self.schedule_version += 1; self.schedule_mutex.unlock(io); self.schedule_event.set(io); } /// The live schedule. Every reader outside the scheduler loop goes through /// here, so none of them reads `update` while `setSchedule` writes it. pub fn schedule(self: *Manager, io: std.Io) model.BlocklistUpdate { self.schedule_mutex.lockUncancelable(io); defer self.schedule_mutex.unlock(io); return self.update; } fn scheduleVersion(self: *Manager, io: std.Io) u64 { self.schedule_mutex.lockUncancelable(io); defer self.schedule_mutex.unlock(io); return self.schedule_version; } fn anchorNow(self: *Manager, io: std.Io) void { const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io); self.schedule_mutex.lockUncancelable(io); self.schedule_anchor_s = now_s; self.schedule_mutex.unlock(io); } /// What one elapsed interval does. Split from the loop above so a test can /// run the pass without waiting the interval out; nothing in production /// calls it but `runScheduler`. pub fn scheduledPass(self: *Manager, io: std.Io) std.Io.Cancelable!void { // Ahead of the gate as well as ahead of the refresh: the sweep only // unlinks, so it is the one thing here that can give a critically full // disk room back, and gating it would keep the residue that helped fill // the disk in the first place. try self.sweepOrphans(io); if (self.refreshGated()) return; // `refreshAll` flushes the pass itself, so the only flush left here is // the one the failure note below needs: flushing unconditionally would // report every outcome of the pass a second time. self.refreshAll(io) catch |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("blocklist refresh pass failed: {s}", .{@errorName(err)}); self.noteSnapshot(io, @errorName(err)); self.flushDiagnostics(io); }, }; } /// `pruneOrphans` with its failure absorbed. Leftover bytes under /// `/blocklists/` are not an outage, and a sweep that could not /// read the directory must not cost the household the refresh pass behind /// it — let alone the server. Cancellation is the one outcome that /// propagates, because it means shutdown. /// /// 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 { if (self.pruneOrphans(io)) { self.noteStorageSuccess(io, .sweep); } else |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("pruning orphaned blocklist files failed: {s}", .{@errorName(err)}); self.noteStorageFailure(io, .sweep, "pruning orphaned blocklist files failed: {s}", .{@errorName(err)}); }, } self.flushDiagnostics(io); } /// The §11.6 gate, consulted by scheduled passes only (ruling 17). A /// download writes tens of megabytes into the blocklist directory and the /// compile writes as much again, which is exactly the "non-essential write" /// a critically full disk must not take. /// /// `reload` and `refreshAll` are deliberately not gated: both are operator /// actions (the composition root's startup load, `POST /// /api/blocklists/update`), /// and an operator who asks for a refresh on a full disk has asked for it. /// /// Counting happens here, so a caller cannot skip a pass without recording /// it. One `warn` line per skipped pass — at a 24-hour interval that is one /// line a day, and the disk monitor already logs the state change itself. fn refreshGated(self: *Manager) bool { const monitor = self.monitor orelse return false; if (monitor.writesAllowed()) return false; _ = self.refreshes_gated.fetchAdd(1, .monotonic); log.warn("free space is critical; skipping the scheduled blocklist refresh", .{}); return true; } /// Scheduled refresh passes the disk gate has skipped. pub fn refreshesGated(self: *const Manager) u64 { return self.refreshes_gated.load(.monotonic); } fn startupPass(self: *Manager, io: std.Io) Error!void { self.refresh_lock.lockUncancelable(io); defer self.refresh_lock.unlock(io); // Inside `refresh_lock`, by being registered after the unlock: see // `refreshSource`. defer self.flushDiagnostics(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.reloadCollecting(io); if (self.refreshGated()) return; var rows = try sources_repo.listSourceRows(self.database, self.gpa); defer rows.deinit(self.gpa); defer sources_repo.freeSourceRows(self.gpa, rows.items); const now = std.Io.Clock.real.now(io).toSeconds(); var refreshed = false; for (rows.items) |row| { if (!row.enabled) continue; if (!self.needsRefresh(io, row, now)) continue; if (try self.refreshSourceLocked(io, row)) refreshed = true; } if (refreshed) try self.reloadCollecting(io); } fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool { self.lock.lockSharedUncancelable(io); const state: State = state: { for (self.statuses) |status| { if (status.id == row.id) break :state status.state; } break :state .never_fetched; }; self.lock.unlockShared(io); if (state != .ok) return true; const last = row.last_updated orelse return true; // The Pi has no RTC, so a fetch stamped while the clock ran ahead of // real time (a pre-NTP boot, a restored image) leaves a `last_updated` // in the future. Plain interval arithmetic would then suspend every // refresh until real time caught up with the poison stamp, and the // reconcile engine preserves runtime columns faithfully, so nothing // else would ever clear it. A stamp from the future is not evidence of // a recent fetch. if (last > now) return true; return now - last >= model.updateIntervalSeconds(self.schedule(io)); } // ----------------------------------------------------------------------- // orphans // ----------------------------------------------------------------------- /// Deletes the compiled files and the leftover temporaries whose id is no /// longer a `blocklist_sources` row. Files of a live source are left alone, /// whatever their state. /// /// Three callers, and between them they cover every way an orphan is made: /// `runScheduler` sweeps once before its startup pass — the residue of a /// process that was killed mid-refresh, and of a source deleted while the /// server was down — and again before each scheduled pass; the /// `DELETE /api/blocklists/{id}` handler sweeps as soon as it has removed /// the row, so the directory follows the table an operator can see instead /// of waiting out `blocklist_update.interval_hours`. /// /// It is safe to call on a fresh install: `openDir` creates /// `/blocklists/` if nothing has yet, and an empty directory /// sweeps to nothing. pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void { defer self.flushDiagnostics(io); return self.pruneOrphansLocked(io); } /// Assumes nothing and takes both writer mutexes itself. Split from /// `pruneOrphans` so the diagnostics flush above happens with neither held. fn pruneOrphansLocked(self: *Manager, io: std.Io) Error!void { // `refresh_lock` first, and for the reason it exists: the download and // the compile are the only writers of `.raw.tmp`, `.list.tmp`, // `.wild.tmp` and `.allow.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); var rows = try sources_repo.listSourceRows(self.database, self.gpa); defer rows.deinit(self.gpa); defer sources_repo.freeSourceRows(self.gpa, rows.items); var dir = try self.openDir(io, .{ .iterate = true }); defer dir.close(io); // The names are collected first: `Entry.name` is invalidated by the // next `next`, and deleting under an open cursor is not defined. var doomed: std.ArrayList([]u8) = .empty; defer { for (doomed.items) |item| self.gpa.free(item); doomed.deinit(self.gpa); } var it = dir.iterate(); while (true) { const entry = it.next(io) catch |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("pruning blocklists: reading the directory failed: {s}", .{@errorName(err)}); self.noteStorageFailure(io, .directory_read, "reading the blocklist directory failed: {s}", .{@errorName(err)}); return error.FileSystem; }, } orelse break; if (entry.kind != .file) continue; const id = sourceFileId(entry.name) orelse continue; if (containsId(rows.items, id)) continue; try doomed.append(self.gpa, try self.gpa.dupe(u8, entry.name)); } self.noteStorageSuccess(io, .directory_read); for (doomed.items) |name| { self.deleteQuietly(io, dir, name); log.info("pruned orphaned blocklist file {s}", .{name}); } } // ----------------------------------------------------------------------- // internals // ----------------------------------------------------------------------- /// Builds the status table for `rows`, carrying every existing entry over /// by row id so a recorded failure survives. Nothing is published: the /// caller either installs the result or frees it, which is what lets /// `reloadLocked` decide only once its snapshot exists. fn buildStatusTable( self: *Manager, io: std.Io, rows: []const sources_repo.SourceRow, ) Error!StatusTable { var fresh: std.heap.ArenaAllocator = .init(self.gpa); errdefer fresh.deinit(); const arena = fresh.allocator(); // The published table is copied under the lock. Reading it unlocked // would race the writer that replaces it — and the arena its entries // live in is freed by whoever installs what this builds. const previous = previous: { self.lock.lockSharedUncancelable(io); defer self.lock.unlockShared(io); break :previous try arena.dupe(SourceStatus, self.statuses); }; const table = try arena.alloc(SourceStatus, rows.len); mergeStatuses(table, rows, previous); return .{ .arena = fresh, .items = table }; } /// Publishes a built table and frees the one it replaces. The caller holds /// the exclusive lock, so no reader is inside the old table and no flush is /// half way through draining it. /// /// The pass accounting the live table still holds is folded into the /// incoming entry of the same id first. `mergeStatuses` left the candidate /// carrying none, so an outcome recorded after the candidate was built — /// and any a flush has not drained yet — survives the swap exactly once. An /// outcome a flush already reported is zero in the live table, so nothing /// here resurrects it. fn installStatuses(self: *Manager, table: StatusTable) void { for (table.items) |*incoming| { const live = entryFor(self.statuses, incoming.id) orelse continue; incoming.pass_failures +|= live.pass_failures; incoming.pass_outcome = incoming.pass_outcome or live.pass_outcome; } self.status_arena.deinit(); self.status_arena = table.arena; self.statuses = table.items; } /// Rebuilds and publishes the status table from the current source set. /// /// `refreshAll` calls this before it refreshes anything: a source added /// since the last reload needs an entry to record its outcome in, and the /// API has to see the pass advance while it runs. `reloadLocked` does not /// call it — a reload publishes its table together with the snapshot that /// table describes. fn syncStatuses(self: *Manager, io: std.Io, rows: []const sources_repo.SourceRow) Error!void { const table = try self.buildStatusTable(io, rows); self.lock.lockUncancelable(io); defer self.lock.unlock(io); self.installStatuses(table); } /// The recorded entry for one source, or `null` when the table has none. fn priorStatus(self: *Manager, io: std.Io, id: i64) ?SourceStatus { self.lock.lockSharedUncancelable(io); defer self.lock.unlockShared(io); for (self.statuses) |entry| { if (entry.id == id) return entry; } return null; } fn commitStatus(self: *Manager, io: std.Io, status: SourceStatus) void { self.lock.lockUncancelable(io); defer self.lock.unlock(io); for (self.statuses) |*entry| { if (entry.id != status.id) continue; // `loaded` is the reload's fact, not the refresh's: the files this // refresh wrote are not in a snapshot until the next reload reads // them. const loaded = entry.loaded; entry.* = status; 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. fn groupIds(self: *Manager, groups: []const model.Group) Error![]i64 { const out = try self.gpa.alloc(i64, groups.len); errdefer self.gpa.free(out); for (out, groups) |*slot, group| { slot.* = try groups_repo.groupId(self.database, group.name) orelse return error.GroupSetChanged; } return out; } fn openDir(self: *Manager, io: std.Io, options: std.Io.Dir.OpenOptions) Error!std.Io.Dir { _ = self.paths.dir.createDirPathStatus(io, self.paths.subdir, .fromMode(0o700)) catch |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("creating {s} failed: {s}", .{ self.paths.subdir, @errorName(err) }); self.noteStorageFailure(io, .create_dir, "creating {s} failed: {s}", .{ self.paths.subdir, @errorName(err) }); return error.FileSystem; }, }; self.noteStorageSuccess(io, .create_dir); const dir = self.paths.dir.openDir(io, self.paths.subdir, options) catch |err| switch (err) { error.Canceled => return error.Canceled, else => { log.warn("opening {s} failed: {s}", .{ self.paths.subdir, @errorName(err) }); self.noteStorageFailure(io, .open_dir, "opening {s} failed: {s}", .{ self.paths.subdir, @errorName(err) }); return error.FileSystem; }, }; self.noteStorageSuccess(io, .open_dir); return dir; } /// A temporary that cannot be removed is not a failure of the operation /// that made it, but it is not nothing either: it is left visible. fn deleteQuietly(self: *Manager, io: std.Io, dir: std.Io.Dir, name: []const u8) void { if (dir.deleteFile(io, name)) { self.noteStorageSuccess(io, .delete); } else |err| switch (err) { // A name that was never created is the ordinary case: the temporary // deletes are installed before the files exist. error.FileNotFound => self.noteStorageSuccess(io, .delete), else => { log.warn("deleting {s} failed: {s}", .{ name, @errorName(err) }); self.noteStorageFailure(io, .delete, "deleting {s} failed: {s}", .{ name, @errorName(err) }); }, } } }; /// The one subject `blocklist.snapshot` ever has: a box publishes exactly one /// snapshot, and every source that failed to load is its own /// `blocklist.refresh` episode. const snapshot_key = "snapshot"; /// A status table and the arena holding it. Until `installStatuses` takes it, /// it is a candidate nobody can see, and `deinit` frees it whole. const StatusTable = struct { arena: std.heap.ArenaAllocator, items: []SourceStatus, fn deinit(self: *StatusTable) void { self.arena.deinit(); self.items = &.{}; } }; /// Fills `table` with one entry per row, carrying an entry of the same row id /// over from `previous`. A source deleted since `previous` was built is gone; a /// source added since starts blank. `previous` is only read, so the caller's /// published table is untouched by this. /// /// The pass accounting is *not* carried: it lives in exactly one table copy at /// a time. `previous` is a snapshot of the published table taken outside the /// swap, so copying its counters here would leave the same outcomes in two /// tables — the live one for a flush to drain, and this candidate for the /// reload's own flush to report a second time. A candidate holds only what /// `applyLoadOutcomes` writes into it; what the live table holds is folded in /// by `installStatuses` under the exclusive lock. fn mergeStatuses( table: []SourceStatus, rows: []const sources_repo.SourceRow, previous: []const SourceStatus, ) void { for (table, rows) |*status, row| { status.* = .{ .id = row.id }; for (previous) |prior| { if (prior.id != row.id) continue; status.* = prior; break; } status.pass_outcome = false; status.pass_failures = 0; // After the carry-over: a url edited on the row wins over the one the // prior entry recorded. status.setUrl(row.url); } } /// What one `reload` found for one source. The texts are static, so an outcome /// borrows nothing and stays valid until the swap that commits it. const LoadOutcome = union(enum) { /// The source is switched off. Not in the snapshot, whatever it was before. disabled, /// Its compiled files are in the snapshot. loaded: matcher.Snapshot.Compiled, /// It is not in the snapshot, for this reason. failed: struct { state: State, text: []const u8 }, }; fn loadFailure(row: sources_repo.SourceRow, file_name: []const u8, err: anyerror) LoadOutcome { log.warn("blocklist {f}: reading {s} failed: {s}", .{ SourceLabel.of(row), file_name, @errorName(err) }); return .{ .failed = .{ .state = .load_failed, .text = @errorName(err) } }; } /// Writes one reload's findings into the status table. The caller holds the /// exclusive lock: this runs inside the swap so the table and the published /// snapshot describe the same thing. /// /// `rows` and `outcomes` are parallel. A source with no entry is one the table /// was not synced for, which cannot happen from `reloadLocked` and is skipped /// rather than asserted, because the table is rebuilt by row id. fn applyLoadOutcomes( statuses: []SourceStatus, rows: []const sources_repo.SourceRow, outcomes: []const LoadOutcome, ) void { for (rows, outcomes) |row, outcome| { const entry = entryFor(statuses, row.id) orelse continue; switch (outcome) { // A source disabled since it last loaded is no longer filtering, // and its recorded state describes files nothing reads. .disabled => entry.loaded = false, .loaded => { entry.loaded = true; // Two states survive a successful load. `.ok`, because a // refresh in this process already filled the counters the // compile produced and the five database columns are a subset // of them. And any refresh failure, because the files that just // loaded are exactly the ones the failed refresh could not // replace, so the operator must still see why. if (entry.state == .ok or entry.state.isRefreshFailure()) continue; entry.succeed(row.last_updated orelse 0, .{ .domains = countOf(row.domain_count), .wildcards = countOf(row.wildcard_count), .exceptions = countOf(row.exception_count), .skipped_regex = countOf(row.skipped_regex_count), .skipped_unsupported = countOf(row.skipped_unsupported_count), }); }, .failed => |reason| { entry.loaded = false; // The state follows only when nothing more informative is // there: a refresh failure already says why the files are // missing or stale, and `loaded` already says they are not // filtering. if (entry.state.isRefreshFailure()) continue; entry.fail(reason.state, reason.text); }, } } } fn entryFor(statuses: []SourceStatus, id: i64) ?*SourceStatus { for (statuses) |*entry| { if (entry.id == id) return entry; } return null; } const Outcome = union(enum) { fetch: fetcher.Error!fetcher.Result, expiry: std.Io.Cancelable!void, }; fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { return duration.sleep(io); } /// A counter column read back from the database. It is `NOT NULL DEFAULT 0` and /// only this file writes it, so a value outside `u32` means the row was edited /// behind nxdns's back; the status reports 0 rather than trapping. fn countOf(value: i64) u32 { return std.math.cast(u32, value) orelse 0; } fn destroySnapshot(gpa: Allocator, snapshot: *matcher.Snapshot) void { snapshot.deinit(); gpa.destroy(snapshot); } /// Copies the lines `parsers.detectFormat` would count — neither blank nor a /// comment — until it has `parsers.sample_lines` of them, and writes them to /// `w` newline-separated. Sampling by line rather than by a byte window is what /// keeps a run of long comment lines from deciding the format: `detectFormat` /// reads exactly these lines and ignores everything this drops. /// /// A line over `compiler.max_line_len` is skipped, as the compiler skips it. fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteFailed }!void { var considered: usize = 0; while (considered < parsers.sample_lines) { const event = (try parsers.nextBoundedLine(r, compiler.max_line_len)) orelse return; const raw = switch (event) { .long_line => continue, .line => |line| line, }; const line = std.mem.trim(u8, raw, &std.ascii.whitespace); if (line.len == 0) continue; if (parsers.isComment(line)) continue; considered += 1; try w.writeAll(line); try w.writeByte('\n'); } } /// Whether three compiled files carry the bodies `expected` was taken over. fn compiledBodiesMatch( list_bytes: []const u8, wild_bytes: []const u8, allow_bytes: []const u8, expected: []const u8, ) bool { return std.mem.eql(u8, expected, &bodyChecksum( stripHeader(list_bytes), stripHeader(wild_bytes), stripHeader(allow_bytes), )); } /// A compile that produced no entry at all while rejecting lines is an error /// page, a compressed body or a format the sniff got wrong — not a blocklist. /// Publishing it would replace a working list with nothing and report `ok`. An /// input that rejected nothing is an empty list, which is legal. /// /// A list of nothing but exceptions is loadable: an allow-only list published /// beside a blocking one is a shape operators use, and it produces entries. fn rejectedWithoutEntries(counts: compiler.Counts) bool { if (counts.domains != 0 or counts.wildcards != 0 or counts.exceptions != 0) return false; return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0; } /// The digest the `.list`, `.wild` and `.allow` bodies share, in that order, /// each followed by `compiler.body_separator`. /// /// Must stay byte-for-byte what `compiler.compile` produces, separators /// included: this is the other half of the same digest, and the two are /// compared against each other on every refresh. fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 { var hasher = Sha256.init(.{}); hasher.update(list_body); hasher.update(compiler.body_separator); hasher.update(wild_body); hasher.update(compiler.body_separator); hasher.update(allow_body); hasher.update(compiler.body_separator); var digest: [Sha256.digest_length]u8 = undefined; hasher.final(&digest); return std.fmt.bytesToHex(digest, .lower); } fn compiledName(buf: *[name_buf_len]u8, id: i64, suffix: []const u8) []const u8 { // An `i64` prints in at most 20 characters and the longest suffix is ten, // so `name_buf_len` cannot be exceeded. return std.fmt.bufPrint(buf, "{d}{s}", .{ id, suffix }) catch unreachable; } /// Every name `compiledName` can produce, longest suffix first so `.list.tmp` /// is never read as `.list`. const source_file_suffixes = [_][]const u8{ ".allow.tmp", ".list.tmp", ".wild.tmp", ".raw.tmp", ".allow", ".list", ".wild", }; /// The source id a file under the blocklist directory belongs to, or null when /// the name is not one of ours. /// /// The four 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 `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; const stem = file_name[0 .. file_name.len - suffix.len]; return std.fmt.parseInt(i64, stem, 10) catch null; } return null; } fn containsId(rows: []const sources_repo.SourceRow, id: i64) bool { for (rows) |row| { if (row.id == id) return true; } return false; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- // // 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 events_fixture = @import("../storage/events_fixture.zig"); const migrations = @import("../storage/migrations.zig"); fn openMigrated() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); return database; } fn testManager(database: *db.Db, fetcher_ptr: *fetcher.Fetcher) !Manager { return Manager.init( testing.allocator, database, // `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, .{}, .{ .raw = .fromSeconds(30), .clock = .awake }, ); } test "init leaves the manager with no snapshot and no statuses" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); try testing.expectEqual(@as(u64, 0), manager.generation); try testing.expectEqual(@as(usize, 0), manager.statuses.len); try testing.expect(manager.current == null); } test "acquire before any reload returns null and holds no lock" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); try testing.expect(manager.acquire(io) == null); // A retained shared lock would make this exclusive lock block forever. try testing.expect(manager.lock.tryLock(io)); manager.lock.unlock(io); } test "needsRefresh treats a last_updated in the future as due" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); // `.ok` is the only state that consults the clock at all; every other one // is already due, so the arithmetic below would be unreachable without it. var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }}; manager.statuses = &statuses; defer manager.statuses = &.{}; const row = testRow(1, true); const stamp = row.last_updated.?; const interval = model.updateIntervalSeconds(manager.update); // The ordinary cases still hold: fresh is not due, stale is. try testing.expect(!manager.needsRefresh(io, row, stamp + 1)); try testing.expect(manager.needsRefresh(io, row, stamp + interval)); // The Pi has no RTC. A fetch stamped while the clock ran ahead of real // time leaves `now - last` negative, which reads as "fetched moments ago" // and suspends every refresh until real time catches the poison stamp — // for a whole day here, and for as long as the clock was wrong in general. try testing.expect(manager.needsRefresh(io, row, stamp - 1)); try testing.expect(manager.needsRefresh(io, row, stamp - 86_400)); } test "the disk gate skips a scheduled refresh only while writes are critical" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); // No monitor: every pass runs, which is what the tests and `check` rely on. try testing.expect(!manager.refreshGated()); try testing.expectEqual(@as(u64, 0), manager.refreshesGated()); var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null); manager.monitor = &monitor; // `.ok` and `.warn` both allow writes: only `critical` stops them. try testing.expect(!manager.refreshGated()); monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic); try testing.expect(!manager.refreshGated()); try testing.expectEqual(@as(u64, 0), manager.refreshesGated()); monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic); try testing.expect(manager.refreshGated()); try testing.expect(manager.refreshGated()); try testing.expectEqual(@as(u64, 2), manager.refreshesGated()); // Free space recovers and the schedule resumes; the counter keeps its total. monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic); try testing.expect(!manager.refreshGated()); try testing.expectEqual(@as(u64, 2), manager.refreshesGated()); } test "statusSnapshot on an empty manager copies nothing" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); var out: [4]SourceStatus = undefined; 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 = ""; const allow_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; var allow_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 }); // Present rather than absent, so the third read is a real one: `loadSource` // treats a missing `.allow` as an empty body and would never open it. try dir.writeFile(io, .{ .sub_path = compiledName(&allow_buf, id, ".allow"), .data = allow_body }); try sources_repo.updateSourceStats(&database, id, .{ .last_updated = 1_700_000_000, .domain_count = 1, .wildcard_count = 0, .skipped_regex_count = 0, .skipped_unsupported_count = 0, .exception_count = 0, .checksum = &bodyChecksum(list_body, wild_body, allow_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; // Every catch site, in the order `loadSource` reads the three 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. The `.allow` read is the one that can get this wrong // twice over: it also has to keep `FileNotFound` apart from a cancellation. for ([_][]const u8{ ".list", ".wild", ".allow" }) |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); const header: Header = .{ .url = "https://lists.example/hosts.txt", .format = .hosts, .fetched_at = 1_700_000_000, .counts = .{ .domains = 12, .wildcards = 3, .exceptions = 7, .skipped_regex = 2, .skipped_unsupported = 1, .invalid = 5, .long_lines = 9, .duplicates = 4, }, .checksum = "0" ** 64, }; try header.write(&w); try testing.expectEqualStrings( \\# nxdns blocklist \\# url https://lists.example/hosts.txt \\# format hosts \\# fetched_at 1700000000 \\# domains 12 \\# wildcards 3 \\# exceptions 7 \\# skipped_regex 2 \\# skipped_unsupported 1 \\# invalid 5 \\ ++ "# sha256 " ++ "0" ** 64 ++ "\n", w.buffered()); } test "the log label names a source without printing what its url carries" { // Every `log.warn` in this file formats its subject through `SourceLabel`, // so this is the text of those lines. A `std.log` line is not observable // from a unit test under the default runner; the label is. var buf: [1024]u8 = undefined; const row: sources_repo.SourceRow = .{ .id = 3, .url = "https://lists.example/download/token/hunter2/hosts.txt?apikey=s3cr3t", .name = "ads", .enabled = true, .last_updated = null, .domain_count = 0, .wildcard_count = 0, .skipped_regex_count = 0, .skipped_unsupported_count = 0, .checksum = null, }; const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{ SourceLabel.of(row), @errorName(error.ConnectFailed), }); try testing.expectEqualStrings( "blocklist source 3 'ads' 'https://lists.example': download failed: ConnectFailed", printed, ); try testing.expect(!std.mem.containsAtLeast(u8, printed, 1, "hunter2")); try testing.expect(!std.mem.containsAtLeast(u8, printed, 1, "s3cr3t")); // The row is database text, and a path that writes it does not have to // validate as strictly as the config validator. Neither column may end the // line and start one of the operator's choosing. var forged = row; forged.name = "ads\n2026-01-01 ERROR forged"; forged.url = "https://lists.example\n2026-01-01 ERROR forged/hosts.txt"; const escaped = try std.fmt.bufPrint(&buf, "blocklist {f}", .{SourceLabel.of(forged)}); try testing.expectEqualStrings( "blocklist source 3 'ads\\n2026-01-01 ERROR forged'" ++ " 'https://lists.example\\n2026-01-01 ERROR forged'", escaped, ); try testing.expect(!std.mem.containsAtLeast(u8, escaped, 1, "\n")); // A name is operator-supplied and reaches the row through the API, so it // can close the quote this label puts around it and open a decoy that reads // as the url of a second source. The quote it would close is escaped, and // the escape is unambiguous because a `\` is escaped too. var decoy = row; decoy.name = "ads' (https://decoy.example) --"; decoy.url = "https://lists.example/hosts.txt"; const quoted = try std.fmt.bufPrint(&buf, "blocklist {f}", .{SourceLabel.of(decoy)}); try testing.expectEqualStrings( "blocklist source 3 'ads\\' (https://decoy.example) --' 'https://lists.example'", quoted, ); } test "stripHeader returns the body of a compiled file" { const file = "# nxdns blocklist\n" ++ "# url https://lists.example/hosts.txt\n" ++ "ads.example.com\ntracker.example.net\n"; try testing.expectEqualStrings("ads.example.com\ntracker.example.net\n", stripHeader(file)); } test "stripHeader returns everything for a file with no header" { try testing.expectEqualStrings("a.example.com\n", stripHeader("a.example.com\n")); } test "stripHeader returns an empty body for a header-only file" { try testing.expectEqualStrings("", stripHeader("# nxdns blocklist\n# sha256 x\n")); } test "stripHeader tolerates an unterminated header line" { try testing.expectEqualStrings("", stripHeader("# nxdns blocklist")); } test "SourceStatus truncates a long error at max_error_len" { var status: SourceStatus = .{ .id = 1 }; const long = "E" ** (max_error_len + 40); status.fail(.fetch_failed, long); try testing.expectEqual(State.fetch_failed, status.state); try testing.expectEqual(@as(u8, max_error_len), status.last_error_len); try testing.expectEqualStrings("E" ** max_error_len, status.errorText()); } test "a success clears the recorded error" { var status: SourceStatus = .{ .id = 7 }; status.fail(.compile_failed, "TooManyDomains"); status.succeed(1_700_000_000, .{ .domains = 3, .wildcards = 1 }); try testing.expectEqual(State.ok, status.state); try testing.expectEqual(@as(i64, 1_700_000_000), status.last_success); try testing.expectEqual(@as(u32, 3), status.counts.domains); try testing.expectEqualStrings("", status.errorText()); } comptime { // The two tests below spell every suffix out instead of looping over // `source_file_suffixes`: a test that reads the table moves with it, so a // name dropped from the table would take the assertion that covers it along. // An eighth suffix breaks the build here until both are extended. std.debug.assert(source_file_suffixes.len == 7); } test "compiledName spells every file name of a source" { var buf: [name_buf_len]u8 = undefined; try testing.expectEqualStrings("42.list", compiledName(&buf, 42, ".list")); try testing.expectEqualStrings("42.wild", compiledName(&buf, 42, ".wild")); try testing.expectEqualStrings("42.allow", compiledName(&buf, 42, ".allow")); try testing.expectEqualStrings("42.raw.tmp", compiledName(&buf, 42, ".raw.tmp")); try testing.expectEqualStrings("42.list.tmp", compiledName(&buf, 42, ".list.tmp")); try testing.expectEqualStrings("42.wild.tmp", compiledName(&buf, 42, ".wild.tmp")); try testing.expectEqualStrings("42.allow.tmp", compiledName(&buf, 42, ".allow.tmp")); } test "sourceFileId matches every name a refresh writes, including the temporaries" { try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list")); try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild")); try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow")); // A temporary left by a refresh that died belongs to its source id, so the // sweep can tell whether that source still has a row. try testing.expectEqual(@as(?i64, 7), sourceFileId("7.raw.tmp")); try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list.tmp")); try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild.tmp")); try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow.tmp")); try testing.expectEqual(@as(?i64, null), sourceFileId("notes.list")); try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow")); try testing.expectEqual(@as(?i64, null), sourceFileId("notes.raw.tmp")); try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow.tmp")); try testing.expectEqual(@as(?i64, null), sourceFileId("7.tmp")); try testing.expectEqual(@as(?i64, null), sourceFileId("7.raw")); try testing.expectEqual(@as(?i64, null), sourceFileId("7.allowed")); try testing.expectEqual(@as(?i64, null), sourceFileId("README")); } test "every name compiledName writes is a name the sweep can attribute" { // A round-trip over the table, not a coverage check: this loop reads the // same array the code reads, so it cannot notice a missing entry. The two // tests above are what pins the set. var buf: [name_buf_len]u8 = undefined; for (source_file_suffixes) |suffix| { try testing.expectEqual(@as(?i64, 42), sourceFileId(compiledName(&buf, 42, suffix))); } } test "a failed refresh keeps the fields of the compiled files still serving" { var status: SourceStatus = .{ .id = 3 }; status.succeed(1_700_000_000, .{ .domains = 5, .wildcards = 2 }); // What `refreshSourceLocked` starts from, and what a download failure does // to it. var next = status; next.last_attempt = 1_700_003_600; next.fail(.fetch_failed, "Timeout"); try testing.expectEqual(State.fetch_failed, next.state); try testing.expectEqualStrings("Timeout", next.errorText()); try testing.expectEqual(@as(i64, 1_700_000_000), next.last_success); try testing.expectEqual(@as(i64, 1_700_003_600), next.last_attempt); try testing.expectEqual(@as(u32, 5), next.counts.domains); try testing.expectEqual(@as(u32, 2), next.counts.wildcards); } test "a load outcome never overwrites a refresh failure" { try testing.expect(State.fetch_failed.isRefreshFailure()); try testing.expect(State.compile_failed.isRefreshFailure()); try testing.expect(State.no_valid_entries.isRefreshFailure()); // The three a load produces. `applyLoadOutcomes` may write over these, // because nothing more informative is there. try testing.expect(!State.ok.isRefreshFailure()); try testing.expect(!State.never_fetched.isRefreshFailure()); try testing.expect(!State.load_failed.isRefreshFailure()); } fn testRow(id: i64, enabled: bool) sources_repo.SourceRow { return .{ .id = id, .url = "https://lists.example/hosts.txt", .name = "example", .enabled = enabled, .last_updated = 1_700_000_000, .domain_count = 9, .wildcard_count = 4, .exception_count = 2, .skipped_regex_count = 1, .skipped_unsupported_count = 5, .checksum = "0" ** 64, }; } test "a candidate table carries prior entries over and leaves the published one alone" { var published = [_]SourceStatus{ .{ .id = 1 }, .{ .id = 2 } }; published[0].setUrl("https://lists.example/one.txt"); published[0].fail(.fetch_failed, "HttpStatus"); published[0].loaded = true; published[1].setUrl("https://lists.example/two.txt"); published[1].succeed(1_700_000_000, .{ .domains = 4 }); // Source 2 was deleted and source 3 added; source 1 kept its id and got a // new url. const rows = [_]sources_repo.SourceRow{ blk: { var row = testRow(1, true); row.url = "https://lists.example/moved.txt"; break :blk row; }, testRow(3, true), }; var candidate: [2]SourceStatus = undefined; mergeStatuses(&candidate, &rows, &published); try testing.expectEqual(@as(i64, 1), candidate[0].id); try testing.expectEqual(State.fetch_failed, candidate[0].state); try testing.expectEqualStrings("HttpStatus", candidate[0].errorText()); try testing.expect(candidate[0].loaded); try testing.expectEqualStrings("https://lists.example/moved.txt", candidate[0].urlText()); try testing.expectEqual(@as(i64, 3), candidate[1].id); try testing.expectEqual(State.never_fetched, candidate[1].state); try testing.expect(!candidate[1].loaded); // The one thing a candidate does not carry. `published[0]` is holding a // failure no flush has drained yet; copying its accounting here would leave // the same outcome in two tables, and the flush of each would report it. try testing.expect(published[0].pass_outcome); try testing.expect(!candidate[0].pass_outcome); try testing.expectEqual(@as(u16, 0), candidate[0].pass_failures); // The published table is untouched, so a reload that fails before the swap // leaves it describing the snapshot that is still serving — including the // entry of the deleted source, which that snapshot still enforces. try testing.expectEqual(@as(usize, 2), published.len); try testing.expectEqual(@as(i64, 2), published[1].id); try testing.expectEqual(State.ok, published[1].state); try testing.expectEqualStrings("https://lists.example/one.txt", published[0].urlText()); } test "installing a table folds the live pass accounting in by id" { 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); // Source 1 recorded a failure the flush has not drained; source 2 was // drained already; source 3 recorded one this reload knows nothing about. var live: std.heap.ArenaAllocator = .init(testing.allocator); const live_items = try live.allocator().alloc(SourceStatus, 3); live_items[0] = .{ .id = 1, .pass_outcome = true, .pass_failures = 2 }; live_items[1] = .{ .id = 2 }; live_items[2] = .{ .id = 3, .pass_outcome = true, .pass_failures = 1 }; { mgr.lock.lockUncancelable(io); defer mgr.lock.unlock(io); mgr.installStatuses(.{ .arena = live, .items = live_items }); } // What a reload built beside it, carrying only its own load outcomes. var incoming: std.heap.ArenaAllocator = .init(testing.allocator); const incoming_items = try incoming.allocator().alloc(SourceStatus, 3); incoming_items[0] = .{ .id = 1, .pass_outcome = true, .pass_failures = 1 }; incoming_items[1] = .{ .id = 2, .pass_outcome = true, .pass_failures = 4 }; incoming_items[2] = .{ .id = 3 }; { mgr.lock.lockUncancelable(io); defer mgr.lock.unlock(io); mgr.installStatuses(.{ .arena = incoming, .items = incoming_items }); } try testing.expectEqual(@as(u16, 3), mgr.statuses[0].pass_failures); try testing.expect(mgr.statuses[0].pass_outcome); // A drained entry adds nothing: what the swap publishes is the reload's own // accounting and no resurrection of what was already reported. try testing.expectEqual(@as(u16, 4), mgr.statuses[1].pass_failures); try testing.expect(mgr.statuses[1].pass_outcome); // The half the swap used to lose: an outcome the live table held and the // candidate never saw. try testing.expectEqual(@as(u16, 1), mgr.statuses[2].pass_failures); try testing.expect(mgr.statuses[2].pass_outcome); } test "the flush claims every entry that carries pass accounting, once" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1_700_000_000); defer fx.deinit(); var f: fetcher.Fetcher = undefined; var mgr = try testManager(&database, &f); defer mgr.deinit(io); mgr.diagnostics = &fx.store; var arena: std.heap.ArenaAllocator = .init(testing.allocator); const items = try arena.allocator().alloc(SourceStatus, 3); items[0] = .{ .id = 1 }; items[0].setUrl("https://lists.example/one.txt"); items[0].fail(.fetch_failed, "HttpStatus"); // No url, so no episode to report under. The drain has to claim it anyway: // an entry left with `pass_outcome` set is the one every later scan finds // first, and the entry behind it would never be reached. items[1] = .{ .id = 2, .pass_outcome = true, .pass_failures = 1 }; items[2] = .{ .id = 3 }; items[2].setUrl("https://lists.example/three.txt"); items[2].succeed(1_700_000_000, .{ .domains = 3 }); { mgr.lock.lockUncancelable(io); defer mgr.lock.unlock(io); mgr.installStatuses(.{ .arena = arena, .items = items }); } mgr.flushDiagnostics(io); try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT count(*) FROM operational_events WHERE code = 'blocklist.refresh'", )); try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT occurrences FROM operational_events WHERE code = 'blocklist.refresh'", )); for (mgr.statuses) |entry| { try testing.expect(!entry.pass_outcome); try testing.expectEqual(@as(u16, 0), entry.pass_failures); } // Drained: flushing the same table again reports nothing a second time. mgr.flushDiagnostics(io); try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT occurrences FROM operational_events WHERE code = 'blocklist.refresh'", )); } test "the flush closes the episode of a source that is no longer in the table" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1_700_000_000); defer fx.deinit(); var f: fetcher.Fetcher = undefined; var mgr = try testManager(&database, &f); defer mgr.deinit(io); mgr.diagnostics = &fx.store; // What the operator deleted while it was failing. Nothing will ever record // a success for it, so nothing but the sweep can close this. fx.store.report( io, 1_700_000_000, .blocklist_refresh, "https://lists.example/deleted.txt", "lists.example/deleted.txt", .warning, "fetch_failed: HttpStatus (1 this pass)", ); var arena: std.heap.ArenaAllocator = .init(testing.allocator); const items = try arena.allocator().alloc(SourceStatus, 1); items[0] = .{ .id = 1 }; items[0].setUrl("https://lists.example/one.txt"); items[0].fail(.fetch_failed, "HttpStatus"); { mgr.lock.lockUncancelable(io); defer mgr.lock.unlock(io); mgr.installStatuses(.{ .arena = arena, .items = items }); } // No snapshot published yet, so the table is not known to describe the // source set and the sweep must not run: the drain reports the failing // source and the deleted one's episode is left alone. mgr.flushDiagnostics(io); try testing.expectEqual(@as(i64, 2), try fx.count( "SELECT count(*) FROM operational_events WHERE code = 'blocklist.refresh' AND resolved_at IS NULL", )); mgr.generation = 1; mgr.flushDiagnostics(io); // One left active, and it is the source that still exists. try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT count(*) FROM operational_events WHERE code = 'blocklist.refresh' AND resolved_at IS NULL", )); try testing.expectEqualStrings( "https://lists.example/one.txt", try fx.text( "SELECT subject_key FROM operational_events WHERE code = 'blocklist.refresh' AND resolved_at IS NULL", ), ); try testing.expectEqualStrings( "https://lists.example/deleted.txt", try fx.text( "SELECT subject_key FROM operational_events WHERE code = 'blocklist.refresh' AND resolved_at IS NOT NULL", ), ); } test "a disabled source stops being loaded" { var statuses = [_]SourceStatus{.{ .id = 1 }}; statuses[0].succeed(1_700_000_000, .{ .domains = 9 }); statuses[0].loaded = true; const rows = [_]sources_repo.SourceRow{testRow(1, false)}; applyLoadOutcomes(&statuses, &rows, &.{.disabled}); // Nothing enforces it any more, and the state that described the files it // used to serve is left as the record of how it last stood. try testing.expect(!statuses[0].loaded); try testing.expectEqual(State.ok, statuses[0].state); } test "a source that failed to refresh keeps its failure while its old files serve" { // What `refreshSourceLocked` records, then what the `reload` that follows // it in `refreshAll` finds: the previous files still load. var statuses = [_]SourceStatus{.{ .id = 1 }}; statuses[0].succeed(1_700_000_000, .{ .domains = 9 }); statuses[0].fail(.fetch_failed, "HttpStatus"); const rows = [_]sources_repo.SourceRow{testRow(1, true)}; const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" }; applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }}); try testing.expect(statuses[0].loaded); try testing.expectEqual(State.fetch_failed, statuses[0].state); try testing.expectEqualStrings("HttpStatus", statuses[0].errorText()); try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains); } test "a load failure is recorded when no refresh failure explains it" { var statuses = [_]SourceStatus{ .{ .id = 1 }, .{ .id = 2 } }; statuses[0].succeed(1_700_000_000, .{ .domains = 9 }); statuses[0].loaded = true; statuses[1].fail(.compile_failed, "TooManyDomains"); statuses[1].loaded = true; const rows = [_]sources_repo.SourceRow{ testRow(1, true), testRow(2, true) }; const reason: LoadOutcome = .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } }; applyLoadOutcomes(&statuses, &rows, &.{ reason, reason }); try testing.expect(!statuses[0].loaded); try testing.expectEqual(State.load_failed, statuses[0].state); try testing.expectEqualStrings("ChecksumMismatch", statuses[0].errorText()); // The compile failure is why the files are unusable; it outranks the // symptom the loader saw. try testing.expect(!statuses[1].loaded); try testing.expectEqual(State.compile_failed, statuses[1].state); try testing.expectEqualStrings("TooManyDomains", statuses[1].errorText()); } test "a load of a source this process never refreshed takes the row counters" { var statuses = [_]SourceStatus{.{ .id = 1 }}; const rows = [_]sources_repo.SourceRow{testRow(1, true)}; const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" }; applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }}); try testing.expect(statuses[0].loaded); try testing.expectEqual(State.ok, statuses[0].state); try testing.expectEqual(@as(i64, 1_700_000_000), statuses[0].last_success); try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains); try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards); try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex); // Rehydration: a restart reads this from the row and nowhere else, because // no path reparses a compiled file's header. try testing.expectEqual(@as(u32, 5), statuses[0].counts.skipped_unsupported); } test "a status borrows nothing, so a copy outlives the table it came from" { var status: SourceStatus = .{ .id = 5 }; status.setUrl("https://lists.example/hosts.txt"); status.fail(.load_failed, "ChecksumMismatch"); const copy = status; // The source of the original is overwritten, as a reload overwrites the // table: a copy that borrowed would read the new bytes or freed memory. status.setUrl("https://other.example/other.txt"); status.fail(.fetch_failed, "Timeout"); try testing.expectEqualStrings("https://lists.example/hosts.txt", copy.urlText()); try testing.expectEqualStrings("ChecksumMismatch", copy.errorText()); try testing.expectEqual(State.load_failed, copy.state); } test "SourceStatus truncates a long url at max_url_len" { var status: SourceStatus = .{ .id = 6 }; status.setUrl("https://lists.example/" ++ "p" ** max_url_len); try testing.expectEqual(@as(u8, max_url_len), status.url_len); try testing.expectEqualStrings( ("https://lists.example/" ++ "p" ** max_url_len)[0..max_url_len], status.urlText(), ); // A shorter url must not leave the tail of the longer one behind it. status.setUrl("https://a.example/x"); try testing.expectEqualStrings("https://a.example/x", status.urlText()); } test "compiledBodiesMatch verifies the bodies, not the presence of the files" { const list_body = "a.example.com\nb.example.com\n"; const wild_body = "c.example.com\n"; const allow_body = "d.example.com\n"; const expected = bodyChecksum(list_body, wild_body, allow_body); const header = "# nxdns blocklist\n" ++ "# url https://lists.example/hosts.txt\n"; try testing.expect(compiledBodiesMatch( header ++ list_body, header ++ wild_body, header ++ allow_body, &expected, )); // The corruption a reload reports as `ChecksumMismatch`: the file is there, // its body is not what the checksum was taken over. An allow body that lost // its entry counts, because a dropped exception silently restores a block. try testing.expect(!compiledBodiesMatch( header ++ "a.example.com\nb.exa", header ++ wild_body, header ++ allow_body, &expected, )); try testing.expect(!compiledBodiesMatch(header ++ list_body, header ++ wild_body, "", &expected)); try testing.expect(!compiledBodiesMatch("", "", "", &expected)); } test "bodyChecksum separates the three bodies" { const list_body = "a.example.com\nb.example.com\n"; const wild_body = "c.example.com\n"; var hasher = Sha256.init(.{}); hasher.update(list_body); hasher.update(compiler.body_separator); hasher.update(wild_body); hasher.update(compiler.body_separator); hasher.update(compiler.body_separator); var digest: [Sha256.digest_length]u8 = undefined; hasher.final(&digest); const expected = std.fmt.bytesToHex(digest, .lower); try testing.expectEqualStrings(&expected, &bodyChecksum(list_body, wild_body, "")); // No `.allow` file: what `loadSource` and `diskBodiesMatch` pass for one. // It is an empty body, and an empty body still gets its separator. try testing.expect(compiledBodiesMatch(list_body, wild_body, "", &expected)); // The framing itself: the same bytes in a different body is a different // digest. Unframed these two are equal, and a stale `.list` survives an // upstream that switched the name to a wildcard. try testing.expect(!std.mem.eql( u8, &bodyChecksum("a.example\n", "", ""), &bodyChecksum("", "a.example\n", ""), )); } test "rejectedWithoutEntries fails a compile that produced nothing usable" { // An html error page: every line is rejected, nothing is written. try testing.expect(rejectedWithoutEntries(.{ .invalid = 12, .skipped_unsupported = 3 })); // A compressed body: one long binary run with no newline in it. try testing.expect(rejectedWithoutEntries(.{ .long_lines = 1 })); // An empty list rejects nothing and is legal. try testing.expect(!rejectedWithoutEntries(.{})); // A real list rejects lines and still produces entries. try testing.expect(!rejectedWithoutEntries(.{ .domains = 1000, .invalid = 40 })); try testing.expect(!rejectedWithoutEntries(.{ .wildcards = 7, .skipped_unsupported = 90 })); } fn sampleOf(input: []const u8, out: []u8) ![]const u8 { var r: std.Io.Reader = .fixed(input); var w: std.Io.Writer = .fixed(out); try collectSample(&r, &w); return w.buffered(); } test "collectSample skips comments instead of spending the sample on them" { const gpa = testing.allocator; const long_comment = "# " ++ "c" ** (compiler.max_line_len - 2) ++ "\n"; var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); // Sixteen of these fill a 64 KiB window on their own. for (0..20) |_| try input.appendSlice(gpa, long_comment); try input.appendSlice(gpa, "0.0.0.0 ads.example.com\n0.0.0.0 tracker.example.net\n"); const out = try gpa.alloc(u8, sample_buf_len); defer gpa.free(out); const sample = try sampleOf(input.items, out); try testing.expectEqualStrings( "0.0.0.0 ads.example.com\n0.0.0.0 tracker.example.net\n", sample, ); try testing.expectEqual(parsers.Format.hosts, parsers.detectFormat(sample)); } test "collectSample stops at sample_lines counted lines" { const gpa = testing.allocator; var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); var line_buf: [64]u8 = undefined; for (0..parsers.sample_lines + 10) |i| { try input.appendSlice(gpa, try std.fmt.bufPrint(&line_buf, "0.0.0.0 host{d}.example.com\n", .{i})); } const out = try gpa.alloc(u8, sample_buf_len); defer gpa.free(out); const sample = try sampleOf(input.items, out); var lines = std.mem.tokenizeScalar(u8, sample, '\n'); var count: usize = 0; while (lines.next()) |_| count += 1; try testing.expectEqual(parsers.sample_lines, count); } test "collectSample keeps the abp marker a long comment run would have hidden" { const gpa = testing.allocator; const long_comment = "! " ++ "c" ** (compiler.max_line_len - 2) ++ "\n"; var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); for (0..20) |_| try input.appendSlice(gpa, long_comment); try input.appendSlice(gpa, "||ads.example.com^\n"); const out = try gpa.alloc(u8, sample_buf_len); defer gpa.free(out); const sample = try sampleOf(input.items, out); try testing.expectEqualStrings("||ads.example.com^\n", sample); try testing.expectEqual(parsers.Format.abp, parsers.detectFormat(sample)); } test "collectSample skips a line over max_line_len" { const gpa = testing.allocator; var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); try input.appendNTimes(gpa, 'x', 8 * compiler.max_line_len); try input.appendSlice(gpa, "\nads.example.com\n"); const out = try gpa.alloc(u8, sample_buf_len); defer gpa.free(out); // A `Reader.fixed` holds the whole input, so the over-long line comes back // rather than being refused. The compiler would skip it, so the sniff does. const sample = try sampleOf(input.items, out); try testing.expectEqualStrings("ads.example.com\n", sample); } test "collectSample steps over a line that does not fit the reader buffer" { const gpa = testing.allocator; var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); try input.appendNTimes(gpa, 'x', 4 * compiler.max_line_len); try input.appendSlice(gpa, "\nads.example.com\n"); // A reader buffer smaller than the long line makes `takeDelimiter` report // `error.StreamTooLong` and leave the stream where it was, which is the // path that loops forever without the discard. var backing: std.Io.Reader = .fixed(input.items); var reader_buf: [compiler.max_line_len]u8 = undefined; var limited = backing.limited(.unlimited, &reader_buf); const out = try gpa.alloc(u8, sample_buf_len); defer gpa.free(out); var w: std.Io.Writer = .fixed(out); try collectSample(&limited.interface, &w); try testing.expectEqualStrings("ads.example.com\n", w.buffered()); } test "bodyChecksum covers the list body, then the wild body, then the allow body" { const all = bodyChecksum("a.example.com\n", "b.example.com\n", "c.example.com\n"); var hasher = Sha256.init(.{}); hasher.update("a.example.com\n"); hasher.update(compiler.body_separator); hasher.update("b.example.com\n"); hasher.update(compiler.body_separator); hasher.update("c.example.com\n"); hasher.update(compiler.body_separator); var digest: [Sha256.digest_length]u8 = undefined; hasher.final(&digest); try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &all); // Order matters: the three parts are not interchangeable. try testing.expect(!std.mem.eql( u8, &all, &bodyChecksum("b.example.com\n", "a.example.com\n", "c.example.com\n"), )); try testing.expect(!std.mem.eql( u8, &all, &bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"), )); } // --------------------------------------------------------------------------- // wakeable scheduler (milestone-34 S3.6) // --------------------------------------------------------------------------- /// A `ScheduleClock` that never sleeps. Each park is recorded, then the clock /// jumps straight to the deadline so the loop runs the next pass at once; a /// budget of parks ends the run with the `error.Canceled` shutdown is the only /// other source of. A park may also fire a `setSchedule`, which is what a /// settings PUT landing while the scheduler waits looks like. const StepClock = struct { const max_parks = 16; mutex: std.Io.Mutex = .init, manager: *Manager, now_s: i64 = 0, /// Deadline of each park in order; null means "parked with no deadline", /// which is what a disabled schedule does. parks: [max_parks]?i64 = @splat(null), park_count: usize = 0, budget: usize = 2, /// Fired from inside the park at this index, before the wait returns. change_at_park: ?usize = null, change_enabled: bool = true, change_hours: u16 = 1, fn clock(self: *StepClock) ScheduleClock { return .{ .ctx = self, .nowFn = now, .waitFn = wait }; } fn now(ctx: ?*anyopaque, io: std.Io) i64 { const self: *StepClock = @ptrCast(@alignCast(ctx.?)); self.mutex.lockUncancelable(io); defer self.mutex.unlock(io); return self.now_s; } fn wait(ctx: ?*anyopaque, io: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void { const self: *StepClock = @ptrCast(@alignCast(ctx.?)); self.mutex.lockUncancelable(io); const index = self.park_count; if (index < max_parks) self.parks[index] = deadline_s; self.park_count = index + 1; const fire_change = self.change_at_park == index; const over_budget = self.park_count >= self.budget; if (deadline_s) |d| self.now_s = d; self.mutex.unlock(io); // Taken outside this clock's own mutex: `setSchedule` takes the // manager's, and the loop reads this clock under neither. if (fire_change) { self.manager.setSchedule(io, self.change_enabled, self.change_hours); return; } if (over_budget or deadline_s == null) return error.Canceled; } fn parked(self: *StepClock) []const ?i64 { return self.parks[0..@min(self.park_count, max_parks)]; } }; const hour = 3_600; test "the scheduler parks one interval past the anchor and again past each pass" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = true, .interval_hours = 2 }; var step: StepClock = .{ .manager = &manager, .budget = 3 }; manager.schedule_clock = step.clock(); try testing.expectError(error.Canceled, manager.runScheduler(io)); // The startup pass anchored at 0, so the first park is due at 2 h and each // completed pass re-anchors: 2 h, 4 h, 6 h. try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked()); } test "a shortened interval moves the next refresh onto the new cadence" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = true, .interval_hours = 24 }; // The PUT lands while the loop waits out the 24-hour deadline. var step: StepClock = .{ .manager = &manager, .budget = 4, .change_at_park = 0, .change_enabled = true, .change_hours = 1, }; manager.schedule_clock = step.clock(); try testing.expectError(error.Canceled, manager.runScheduler(io)); // Park 0 was the old 24-hour deadline; the change woke it, and every park // after it is one hour past the anchor the previous pass set. const parks = step.parked(); try testing.expectEqual(@as(usize, 4), parks.len); try testing.expectEqual(@as(?i64, 24 * hour), parks[0]); try testing.expectEqual(@as(?i64, 24 * hour + hour), parks[1]); try testing.expectEqual(@as(?i64, 25 * hour + hour), parks[2]); } test "a disabled schedule parks with no deadline and the startup pass still runs" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = false, .interval_hours = 1 }; var step: StepClock = .{ .manager = &manager, .budget = 8 }; manager.schedule_clock = step.clock(); try testing.expectError(error.Canceled, manager.runScheduler(io)); // The startup pass ran — it published a snapshot even with updates off — // and then the loop parked once, on nothing. try testing.expect(manager.generation > 0); try testing.expectEqualSlices(?i64, &.{null}, step.parked()); } test "re-enabling anchors the first interval on the last pass that ran" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = false, .interval_hours = 1 }; var step: StepClock = .{ .manager = &manager, .budget = 3, .change_at_park = 0, .change_enabled = true, .change_hours = 3, }; manager.schedule_clock = step.clock(); try testing.expectError(error.Canceled, manager.runScheduler(io)); const parks = step.parked(); // Park 0 is the disabled park; the enable wakes it, and the first deadline // is three hours past the STARTUP pass's anchor rather than past the // moment the operator flipped the switch. try testing.expectEqual(@as(?i64, null), parks[0]); try testing.expectEqual(@as(?i64, 3 * hour), parks[1]); } test "an interval already elapsed at enable time refreshes immediately" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = true, .interval_hours = 2 }; var step: StepClock = .{ .manager = &manager, .budget = 2 }; manager.schedule_clock = step.clock(); // The anchor is four hours in the past, so two hours past it is already // gone and the loop must not wait at all before its first pass. manager.schedule_anchor_s = -4 * hour; step.now_s = 0; try testing.expectError(error.Canceled, manager.runScheduler(io)); // The startup pass re-anchors at 0, so this proves nothing on its own // unless the anchor survives it; assert on the parks instead: the first // park is one interval past the startup anchor, never a wait for a // deadline already behind us. const parks = step.parked(); try testing.expectEqual(@as(?i64, 2 * hour), parks[0]); } test "a gate-skipped pass advances the anchor rather than retrying early" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.update = .{ .enabled = true, .interval_hours = 2 }; var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null); monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic); manager.monitor = &monitor; var step: StepClock = .{ .manager = &manager, .budget = 3 }; manager.schedule_clock = step.clock(); try testing.expectError(error.Canceled, manager.runScheduler(io)); // Every scheduled pass was refused by the gate, and each one still spent // its slot: the deadlines march one interval at a time instead of // collapsing onto the same anchor. try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked()); // The startup pass is gated too, so three refusals: one startup and the // two scheduled passes the parks above bracket. try testing.expectEqual(@as(u64, 3), manager.refreshesGated()); } test "setSchedule is what the live schedule readers see" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openMigrated(); defer database.close(); var f: fetcher.Fetcher = undefined; var manager = try testManager(&database, &f); defer manager.deinit(io); manager.setSchedule(io, false, 6); const live = manager.schedule(io); try testing.expect(!live.enabled); try testing.expectEqual(@as(u16, 6), live.interval_hours); try testing.expect(manager.schedule_event.isSet()); }