From 6f67940995f078e8d8aef10820436e7d97e33439 Mon Sep 17 00:00:00 2001 From: m5r Date: Fri, 7 Aug 2026 18:20:30 +0200 Subject: [PATCH] milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules --- TECH_DEBT.md | 26 +- build.zig | 218 +++---- specs/milestone-10.md | 5 +- specs/milestone-18.md | 130 ++++ specs/milestone-4.md | 13 +- src/app.zig | 14 +- src/cache/dns_cache.zig | 4 + src/cli.zig | 53 +- src/config/import.zig | 31 +- src/dns/name.zig | 47 ++ src/filter/compiler.zig | 27 +- src/filter/manager.zig | 18 +- src/filter/parsers.zig | 96 +++ src/filter/rules.zig | 4 + src/local/forward_client.zig | 77 +-- src/local/forward_zones.zig | 26 +- src/local/records.zig | 27 +- src/server/doh_server.zig | 478 +++------------ src/server/dot_server.zig | 514 +++------------- src/server/listener.zig | 568 ++++++++++++++++++ src/server/resolver_integration_test.zig | 2 +- src/server/tcp_server.zig | 471 ++------------- src/server/tcp_server_integration_test.zig | 30 +- src/storage/repositories/clients_repo.zig | 136 ++--- src/storage/repositories/crud.zig | 190 ++++++ src/storage/repositories/groups_repo.zig | 113 ++-- src/storage/repositories/local_repo.zig | 114 ++-- src/storage/repositories/rules_repo.zig | 56 +- src/storage/repositories/settings_repo.zig | 36 +- src/storage/repositories/sources_repo.zig | 68 +-- src/storage/repositories/upstreams_repo.zig | 64 +- src/tests.zig | 1 + src/upstream/doh_client.zig | 191 +++++- src/upstream/dot_client.zig | 58 +- src/upstream/pool.zig | 65 +- src/upstream/transport.zig | 202 +++++++ src/web/handlers/blocklists.zig | 59 +- src/web/handlers/clients.zig | 82 +-- src/web/handlers/groups.zig | 70 +-- src/web/handlers/local.zig | 122 +--- src/web/handlers/mutations.zig | 160 ++++- src/web/handlers/rules.zig | 63 +- src/web/handlers/settings.zig | 11 +- src/web/handlers/upstreams.zig | 59 +- src/web/metrics.zig | 16 +- src/web/server.zig | 351 +++-------- src/web/server_integration_test.zig | 47 +- src/web/web_integration_test.zig | 2 +- tests/fuzz/blocklist_fuzz.zig | 47 +- tests/fuzz/compiler_fuzz.zig | 32 +- tests/fuzz/corpus.zig | 25 +- tests/fuzz/http_util_fuzz.zig | 50 +- tests/fuzz/smith_encode.zig | 64 ++ web/src/auth/LoginPage.tsx | 5 +- web/src/features/blocklists/BlocklistForm.tsx | 27 +- .../features/blocklists/BlocklistsPage.tsx | 53 +- .../blocklists/SourceStatusSection.tsx | 36 +- web/src/features/clients/ClientEditDialog.tsx | 20 +- web/src/features/clients/ClientsPage.tsx | 13 +- web/src/features/clients/PrefixesEditor.tsx | 17 +- web/src/features/dashboard/DashboardPage.tsx | 17 +- .../features/groups/GroupSourcesEditor.tsx | 10 +- web/src/features/groups/GroupsPage.tsx | 26 +- web/src/features/live/LiveLogPage.tsx | 19 +- web/src/features/local/LocalDnsPage.tsx | 3 +- web/src/features/local/RecordsTab.tsx | 82 +-- web/src/features/local/ZonesTab.tsx | 82 +-- web/src/features/lookup/LookupPage.tsx | 10 +- web/src/features/pause/PauseWidget.tsx | 12 +- web/src/features/queries/QueryLogPage.tsx | 29 +- web/src/features/rules/RulesPage.tsx | 48 +- web/src/features/settings/RestartBanner.tsx | 3 +- web/src/features/settings/SettingsPage.tsx | 18 +- web/src/features/upstreams/UpstreamForm.tsx | 29 +- web/src/features/upstreams/UpstreamsPage.tsx | 31 +- web/src/lib/InlineError.test.tsx | 24 + web/src/lib/InlineError.tsx | 16 +- web/src/routes.tsx | 7 +- web/src/shell/AppShell.tsx | 7 +- web/src/ui/classes.ts | 32 + web/src/ui/useCrudForm.test.tsx | 118 ++++ web/src/ui/useCrudForm.ts | 54 ++ 82 files changed, 3167 insertions(+), 3114 deletions(-) create mode 100644 src/server/listener.zig create mode 100644 tests/fuzz/smith_encode.zig create mode 100644 web/src/lib/InlineError.test.tsx create mode 100644 web/src/ui/classes.ts create mode 100644 web/src/ui/useCrudForm.test.tsx create mode 100644 web/src/ui/useCrudForm.ts diff --git a/TECH_DEBT.md b/TECH_DEBT.md index bc9f6e4..1d1107a 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -64,37 +64,39 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki ## Theme 2: Duplicated infrastructure, drift already underway (11 findings) -- **[high] src/server/dot_server.zig:3 — connection-server lifecycle machinery duplicated across four listeners.** *(merged: tcp_server.zig:218, dot_server.zig:180, dot_server.zig:452 — four findings describing the same debt)* +> Status (m18): all 11 findings closed by milestone 18 (specs/milestone-18.md). + +- **[high — CLOSED m18] src/server/dot_server.zig:3 — connection-server lifecycle machinery duplicated across four listeners.** *(merged: tcp_server.zig:218, dot_server.zig:180, dot_server.zig:452 — four findings describing the same debt)* tcp_server, dot_server, doh_server, and web/server each carry a private copy of the same slot pool, claim/finish/beginShutdown protocol, accept loop with error mapping, cancel-protection dance, and select-based idle race. `race`/`expire`/`readPrefix`/`readBody`/`writeReply` are byte-identical between tcp and dot; dot_server's header says outright "This file mirrors tcp_server.zig". Drift is live: the counter is `accepted` on TCP vs `connections` on DoT/DoH; `decideClaim` is written two ways for identical semantics; doh_server ships a module-level `serve()` that nothing calls (dead divergent glue); the milestone-10 review log records a TLS-context leak fixed by hand-porting a pattern between copies — the predicted failure mode has already fired once. This is the most invariant-heavy concurrency code in the repo (mutex-ordered shutdown, cancel-protection windows), copied four times with no compiler help. The milestone-10 "mirror tcp_server's shape" ruling was a parallel-session build instruction, not an architectural decision against extraction. Fix: one shared listener core (comptime-parameterized slot pool + claim/shutdown + accept loop + race) with the per-connection serve function and TLS handshake stage as variation points; at minimum, extract race/expire/readPrefix/bump/Stop into src/server/ helpers. -- **[medium] src/storage/repositories/groups_repo.zig:29 — list/free/errdefer scaffolding hand-rolled 18 times across seven repos.** +- **[medium — CLOSED m18] src/storage/repositories/groups_repo.zig:29 — list/free/errdefer scaffolding hand-rolled 18 times across seven repos.** Every repo re-implements prepare → ArrayList → the load-bearing errdefer ordering → per-column columnTextAlloc → append, plus a matching freeX and allocation-failure test. Decisive detail from verification: the milestone-4 spec's own reference sample declares the two errdefers in the reverse (fatal, use-after-free) order — every implementation silently corrected it, and a future repo copied from the spec reproduces the UAF unless its author also copies the test. Fix: a shared comptime helper in crud.zig (`list(RowType, sql, readRow)` plus paired free) so the memory-safety choreography exists once. -- **[medium] src/web/handlers/groups.zig:168 — CRUD shell duplicated across seven handler files; the 4-line configDb switch appears 40 times.** +- **[medium — CLOSED m18] src/web/handlers/groups.zig:168 — CRUD shell duplicated across seven handler files; the 4-line configDb switch appears 40 times.** list/get/remove handlers and the applyCreate/Update/Delete shells are identical modulo repo function and message string, and the copies have already diverged in lock scope and reload flavor (local.zig holds the lock through publish; blocklists adds pruneFiles; groups alone reads back under the lock). Fix: a comptime resource descriptor (repo fns + conflict message + reload flavor) generating the shells, keeping the genuinely different per-resource decision functions hand-written. Note the four distinct reload flavors make this less mechanical than the finding implies. -- **[medium] web/src/features/local/RecordsTab.tsx:25 — RecordsTab/ZonesTab are structural copy-paste, and Tailwind class constants are re-declared across 12 files.** *(merged: RecordsTab.tsx:14, the class-constant half of the same debt)* +- **[medium — CLOSED m18] web/src/features/local/RecordsTab.tsx:25 — RecordsTab/ZonesTab are structural copy-paste, and Tailwind class constants are re-declared across 12 files.** *(merged: RecordsTab.tsx:14, the class-constant half of the same debt)* The two tabs share identical FormState/openForm/onSubmit/onDelete plumbing, mutation trio, and byte-identical class constants; the focus-visible literal appears in 21 files, and drift has already shipped: PrefixesEditor and GroupsPage inputs omit the focus-visible outline entirely, silently violating the milestone-9 accessibility floor. The project's own precedent (InlineError was hoisted and deduplicated during milestone 9) says this is house style left unapplied. Fix: shared form-shell/table-shell components and a ui/ directory (or one classes.ts module). -- **[low] src/local/forward_client.zig:260 — race-against-deadline and stream-error-unwrap scaffolding copied across three transports.** +- **[low — CLOSED m18] src/local/forward_client.zig:260 — race-against-deadline and stream-error-unwrap scaffolding copied across three transports.** The Outcome union + expire() select race is byte-identical between pool.zig and forward_client.zig (`fn expire(` appears 14 times tree-wide); the cancel-protected close helpers and mapPhase/sendFailure/receiveFailure repeat between dot_client and forward_client. The predicted drift is already real: doh_client has none of the unwrap (see Theme 3). Fix: shared helpers in transport.zig, which already owns the framing helpers and error taxonomy. -- **[low] src/local/records.zig:197 — normalizeName duplicated verbatim in forward_zones.zig, with a third partial copy in dns_cache.buildKey.** +- **[low — CLOSED m18] src/local/records.zig:197 — normalizeName duplicated verbatim in forward_zones.zig, with a third partial copy in dns_cache.buildKey.** Divergence risk is not hypothetical: filter/rules.zig and filter/compiler.zig already carry near-variants with differing byte-rejection policies. Fix: move it to dns/name.zig next to fromText (both callers already import it). -- **[low] src/filter/compiler.zig:64 — the subtle takeDelimiter/StreamTooLong/discard streaming loop exists twice (compile and manager.collectSample).** +- **[low — CLOSED m18] src/filter/compiler.zig:64 — the subtle takeDelimiter/StreamTooLong/discard streaming loop exists twice (compile and manager.collectSample).** Both copies are correct and tested, and the cited behavioral differences are intentional per call site; the risk is a std.Io.Reader semantics change or a third copy-paste. Fix: a shared bounded-line-iterator helper. (The 35f2324 linkage in the original finding was inaccurate — that bug was reader-buffer aliasing, a different class.) -- **[low] src/cli.zig:906 — upstream client construction duplicated between probeUpstreams and app.Upstreams.build.** +- **[low — CLOSED m18] src/cli.zig:906 — upstream client construction duplicated between probeUpstreams and app.Upstreams.build.** Concrete drift hazard: cli.zig hardcodes the DoH buffer sizes as literals that app.zig defines as named constants — changing the constants leaves `check` probing different buffers than `run` uses, undermining the probe's stated purpose. Fix: a shared build-one-client-per-entry helper, or at minimum shared size constants. -- **[low] src/cli.zig:722 — ZON parse failure rendered through two channels in `check` vs `import`.** +- **[low — CLOSED m18] src/cli.zig:722 — ZON parse failure rendered through two channels in `check` vs `import`.** check prints the multi-line zon_diag inline (embedding newlines mid-FAIL-line, contradicting the one-line-per-problem promise in docs/reference/configuration.md:332), import routes through reportParseFailure/Diagnostics. Fix: expose reportParseFailure (it is currently private) and route check through it. -- **[low] build.zig:155 — test-suite module wiring duplicated block-for-block for host and aarch64, plus a re-spelled target triple.** +- **[low — CLOSED m18] build.zig:155 — test-suite module wiring duplicated block-for-block for host and aarch64, plus a re-spelled target triple.** Divergence is caught loudly (compile/link errors, blocking CI qemu job), just late. Fix: an addTestSuite helper mirroring addExecutable, triple from cross_targets. -- **[low] tests/fuzz/blocklist_fuzz.zig:160 — Smith corpus encoders (u32-LE length-prefix convention) copy-pasted between the two fuzz files.** +- **[low — CLOSED m18] tests/fuzz/blocklist_fuzz.zig:160 — Smith corpus encoders (u32-LE length-prefix convention) copy-pasted between the two fuzz files.** A Smith stream-format change caught in one file and missed in the other leaves that corpus silently decoding to garbage while targets stay green. Fix: a shared dependency-free tests/fuzz/smith_encode.zig — no build.zig change needed since dns_fuzz.zig already imports corpus.zig by relative path. ## Theme 3: Silent failures and swallowed errors (11 findings) @@ -111,7 +113,7 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki - **[medium — CLOSED m16] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.** The refocus-after-30s refetch shifts the newest-100 boundary up while `extra` starts strictly below the old cursor; the missing rows are in neither, and the stale cursorOverride means load-more never heals it. On a live DNS server the trigger is routine. Fix: useInfiniteQuery (or disable background refetch while extra is non-empty), or detect the discontinuity and reset the accumulation. -- **[low] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.** +- **[low — CLOSED m18] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.** Verification narrowed the blast radius: the pool's select harness means shutdown cancellation is handled correctly despite this, so the reachable impact is rare mid-exchange local-resource errors (e.g. ENOBUFS) recorded as peer faults against a healthy upstream — still a stated spec-invariant violation, fixed once for DoT and left in DoH. Fix: the same unwrap the other two transports carry. - **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.** diff --git a/build.zig b/build.zig index ca5a51e..251803c 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,16 @@ const cross_targets = [_][]const u8{ "aarch64-linux-musl", }; +/// The deploy target, read out of `cross_targets` so `test-aarch64` and `cross` +/// cannot describe different machines. Reordering the array is caught here +/// rather than by a qemu job that quietly ran the wrong architecture. +const aarch64_triple = cross_targets[1]; +comptime { + if (!std.mem.startsWith(u8, aarch64_triple, "aarch64-")) { + @compileError("cross_targets[1] must be the aarch64 triple, found " ++ aarch64_triple); + } +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -109,31 +119,7 @@ pub fn build(b: *std.Build) void { // // No upstream issue matched a search of ziglang/zig for this behaviour; // the reference is the 0.16.0 source lines above. See AGENTS.md. - const tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/tests.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }), - }); - tests.root_module.addOptions("build_options", options); - tests.root_module.linkLibrary(sqliteLibrary(b, target, optimize)); - tests.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize)); - tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") }); - addMbedtlsThreadingMacros(tests.root_module); - tests.root_module.addAnonymousImport("test_fixtures", .{ - .root_source_file = b.path("tests/fixtures/fixtures.zig"), - }); - tests.root_module.addAnonymousImport("docs_files", .{ - .root_source_file = b.path("docs/docs.zig"), - }); - // An anonymous-import root must be Zig, so the committed TypeScript golden - // is reached through a one-decl wrapper beside it. - tests.root_module.addAnonymousImport("contract_samples", .{ - .root_source_file = b.path("web/src/lib/contract_samples.zig"), - }); - tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); + const tests = addTestSuite(b, target, optimize, options, web_assets); const test_step = b.step("test", "Run the test suite"); test_step.dependOn(&b.addRunArtifact(tests).step); @@ -142,61 +128,28 @@ pub fn build(b: *std.Build) void { // the in-file tests). `-Dfuzz` opts into the LLVM backend, which `--fuzz` // needs for sanitizer coverage; stock 0.16.0 also requires a patched // test_runner.zig for fuzz mode — see specs/milestone-2.md. - const dns_mod = b.createModule(.{ - .root_source_file = b.path("src/dns/dns.zig"), - .target = target, - .optimize = optimize, - }); - const fuzz_mod = b.createModule(.{ - .root_source_file = b.path("tests/fuzz/dns_fuzz.zig"), - .target = target, - .optimize = optimize, - }); - fuzz_mod.addImport("dns", dns_mod); - const fuzz_tests = b.addTest(.{ + addFuzzSuite(b, target, optimize, fuzz, test_step, .{ .name = "fuzz", - .use_llvm = if (fuzz) true else null, - .root_module = fuzz_mod, + .root = "tests/fuzz/dns_fuzz.zig", + .import_name = "dns", + .import_module = sourceModule(b, target, optimize, "src/dns/dns.zig"), }); - test_step.dependOn(&b.addRunArtifact(fuzz_tests).step); - const parsers_mod = b.createModule(.{ - .root_source_file = b.path("src/filter/parsers.zig"), - .target = target, - .optimize = optimize, - }); - const blocklist_fuzz_mod = b.createModule(.{ - .root_source_file = b.path("tests/fuzz/blocklist_fuzz.zig"), - .target = target, - .optimize = optimize, - }); - blocklist_fuzz_mod.addImport("parsers", parsers_mod); - const blocklist_fuzz_tests = b.addTest(.{ + addFuzzSuite(b, target, optimize, fuzz, test_step, .{ .name = "blocklist-fuzz", - .use_llvm = if (fuzz) true else null, - .root_module = blocklist_fuzz_mod, + .root = "tests/fuzz/blocklist_fuzz.zig", + .import_name = "parsers", + .import_module = sourceModule(b, target, optimize, "src/filter/parsers.zig"), }); - test_step.dependOn(&b.addRunArtifact(blocklist_fuzz_tests).step); // `src/web/http_util.zig` imports only std, so its fuzz module roots // directly at the file — no aggregator needed (milestone-15 ruling 6c). - const http_util_mod = b.createModule(.{ - .root_source_file = b.path("src/web/http_util.zig"), - .target = target, - .optimize = optimize, - }); - const http_util_fuzz_mod = b.createModule(.{ - .root_source_file = b.path("tests/fuzz/http_util_fuzz.zig"), - .target = target, - .optimize = optimize, - }); - http_util_fuzz_mod.addImport("http_util", http_util_mod); - const http_util_fuzz_tests = b.addTest(.{ + addFuzzSuite(b, target, optimize, fuzz, test_step, .{ .name = "http-util-fuzz", - .use_llvm = if (fuzz) true else null, - .root_module = http_util_fuzz_mod, + .root = "tests/fuzz/http_util_fuzz.zig", + .import_name = "http_util", + .import_module = sourceModule(b, target, optimize, "src/web/http_util.zig"), }); - test_step.dependOn(&b.addRunArtifact(http_util_fuzz_tests).step); // The bench harness (milestone-12 ruling 1). The measured roots // (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative @@ -226,18 +179,12 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); - const compiler_fuzz_mod = b.createModule(.{ - .root_source_file = b.path("tests/fuzz/compiler_fuzz.zig"), - .target = target, - .optimize = optimize, - }); - compiler_fuzz_mod.addImport("core", bench_core_mod); - const compiler_fuzz_tests = b.addTest(.{ + addFuzzSuite(b, target, optimize, fuzz, test_step, .{ .name = "compiler-fuzz", - .use_llvm = if (fuzz) true else null, - .root_module = compiler_fuzz_mod, + .root = "tests/fuzz/compiler_fuzz.zig", + .import_name = "core", + .import_module = bench_core_mod, }); - test_step.dependOn(&b.addRunArtifact(compiler_fuzz_tests).step); const bench_mod = b.createModule(.{ .root_source_file = b.path("tools/bench.zig"), @@ -256,32 +203,10 @@ pub fn build(b: *std.Build) void { // -Dintegration stays out (ruling 7): qemu-user's slowdown makes the // wall-clock-budgeted loopback TLS tests a flake source. const aarch64_target = b.resolveTargetQuery( - std.Target.Query.parse(.{ .arch_os_abi = "aarch64-linux-musl" }) catch unreachable, + std.Target.Query.parse(.{ .arch_os_abi = aarch64_triple }) catch unreachable, ); - const aarch64_tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/tests.zig"), - .target = aarch64_target, - .optimize = optimize, - .link_libc = true, - }), - }); + const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets); aarch64_tests.linkage = .static; - aarch64_tests.root_module.addOptions("build_options", options); - aarch64_tests.root_module.linkLibrary(sqliteLibrary(b, aarch64_target, optimize)); - aarch64_tests.root_module.linkLibrary(mbedtlsLibrary(b, aarch64_target, optimize)); - aarch64_tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") }); - addMbedtlsThreadingMacros(aarch64_tests.root_module); - aarch64_tests.root_module.addAnonymousImport("test_fixtures", .{ - .root_source_file = b.path("tests/fixtures/fixtures.zig"), - }); - aarch64_tests.root_module.addAnonymousImport("docs_files", .{ - .root_source_file = b.path("docs/docs.zig"), - }); - aarch64_tests.root_module.addAnonymousImport("contract_samples", .{ - .root_source_file = b.path("web/src/lib/contract_samples.zig"), - }); - aarch64_tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); const aarch64_run = b.addRunArtifact(aarch64_tests); aarch64_run.skip_foreign_checks = true; b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)") @@ -348,6 +273,91 @@ fn checkTestImports(b: *std.Build) void { } } +/// The `src/tests.zig` suite, wired for one target. The host build and the +/// aarch64 build take the same artifact; their only differences (`linkage` and +/// `skip_foreign_checks`) stay at the call sites. +fn addTestSuite( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options: *std.Build.Step.Options, + web_assets: std.Build.LazyPath, +) *std.Build.Step.Compile { + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + tests.root_module.addOptions("build_options", options); + tests.root_module.linkLibrary(sqliteLibrary(b, target, optimize)); + tests.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize)); + tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") }); + addMbedtlsThreadingMacros(tests.root_module); + tests.root_module.addAnonymousImport("test_fixtures", .{ + .root_source_file = b.path("tests/fixtures/fixtures.zig"), + }); + tests.root_module.addAnonymousImport("docs_files", .{ + .root_source_file = b.path("docs/docs.zig"), + }); + // An anonymous-import root must be Zig, so the committed TypeScript golden + // is reached through a one-decl wrapper beside it. + tests.root_module.addAnonymousImport("contract_samples", .{ + .root_source_file = b.path("web/src/lib/contract_samples.zig"), + }); + tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); + return tests; +} + +/// One fuzz test artifact: a module rooted at a `tests/fuzz/` file plus the one +/// named import through which that target reaches the code under test. The fuzz +/// suites cannot share `addTestSuite` — they take no `build_options`, no +/// anonymous imports and no C libraries, and they carry `use_llvm`, which the +/// main suite must not set. +fn addFuzzSuite( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + fuzz: bool, + test_step: *std.Build.Step, + spec: struct { + name: []const u8, + root: []const u8, + import_name: []const u8, + import_module: *std.Build.Module, + }, +) void { + const mod = b.createModule(.{ + .root_source_file = b.path(spec.root), + .target = target, + .optimize = optimize, + }); + mod.addImport(spec.import_name, spec.import_module); + const tests = b.addTest(.{ + .name = spec.name, + .use_llvm = if (fuzz) true else null, + .root_module = mod, + }); + test_step.dependOn(&b.addRunArtifact(tests).step); +} + +/// A plain module over one source file, with no imports of its own: what a fuzz +/// target reaches its code under test through. +fn sourceModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + path: []const u8, +) *std.Build.Module { + return b.createModule(.{ + .root_source_file = b.path(path), + .target = target, + .optimize = optimize, + }); +} + fn addExecutable( b: *std.Build, target: std.Build.ResolvedTarget, diff --git a/specs/milestone-10.md b/specs/milestone-10.md index d1b6fe9..f431415 100644 --- a/specs/milestone-10.md +++ b/specs/milestone-10.md @@ -286,7 +286,10 @@ store-deinit and the refs==0 assert holds. `dot_alpn` (["dot"]) lives in app.zig Deviation: `doh_server.serve` module entry is NOT used — it constructs the DohServer in its own task frame, so WebState could never get the pointer the `nxdns_doh_server_*` family needs; both listeners bind in app.zig's frame instead (tcp_server style). -`doh_server.serve` remains as unused pub API. metrics.zig: `DohListenerSample` +`doh_server.serve` remains as unused pub API. **Superseded by milestone-18 ruling 1: the +unused `doh_server.serve` is deleted. Nothing ever called it, and a dead second +composition path over the listener core is exactly the divergence that milestone +collapsed. app.zig's in-frame bind is now the only way DoH comes up.** metrics.zig: `DohListenerSample` (ruling-10 four + `bad_requests`); DoT renders `dot_server.StatsSnapshot` directly; accept-side counters stay off the exposition; unwired listeners omit the families. WebState gains `doh_listener`/`dot_listener` optional pointers. build.zig (out of diff --git a/specs/milestone-18.md b/specs/milestone-18.md index f889631..c2c4f47 100644 --- a/specs/milestone-18.md +++ b/specs/milestone-18.md @@ -406,6 +406,136 @@ dead `doh_server.serve`, the two `normalizeName` copies and their shared InlineError. `npm run test`, `typecheck`, `lint` green. - [ ] Full suite green: `zig build test -Dintegration`. +## Recorded (implementation) + +Accepted deviations and findings from the built milestone. Each was +reviewed and accepted at integration; the rulings above stand except as +recorded here. + +### Ruling 1 (S1) + +- `Cfg` supplies more than the four listed members: `Owner`, + `ConnPayload`, `serveConn`, `read_buffer_len`, `write_buffer_len`, + plus `log` (the owner's `std.log` scope) and `name`, because the + accept loop and shutdown log and the text had to stay byte-identical. + Two optional decls: `refuse` (absent means close the stream; the web + listener supplies its 503) and `initPayload`/`deinitPayload`, which + exist only for the web arena's create-in-listen / destroy-in-deinit + lifecycle. +- The read/write staging buffers live in the core's `Conn`, not in + `ConnPayload` — all four listeners have exactly one of each and + differ only in size. The web listener's `recv_buf`/`send_buf` are now + `read_buf`/`write_buf`. +- Stats layout: core counters live at `server.core.stats.*` + (`listener.CoreStats`); listener-specific counters stay on the owner + at `server.stats.*`. `tcp_server.Stats` is an alias of `CoreStats`. + Exported snapshots stay flat, so /metrics output is unchanged except + the tcp rename. `idle_timeouts` sits in `CoreStats` per the ruling, + which gives the web listener a counter it never bumps; it exports no + family, so nothing is visible. +- The docs-reference update for the rename has no target: + `nxdns_tcp_server_accepted_total` appears in no file under docs/ or + web/. Only the metrics name test changed. +- 18 duplicated unit tests were deleted (6 tcp, 6 dot, 3 doh, 3 web) + and replaced by 6 shared claim-rule tests in `listener.zig`. + `web/server_integration_test.zig`'s `withServer` now returns a local + plain-`u64` `Counters` struct instead of `server.Stats`. +- File-ownership breach: S1 edited two files owned by other sessions, + both mechanical fallout of the sanctioned `deinit` shape change — + three call sites in `src/app.zig` (S5) and one line in + `src/web/web_integration_test.zig` (S3). Both owners reviewed and + kept the edits. S1 also added the required `src/tests.zig` import + line for the new file. + +### Ruling 2 (S2) + +- `listRowsBound(comptime Row, database, gpa, comptime sql, args: + anytype, comptime readRow)` exists beside `listRows` for the bound + queries; `freeRow` exists beside `freeRows`. Unknown owning field + shapes are a `@compileError` as ruled. The milestone-4 sample + correction was made by S2, not the orchestrator. + +### Ruling 3 (S3) + +- `plural` is a separate descriptor member: the "listing X" log context + differs from the JSON envelope key for three resources + (`local_records`, `client_prefixes`, `forward_zones`), so deriving one + from the other would change three log strings. +- `view` is an optional descriptor member: rules and local records map + rows through `RuleView`/`RecordView`; without it neither could adopt + without changing its response body. When absent, the row serializes + as-is with no copy. +- `remove` has two comptime-detected arities: three handlers' decision + functions read rows and take an `Allocator` before the id, three do + not. The generator validates the full signature of whichever shape it + finds. + +### Ruling 4 (S4) + +- The ruling's line numbers were stale after m17 (pool.zig's race sites + were at 184-206 and 306-335). +- `closeBlocked` branches at comptime on the close method's parameter + count: `tls_client.TlsStream.close()` takes no `Io` (it owns the one + it was built with), while the other three closes take `io`. +- "The stashed-cause accessor" is three accessors, one per collapse + point: the send phase reads `req.connection.?.stream_writer.err`, + `receiveHead` reads `Connection.getReadError()`, and the body read + consults `Response.bodyErr()` first (HTTP framing faults) then the + connection. +- `Connection.getReadError` can panic (it reads `stream_reader.err.?`). + The unwrap guards the plain-connection no-cause case; the TLS case + relies on std's documented contract that a cause exists after + `error.ReadFailed`. +- `raceWithin` requires the raced function's return type to be exactly + `ExchangeError!T` at comptime. Stricter than the ruling asked; it is + what keeps every failure path inside the race group. Consequence: + `manager.zig`'s `fetchWithin` (optional per the ruling) cannot adopt + it as written — its raced function has a different error set. +- The acceptance line "`fn expire(` production copies are gone" is + scoped to ruling 4's transport files. `filter/manager.zig`, + `storage/logger.zig` and `server/listener.zig` keep their own — none + is transport code. + +### Rulings 5, 6, 7 (S5, S4) + +- `nextBoundedLine` returns `.long_line` from the + EndOfStream-during-discard arm, and the next call returns `null`. The + two originals disagreed there (`compile` counted then broke; + `collectSample` returned without counting); this shape preserves both + behaviors — `compile` counts exactly as before, `collectSample` + ignores the event and sees `null`. Safe because + `discardDelimiterInclusive` drains the stream before reporting + `EndOfStream`. +- The length check runs on the raw line for both callers, as the ruling + placed it. One input changes classification: a line of exactly + `max_line_len + 1` bytes ending in `\r` was compiled before and now + counts as `long_lines`. Accepted as the intended reading. +- `check`'s per-message `FAIL` lines render import's path constant + (`FAIL config: `), not the file name. `checkImpl` + prints the file path immediately above and `check` examines one + source per run, and this keeps `check` and `import` rendering the + same failure identically — the acceptance criterion. + +### Rulings 8, 9 (S6) + +- The fuzz-suite wiring did not fold into `addTestSuite` (the ruling's + own fallback): it lives in a separate `addFuzzSuite` helper, with a + shared `sourceModule` helper. The aarch64 target resolves from + `cross_targets[1]` behind a comptime prefix guard. +- `pairInput` moved into `smith_encode.zig` rather than staying in + blocklist_fuzz: m15's fuzz files had made it a two-copy duplicate, + which is the condition ruling 9 exists to remove. + +### Ruling 10 (S7) + +- `ui/classes.ts` exports 17 constants, not the 8 listed — the extra + ones are the focus-ring fragment and compositions the 15 adopting + files needed to drop their literals without re-spelling anything. +- Seven ring-less focusable controls were fixed by adoption, not the + two the ruling named. The acceptance grep ("every input/button/select + carries the focus-visible fragment") was taken as the invariant over + the anti-requirement's count of two, which undercounted. + ## Anti-requirements - No behavioral changes: this milestone moves code. The only sanctioned diff --git a/specs/milestone-4.md b/specs/milestone-4.md index 527262a..3b94eaa 100644 --- a/specs/milestone-4.md +++ b/specs/milestone-4.md @@ -1258,18 +1258,27 @@ predicate in S5 needs the true count. SQLite and is invalidated by the next `step`; a repository that returns a borrowed slice is a use-after-free waiting for the second row. - Every `list` builds into a `std.ArrayList(T)` with an `errdefer` that frees **both** every element - already appended and every string of the partially-built element: + already appended and every string of the partially-built element. The two list-level `errdefer`s + run in reverse declaration order, so the free pass is declared **after** `out.deinit` to run + **before** it — the other order reads `out.items` once the backing array is already released: ```zig var out: std.ArrayList(model.Group) = .empty; - errdefer freeGroups(gpa, out.items); errdefer out.deinit(gpa); + errdefer freeGroups(gpa, out.items); while (try stmt.step()) { const name = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(name); try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) }); } ``` + + > **Correction (milestone 18, ruling 2).** This sample originally declared the two `errdefer`s in + > the opposite order, which frees the backing array before the pass that walks it — a + > use-after-free. Every repository written from it silently corrected the order; the sample above + > is the corrected one. Milestone 18 moved the whole choreography into + > `src/storage/repositories/crud.zig` as `listRows`/`freeRows`, so a new repository delegates to + > that helper instead of copying this shape. - `freeX` frees every heap string in every element and is idempotent against an empty slice. - The callers of `list` may pass an arena; `freeX` must still be correct against a general-purpose allocator, because the tests use `std.testing.allocator`. diff --git a/src/app.zig b/src/app.zig index 0e23735..94012e4 100644 --- a/src/app.zig +++ b/src/app.zig @@ -84,10 +84,10 @@ const maintenance_interval_s = 60; /// takes longer than this is not going to finish at all. const download_budget_s = 300; -/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and -/// keeps a maximum-length name with a large OPT record comfortable. -const doh_request_buf_len = 1024; -const doh_transfer_buf_len = 4096; +/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check` +/// probes the buffers `nxdns run` serves with. +const doh_request_buf_len = doh_client.default_request_buf_len; +const doh_transfer_buf_len = doh_client.default_transfer_buf_len; pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { const code = serve(runner, args) catch |err| code: { @@ -451,7 +451,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { // A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS // failing to come up must not stop the plain-DNS side this box exists for. var doh: ?doh_server.DohServer = null; - defer if (doh) |*server| server.deinit(gpa, io); + defer if (doh) |*server| server.deinit(io); if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store); var dot: ?dot_server.DotServer = null; @@ -526,7 +526,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err); break :bound null; }; - defer if (tcp6) |*s| s.deinit(gpa, io); + defer if (tcp6) |*s| s.deinit(io); var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: { if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) { @@ -535,7 +535,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{}); break :bound null; }; - defer if (tcp4) |*s| s.deinit(gpa, io); + defer if (tcp4) |*s| s.deinit(io); // Ruling 13: `/metrics` sums each transport's listeners into one family, so // the web state carries pointers to whichever of the four came up. The diff --git a/src/cache/dns_cache.zig b/src/cache/dns_cache.zig index 156abeb..ddbfdfd 100644 --- a/src/cache/dns_cache.zig +++ b/src/cache/dns_cache.zig @@ -45,6 +45,10 @@ pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len; /// /// The length bounds are assertions, not errors: both values reach here from /// the packet parser, which has already rejected anything longer. +/// +/// The lowercase-and-strip-the-dot below is deliberately not +/// `dns.name.normalizeText`: that one validates, and this input is already +/// asserted valid, so a key build must never fail. pub fn buildKey( buf: *[max_key_len]u8, qname: []const u8, diff --git a/src/cli.zig b/src/cli.zig index f6e25c5..c3ae6aa 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -757,9 +757,15 @@ fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 { const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, // The rendering carries the line and column, which is the whole value of - // running `check` against a file the operator just edited. + // running `check` against a file the operator just edited. It is + // multi-line, and `check` promises one line per problem, so it goes + // through the same `Diagnostics` channel `nxdns import` uses rather than + // into one `FAIL` record with newlines inside it. error.ParseZon => { - try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag }); + var diags: validate.Diagnostics = .init(r.gpa); + defer diags.deinit(); + try import.reportParseFailure(&diags, &zon_diag); + try diags.writeAll(r.out); return exit_check; }, }; @@ -917,8 +923,10 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize { const tls_buffers = try r.gpa.alloc(u8, 4 * chunk); defer r.gpa.free(tls_buffers); - var request_buf: [1024]u8 = undefined; - var transfer_buf: [4096]u8 = undefined; + // The same sizes `nxdns run` serves with, so the probe reports on the + // buffers the server will actually use. + var request_buf: [doh_client.default_request_buf_len]u8 = undefined; + var transfer_buf: [doh_client.default_transfer_buf_len]u8 = undefined; const response_buf = try r.gpa.alloc(u8, transport.max_message_len); defer r.gpa.free(response_buf); @@ -1461,6 +1469,43 @@ test "check --config naming a missing file is a reported failure at exit 2" { try testing.expectEqualStrings("", captured.err.written()); } +test "check renders a multi-line ZON failure as one FAIL line per message" { + // The rendering used to go inline into a single `FAIL` record, which put + // newlines mid-line and broke the one-line-per-problem promise the rest of + // `check` keeps. + var env: CheckEnv = undefined; + try env.init(); + defer env.deinit(); + + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + const r = captured.runner(); + + // An unexpected field renders as an "error:" line and a "note:" line. + try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data = ".{ .grops = .{} }\n" }); + var path_buf: [160]u8 = undefined; + const config_path = try env.path(&path_buf, "config.zon"); + + const code = runCheck(r, .{ + .paths = .{ .config = config_path }, + .config_explicit = true, + }, false); + try testing.expectEqual(exit_check, code); + + const text = captured.out.written(); + var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n'); + // The first line names what was checked; every line after it is a problem. + try testing.expect(std.mem.startsWith(u8, lines.next().?, "checking configuration file ")); + var failures: usize = 0; + while (lines.next()) |line| { + try testing.expect(std.mem.startsWith(u8, line, "FAIL config: ")); + failures += 1; + } + try testing.expectEqual(@as(usize, 2), failures); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "error: unexpected field 'grops'")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "note: supported: ")); +} + test "check reads config.db without writing to it" { // D6: the database branch opened read/write, chmod'ed 0600, turned WAL on — // which is what creates the two sidecars — and committed migration steps, diff --git a/src/config/import.zig b/src/config/import.zig index 5c47c0c..3545b9b 100644 --- a/src/config/import.zig +++ b/src/config/import.zig @@ -164,7 +164,11 @@ pub fn importSource( /// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per /// problem, plus a "note:" line each, so each rendered line becomes one /// `Problem` and the list keeps the parser's order. -fn reportParseFailure( +/// +/// `pub` because `nxdns check` parses the same file and owes the operator the +/// same one-line-per-problem output; rendering the ZON diagnostics inline would +/// put newlines inside a single `FAIL` record. +pub fn reportParseFailure( diags: *validate.Diagnostics, zon_diag: *const std.zon.parse.Diagnostics, ) error{OutOfMemory}!void { @@ -910,6 +914,31 @@ test "importSource reports a ZON syntax error and writes nothing" { try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null); } +test "importSource splits a multi-line ZON failure into one problem per message" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + // An unexpected field renders as an "error:" line plus a "note:" line, so + // the rendering the CLI prints has to be split rather than embedded whole. + try testing.expectError( + error.ParseZon, + importSource(io, testing.allocator, &database, ".{ .grops = .{} }", .{}, &diags), + ); + + try testing.expectEqual(@as(usize, 2), diags.problems.items.len); + for (diags.problems.items) |problem| { + try testing.expectEqual(@as(?usize, null), std.mem.findScalar(u8, problem.message, '\n')); + } + try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "error: ") != null); + try testing.expect(std.mem.indexOf(u8, diags.problems.items[1].message, "note: ") != null); +} + test "a config omitting every optional field parses into an arena and leaks nothing" { // The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena // is the only release, and `std.testing.allocator` fails the test if a diff --git a/src/dns/name.zig b/src/dns/name.zig index 3e370b0..b7f032d 100644 --- a/src/dns/name.zig +++ b/src/dns/name.zig @@ -134,6 +134,34 @@ pub fn fromText(text: []const u8) FromTextError!Name { return name; } +pub const NormalizeError = error{BadName}; + +/// Lowercases over ASCII into `buf`, strips one trailing dot, and checks the +/// result is a name `fromText` accepts. Returns the normalized text, borrowed +/// from `buf`. +/// +/// A byte ≥ 0x80 is rejected: query names arrive ASCII-lowercased, so a high +/// byte here could never be matched, and configuration that can never match is +/// worth reporting rather than storing. The root name is rejected for the same +/// reason — a configured entry that matches everything, or nothing, is not what +/// either caller means. +/// +/// `filter/rules.zig`, `filter/compiler.zig` and `cache/dns_cache.zig` keep +/// their own variants on purpose; each says why beside its copy. +pub fn normalizeText(text: []const u8, buf: *[types.max_name_len]u8) NormalizeError![]const u8 { + var rest = text; + if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; + if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName; + + for (rest, 0..) |byte, i| { + if (byte >= 0x80) return error.BadName; + buf[i] = std.ascii.toLower(byte); + } + const normalized = buf[0..rest.len]; + _ = fromText(normalized) catch return error.BadName; + return normalized; +} + /// Writes presentation form: labels joined by dots, no trailing dot. The root /// name writes as ".". pub fn formatText(name: Name, w: *Writer) Writer.Error!void { @@ -392,3 +420,22 @@ test "labelCount" { try testing.expectEqual(@as(usize, 1), (try fromText("com")).labelCount()); try testing.expectEqual(@as(usize, 3), (try fromText("www.example.com")).labelCount()); } + +test "normalizeText lowercases and strips one trailing dot" { + var buf: [types.max_name_len]u8 = undefined; + try testing.expectEqualStrings("example.com", try normalizeText("Example.COM", &buf)); + try testing.expectEqualStrings("example.com", try normalizeText("example.com.", &buf)); + try testing.expectEqualStrings("a", try normalizeText("A", &buf)); +} + +test "normalizeText rejects the root, high bytes and names fromText refuses" { + var buf: [types.max_name_len]u8 = undefined; + try testing.expectError(error.BadName, normalizeText("", &buf)); + try testing.expectError(error.BadName, normalizeText(".", &buf)); + try testing.expectError(error.BadName, normalizeText("caf\xc3\xa9.example.com", &buf)); + try testing.expectError(error.BadName, normalizeText("a..b", &buf)); + try testing.expectError(error.BadName, normalizeText("a." ** 200 ++ "com", &buf)); + + // A label of 64 bytes is one over the wire limit. + try testing.expectError(error.BadName, normalizeText("a" ** 64 ++ ".com", &buf)); +} diff --git a/src/filter/compiler.zig b/src/filter/compiler.zig index 070cc38..e1fd910 100644 --- a/src/filter/compiler.zig +++ b/src/filter/compiler.zig @@ -61,30 +61,17 @@ pub fn compile( var wild: Entries = .{}; defer wild.deinit(gpa); - while (true) { - const raw = r.takeDelimiter('\n') catch |err| switch (err) { - error.ReadFailed => return error.ReadFailed, - // `takeDelimiter` leaves the stream unmodified on `StreamTooLong` - // (Reader.zig:885). Without this discard the loop re-reads the same - // bytes forever. - error.StreamTooLong => { + while (try parsers.nextBoundedLine(r, max_line_len)) |event| { + const raw = switch (event) { + .long_line => { counts.long_lines += 1; - _ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { - error.EndOfStream => break, - error.ReadFailed => return error.ReadFailed, - }; continue; }, - } orelse break; + .line => |line| line, + }; var line = raw; if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - // A reader whose buffer is larger than `max_line_len` reports the - // over-long line here instead of through `error.StreamTooLong`. - if (line.len > max_line_len) { - counts.long_lines += 1; - continue; - } const parsed = parsers.parseLine(format, line); switch (parsed.kind) { @@ -120,6 +107,10 @@ pub fn compile( /// Normalizes one whitespace-separated candidate and files it under `.list`, /// `.wild`, or neither. +/// +/// The normalization below is deliberately not `dns.name.normalizeText`: this +/// one adds the two-label minimum, rejects control bytes, and reports every +/// rejection through `counts.invalid` rather than an error. fn addCandidate( gpa: std.mem.Allocator, field: []const u8, diff --git a/src/filter/manager.zig b/src/filter/manager.zig index 5f972a5..7cc429b 100644 --- a/src/filter/manager.zig +++ b/src/filter/manager.zig @@ -1530,20 +1530,12 @@ fn destroySnapshot(gpa: Allocator, snapshot: *matcher.Snapshot) void { fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteFailed }!void { var considered: usize = 0; while (considered < parsers.sample_lines) { - const raw = r.takeDelimiter('\n') catch |err| switch (err) { - // The stream is left unmodified here, so the line has to be stepped - // over or this loop never advances. - error.StreamTooLong => { - _ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { - error.EndOfStream => return, - error.ReadFailed => return error.ReadFailed, - }; - continue; - }, - error.ReadFailed => return error.ReadFailed, - } orelse return; + const event = (try parsers.nextBoundedLine(r, compiler.max_line_len)) orelse return; + const raw = switch (event) { + .long_line => continue, + .line => |line| line, + }; - if (raw.len > compiler.max_line_len) continue; const line = std.mem.trim(u8, raw, &std.ascii.whitespace); if (line.len == 0) continue; if (parsers.isComment(line)) continue; diff --git a/src/filter/parsers.zig b/src/filter/parsers.zig index f2b1470..8ea1958 100644 --- a/src/filter/parsers.zig +++ b/src/filter/parsers.zig @@ -48,6 +48,50 @@ pub fn parseLine(format: Format, line: []const u8) Line { }; } +pub const LineEvent = union(enum) { + /// One line without its delimiter, borrowed from the reader's buffer and + /// valid only until the next call. A trailing '\r' is left on: whether it + /// belongs to the line is the caller's decision. + line: []const u8, + /// A line longer than `max_len`. It has already been stepped over. + long_line, +}; + +/// One line, or `null` at end of stream. `max_len` bounds a line; anything +/// longer comes back as `.long_line` with the stream positioned on the line +/// after it, so a caller that keeps calling always advances. +/// +/// The bound is a parameter because this file may not import `compiler.zig`: +/// the compiler imports this one, and this file is the root of a separate fuzz +/// module. Both callers pass `compiler.max_line_len`. +/// +/// The two over-long paths exist because a `Reader` reports an over-long line +/// two different ways. A reader whose buffer is smaller than `max_len` reports +/// `error.StreamTooLong` and — this is the hazard — leaves the stream +/// unmodified (Reader.zig:895-919), so without the discard a caller re-reads +/// the same bytes forever. A reader whose buffer is larger hands the whole line +/// over and the length check catches it. +/// +/// An over-long final line with no delimiter ends the stream inside the +/// discard. That still counts as a line, so it comes back as `.long_line`; the +/// discard drained the stream (Reader.zig:1042), so the next call returns +/// `null`. +pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent { + const raw = r.takeDelimiter('\n') catch |err| switch (err) { + error.ReadFailed => return error.ReadFailed, + error.StreamTooLong => { + _ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { + error.EndOfStream => return .long_line, + error.ReadFailed => return error.ReadFailed, + }; + return .long_line; + }, + } orelse return null; + + if (raw.len > max_len) return .long_line; + return .{ .line = raw }; +} + pub const sample_lines = 64; /// Picks a format from the first `sample_lines` lines that are not blank and @@ -309,6 +353,58 @@ test "parseLine dispatches to the abp parser" { try testing.expect(line.covers_apex); } +fn expectLine(expected: []const u8, event: ?LineEvent) !void { + const got = event orelse return error.TestExpectedLine; + switch (got) { + .line => |line| try testing.expectEqualStrings(expected, line), + .long_line => return error.TestExpectedLine, + } +} + +test "nextBoundedLine walks lines and ends at the stream" { + var r: std.Io.Reader = .fixed("a\nbb\n\nccc"); + try expectLine("a", try nextBoundedLine(&r, 16)); + try expectLine("bb", try nextBoundedLine(&r, 16)); + try expectLine("", try nextBoundedLine(&r, 16)); + // A final line with no delimiter is still a line. + try expectLine("ccc", try nextBoundedLine(&r, 16)); + try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 16)); +} + +test "nextBoundedLine reports an over-long line when the reader buffer is large" { + var r: std.Io.Reader = .fixed("a\nxxxxxxxx\nb\n"); + try expectLine("a", try nextBoundedLine(&r, 4)); + try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(&r, 4)).?); + try expectLine("b", try nextBoundedLine(&r, 4)); + try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 4)); +} + +test "nextBoundedLine steps over a line that does not fit the reader buffer" { + // A 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("a\n" ++ "x" ** 64 ++ "\nb\n"); + var buf: [16]u8 = undefined; + var limited = backing.limited(.unlimited, &buf); + const r = &limited.interface; + + try expectLine("a", try nextBoundedLine(r, 16)); + try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?); + try expectLine("b", try nextBoundedLine(r, 16)); + try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16)); +} + +test "nextBoundedLine reports an over-long final line that ends inside the discard" { + var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64); + var buf: [16]u8 = undefined; + var limited = backing.limited(.unlimited, &buf); + const r = &limited.interface; + + try expectLine("a", try nextBoundedLine(r, 16)); + try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?); + try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16)); +} + test "looksLikeIpLiteral separates addresses from names" { try testing.expect(looksLikeIpLiteral("0.0.0.0")); try testing.expect(looksLikeIpLiteral("127.0.0.1")); diff --git a/src/filter/rules.zig b/src/filter/rules.zig index 9bec2a8..8b29a54 100644 --- a/src/filter/rules.zig +++ b/src/filter/rules.zig @@ -192,6 +192,10 @@ const NameError = error{BadName}; /// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is /// rejected: query names reach the matcher ASCII-lowercased, so a pattern /// carrying a high byte could never match anything. +/// +/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which +/// `name.fromText` would reject, so this variant skips that check and rejects +/// control bytes and space instead. fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 { var rest = text; if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; diff --git a/src/local/forward_client.zig b/src/local/forward_client.zig index 4eb6864..552184d 100644 --- a/src/local/forward_client.zig +++ b/src/local/forward_client.zig @@ -135,13 +135,13 @@ pub const ForwardClient = struct { const socket = local.bind(io, .{ .mode = .dgram }) catch |err| { log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)}); - return mapPhase(err, error.ConnectFailed); + return transport.mapPhase(err, error.ConnectFailed); }; - defer closeSocket(io, &socket); + defer transport.closeBlocked(io, &socket); socket.send(io, &dest, query) catch |err| { log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)}); - return mapPhase(err, error.SendFailed); + return transport.mapPhase(err, error.SendFailed); }; // A deadline, not a duration: a discarded foreign datagram restarts the @@ -152,7 +152,7 @@ pub const ForwardClient = struct { const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) { error.Timeout => return error.Timeout, error.ConcurrencyUnavailable => return error.SystemResources, - else => return mapPhase(err, error.ReceiveFailed), + else => return transport.mapPhase(err, error.ReceiveFailed), }; // Off-path spoofing is the reason the source address is checked at @@ -183,35 +183,16 @@ pub const ForwardClient = struct { } } - /// No stream read or write in 0.16.0 takes a timeout, so the budget is a - /// second task and the loser is canceled. `ConnectOptions.timeout` is never - /// set: the Threaded backend panics on it (Threaded.zig:12076). + /// The read budget bounds the whole TCP exchange through + /// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the + /// Threaded backend panics on it (Threaded.zig:12076). fn exchangeTcp( self: *ForwardClient, io: std.Io, query: []const u8, response_buf: []u8, ) transport.ExchangeError![]u8 { - var outcomes: [2]Outcome = undefined; - var race: std.Io.Select(Outcome) = .init(io, &outcomes); - defer race.cancelDiscard(); - - race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - - switch (try race.await()) { - .exchange => |result| return result, - .expiry => |result| { - // A canceled sleep means this whole task is being torn down, - // not that the resolver is slow. - try result; - return error.Timeout; - }, - } + return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf }); } fn tcpOnce( @@ -224,9 +205,9 @@ pub const ForwardClient = struct { const stream = dest.connect(io, .{ .mode = .stream }) catch |err| { log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)}); - return mapPhase(err, error.ConnectFailed); + return transport.mapPhase(err, error.ConnectFailed); }; - defer closeStream(io, &stream); + defer transport.closeBlocked(io, &stream); const split = self.frame_buf.len / 2; var stream_writer = stream.writer(io, self.frame_buf[0..split]); @@ -257,15 +238,6 @@ pub const ForwardClient = struct { } }; -const Outcome = union(enum) { - exchange: transport.ExchangeError![]u8, - expiry: std.Io.Cancelable!void, -}; - -fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { - return duration.sleep(io); -} - /// The local address a datagram to `dest` is sent from: same family, port /// chosen by the kernel. fn wildcardFor(dest: net.IpAddress) net.IpAddress { @@ -275,25 +247,6 @@ fn wildcardFor(dest: net.IpAddress) net.IpAddress { }; } -/// The TCP budget cancels the exchange task. The next cancelable `Io` call in -/// the `defer` chain would then return `error.Canceled` and skip the close, -/// leaking the descriptor, so both closes run with cancellation blocked. -fn closeStream(io: std.Io, stream: *const net.Stream) void { - const prev = io.swapCancelProtection(.blocked); - defer _ = io.swapCancelProtection(prev); - stream.close(io); -} - -fn closeSocket(io: std.Io, socket: *const net.Socket) void { - const prev = io.swapCancelProtection(.blocked); - defer _ = io.swapCancelProtection(prev); - socket.close(io); -} - -fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError { - return transport.mapLocal(err) orelse phase; -} - /// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the /// cause. Unwrapping it is what keeps `error.Canceled` and the local resource /// errors out of the peer fault group. @@ -302,7 +255,7 @@ fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport stream_writer.err.? else err; - return mapPhase(cause, error.SendFailed); + return transport.mapPhase(cause, error.SendFailed); } fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError { @@ -310,7 +263,7 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp stream_reader.err.? else err; - return mapPhase(cause, error.ReceiveFailed); + return transport.mapPhase(cause, error.ReceiveFailed); } const testing = std.testing; @@ -408,19 +361,19 @@ test "mapPhase keeps local resource and cancellation errors out of the peer faul for (local) |err| { try testing.expectEqual( transport.Group.local_resource, - transport.group(mapPhase(err, error.ReceiveFailed)), + transport.group(transport.mapPhase(err, error.ReceiveFailed)), ); } try testing.expectEqual( transport.ExchangeError.Canceled, - mapPhase(error.Canceled, error.ConnectFailed), + transport.mapPhase(error.Canceled, error.ConnectFailed), ); // A refused connection is the resolver's side, so it stays a peer fault. try testing.expectEqual( transport.ExchangeError.ConnectFailed, - mapPhase(error.ConnectionRefused, error.ConnectFailed), + transport.mapPhase(error.ConnectionRefused, error.ConnectFailed), ); } diff --git a/src/local/forward_zones.zig b/src/local/forward_zones.zig index b9e866b..6999353 100644 --- a/src/local/forward_zones.zig +++ b/src/local/forward_zones.zig @@ -48,7 +48,10 @@ pub const Zones = struct { var buf: [types.max_name_len]u8 = undefined; for (rows) |row| { - const zone = normalizeName(row.zone, &buf) catch return error.BadZone; + // The root is rejected with everything else `normalizeText` refuses: + // a zone that forwards everything would bypass the upstream pool + // entirely, which is not what conditional forwarding means. + const zone = name.normalizeText(row.zone, &buf) catch return error.BadZone; const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver; try spans.append(gpa, .{ .offset = names.items.len, @@ -119,27 +122,6 @@ fn suffixMatches(zone: []const u8, domain: []const u8) bool { return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone); } -const NameError = error{BadName}; - -/// Lowercases over ASCII, strips one trailing dot, and checks the result is a -/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected because query -/// names arrive ASCII-lowercased, so a high byte could never match. The root -/// zone is rejected too: a zone that forwards everything would bypass the -/// upstream pool entirely, which is not what conditional forwarding means. -fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 { - var rest = text; - if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; - if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName; - - for (rest, 0..) |byte, i| { - if (byte >= 0x80) return error.BadName; - buf[i] = std.ascii.toLower(byte); - } - const normalized = buf[0..rest.len]; - _ = name.fromText(normalized) catch return error.BadName; - return normalized; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src/local/records.zig b/src/local/records.zig index d5cacaf..1d183d4 100644 --- a/src/local/records.zig +++ b/src/local/records.zig @@ -52,7 +52,9 @@ pub const Records = struct { var buf: [types.max_name_len]u8 = undefined; for (rows) |row| { - const owner = normalizeName(row.name, &buf) catch return error.BadRecordName; + // A record whose owner can never be matched — a high byte, or the + // root — is a configuration error, not a record that never answers. + const owner = name.normalizeText(row.name, &buf) catch return error.BadRecordName; const value = try parseValue(row.rtype, row.value); try spans.append(gpa, .{ .offset = owners.items.len, @@ -187,27 +189,6 @@ fn rankRun(records: []const Record, wanted: u2) []const Record { return records[start..end]; } -const NameError = error{BadName}; - -/// Lowercases over ASCII, strips one trailing dot, and checks the result is a -/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names -/// arrive ASCII-lowercased, so a high byte here could never be matched and a -/// record that can never answer is a configuration error worth reporting. The -/// root name is rejected for the same reason — nothing can match it. -fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 { - var rest = text; - if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; - if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName; - - for (rest, 0..) |byte, i| { - if (byte >= 0x80) return error.BadName; - buf[i] = std.ascii.toLower(byte); - } - const normalized = buf[0..rest.len]; - _ = name.fromText(normalized) catch return error.BadName; - return normalized; -} - fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value { switch (rtype) { .a => { @@ -226,7 +207,7 @@ fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!V }, .cname => { var buf: [types.max_name_len]u8 = undefined; - const target = normalizeName(text, &buf) catch return error.BadRecordValue; + const target = name.normalizeText(text, &buf) catch return error.BadRecordValue; return .{ .cname = name.fromText(target) catch return error.BadRecordValue }; }, } diff --git a/src/server/doh_server.zig b/src/server/doh_server.zig index e62109a..abcc107 100644 --- a/src/server/doh_server.zig +++ b/src/server/doh_server.zig @@ -1,14 +1,13 @@ //! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2). //! -//! The shape is web/server.zig's: one `std.http.Server` per connection over our -//! own accept loop, fixed pre-allocated connection slots, a keep-alive loop per -//! connection that ends on `error.HttpConnectionClosing`, and the same shutdown -//! split — `deinit` shuts live connections down and drains, a canceled `serve` -//! cancels the connection group because HTTP keep-alive has no deadline of its -//! own. The difference is the transport: after the TCP accept, a certificate -//! generation is pinned (`CertStore.acquire`) and `ServerStream.accept` runs the -//! TLS handshake, and `std.http.Server` sits on the stream's plaintext -//! reader/writer (http/Server.zig:25 takes arbitrary interfaces). +//! The shape is web/server.zig's: one `std.http.Server` per connection over the +//! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and +//! a keep-alive loop per connection that ends on +//! `error.HttpConnectionClosing`. The difference is the transport: after the TCP +//! accept, a certificate generation is pinned (`CertStore.acquire`) and +//! `ServerStream.accept` runs the TLS handshake through +//! `listener.handshakeStage`, and `std.http.Server` sits on the stream's +//! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces). //! //! The handshake runs under the same race budget tcp_server applies to its //! reads (ruling 3's rationale): a client that connects and never handshakes @@ -33,12 +32,11 @@ const address = @import("../platform/address.zig"); const cert_store = @import("cert_store.zig"); const doh_client = @import("../upstream/doh_client.zig"); const handler = @import("handler.zig"); +const listener = @import("listener.zig"); const model = @import("../config/model.zig"); const tls_server = @import("../platform/tls_server.zig"); const transport = @import("../upstream/transport.zig"); -const log = std.log.scoped(.doh_server); - pub const dns_query_path = "/dns-query"; /// Ruling 5. Mbed TLS records the pointer, so the list must outlive every @@ -55,10 +53,6 @@ const send_buffer_len = 4 * 1024; pub const default_max_connections: u16 = 64; -/// How long the accept loop waits after an unexpected accept failure, so a -/// persistent one cannot turn the loop into a spin. -const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; - const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" }; pub const Options = struct { @@ -71,18 +65,9 @@ pub const Options = struct { idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, }; +/// What DoH counts on top of `listener.CoreStats`. pub const Stats = struct { - connections: std.atomic.Value(u64) = .init(0), - rejected_at_capacity: std.atomic.Value(u64) = .init(0), - rejected_at_shutdown: std.atomic.Value(u64) = .init(0), - accept_errors: std.atomic.Value(u64) = .init(0), tls_handshake_failures: std.atomic.Value(u64) = .init(0), - /// Keep-alive connections reclaimed after `idle_timeout` elapsed with no - /// request head on the wire. A stalled handshake counts as a handshake - /// failure instead (milestone-16 ruling 9), so this name means only what - /// it says. - idle_timeouts: std.atomic.Value(u64) = .init(0), - connection_errors: std.atomic.Value(u64) = .init(0), /// Every 4xx answered on `/dns-query` and every miss beside it: the /// visibility counter for clients that speak, but speak wrongly. bad_requests: std.atomic.Value(u64) = .init(0), @@ -94,53 +79,28 @@ pub const Snapshot = struct { rejected_at_shutdown: u64, accept_errors: u64, tls_handshake_failures: u64, + /// Keep-alive connections reclaimed after `idle_timeout` elapsed with no + /// request head on the wire. A stalled handshake counts as a handshake + /// failure instead (milestone-16 ruling 9), so this name means only what + /// it says. idle_timeouts: u64, connection_errors: u64, bad_requests: u64, }; -/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims -/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`. -const State = enum(u32) { idle, serving, closing }; - -/// `.closing` exists so `deinit` never shuts down a descriptor its own task is -/// about to close. -const ConnState = enum { free, active, closing }; - -/// Why the accept loop stopped, which decides what happens to the connections -/// still in flight. -const Stop = enum { closing, canceled }; - -const Claim = union(enum) { - slot: usize, - at_capacity, - shutting_down, -}; - pub const DohServer = struct { - /// Allocates the per-connection Mbed TLS context in `ServerStream.accept`. - gpa: Allocator, + core: listener.Core(Config), handler: *handler.Handler, certs: *cert_store.CertStore, - listener: net.Server, - conns: []Conn, - mutex: std.Io.Mutex, - /// Guarded by `mutex`, set in the same critical section that shuts the live - /// connections down. - shutdown_begun: bool, options: Options, stats: Stats, - run_state: std.atomic.Value(State), - stopped: std.Io.Event, /// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The /// two message buffers cannot shrink: a POST body and the reply both go up - /// to the 65535 bytes a DNS message can be. - pub const Conn = struct { - /// `ServerStream` plaintext buffers; `read_buf` doubles as the HTTP - /// head cap (see `recv_buffer_len`). - read_buf: [recv_buffer_len]u8, - write_buf: [send_buffer_len]u8, + /// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext + /// buffers belong to the core; its `read_buf` doubles as the HTTP head cap + /// (see `recv_buffer_len`). + pub const Payload = struct { /// The decoded query: a POST body or a GET `dns` parameter. query: [transport.max_message_len]u8, reply: [transport.max_message_len]u8, @@ -148,15 +108,22 @@ pub const DohServer = struct { /// serially, so one query uses it at a time. scratch: handler.Scratch, /// Valid between a successful `ServerStream.accept` and the - /// `close(gpa)` in `serveConn`'s defer. + /// `close(gpa)` in `serveOne`'s defer. tls: tls_server.ServerStream, - stream: net.Stream, - peer: net.IpAddress, - /// Guarded by `DohServer.mutex`. - conn_state: ConnState, }; - pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; + const Config = struct { + pub const Owner = DohServer; + pub const ConnPayload = Payload; + pub const serveConn = serveOne; + pub const read_buffer_len = recv_buffer_len; + pub const write_buffer_len = send_buffer_len; + pub const log = std.log.scoped(.doh_server); + pub const name = "doh"; + }; + + pub const Conn = listener.Core(Config).Conn; + pub const ListenError = listener.Core(Config).ListenError; pub fn listen( gpa: Allocator, @@ -166,172 +133,75 @@ pub const DohServer = struct { certs: *cert_store.CertStore, options: Options, ) ListenError!DohServer { - std.debug.assert(options.max_connections > 0); - - const conns = try gpa.alloc(Conn, options.max_connections); - errdefer gpa.free(conns); - for (conns) |*conn| conn.conn_state = .free; - - const listener = try listen_address.listen(io, .{ .reuse_address = true }); - return .{ - .gpa = gpa, + .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections), .handler = h, .certs = certs, - .listener = listener, - .conns = conns, - .mutex = .init, - .shutdown_begun = false, .options = options, .stats = .{}, - .run_state = .init(.idle), - .stopped = .unset, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const DohServer) net.IpAddress { - return self.listener.socket.address; + return self.core.boundAddress(); } pub fn snapshotStats(self: *const DohServer) Snapshot { + const core = &self.core.stats; return .{ - .connections = self.stats.connections.load(.monotonic), - .rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic), - .rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic), - .accept_errors = self.stats.accept_errors.load(.monotonic), + .connections = core.connections.load(.monotonic), + .rejected_at_capacity = core.rejected_at_capacity.load(.monotonic), + .rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic), + .accept_errors = core.accept_errors.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), - .idle_timeouts = self.stats.idle_timeouts.load(.monotonic), - .connection_errors = self.stats.connection_errors.load(.monotonic), + .idle_timeouts = core.idle_timeouts.load(.monotonic), + .connection_errors = core.connection_errors.load(.monotonic), .bad_requests = self.stats.bad_requests.load(.monotonic), }; } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *DohServer, io: std.Io) void { - if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; - - var group: std.Io.Group = .init; - switch (self.acceptLoop(io, &group)) { - // `deinit` shut every live connection down before it published - // `.closing`, so each one is unblocked and finishing on its own. - // Awaiting them means a half-written response still goes out whole. - .closing => { - const prev = io.swapCancelProtection(.blocked); - group.await(io) catch |err| switch (err) { - error.Canceled => unreachable, - }; - _ = io.swapCancelProtection(prev); - }, - // Nothing has shut these connections down, and an idle keep-alive - // connection has no deadline of its own, so draining could wait - // forever. Cancel joins, so the slots are quiet by the time `serve` - // returns; the price is the one response that was mid-write. - .canceled => group.cancel(io), - } - - self.stopped.set(io); + self.core.serve(io); } - pub fn deinit(self: *DohServer, gpa: Allocator, io: std.Io) void { - const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; - - // Shutting the listening socket down is the documented way to unblock a - // pending `accept`: it fails with `error.SocketNotListening`. - const listener: net.Stream = .{ .socket = self.listener.socket }; - listener.shutdown(io, .both) catch |err| { - log.debug("doh listener shutdown failed: {t}", .{err}); - }; - - self.beginShutdown(io); - - if (was_serving) self.stopped.waitUncancelable(io); - - self.listener.deinit(io); - gpa.free(self.conns); + pub fn deinit(self: *DohServer, io: std.Io) void { + self.core.deinit(io); self.* = undefined; } - fn acceptLoop(self: *DohServer, io: std.Io, group: *std.Io.Group) Stop { - while (self.run_state.load(.acquire) == .serving) { - const stream = self.listener.accept(io) catch |err| switch (err) { - error.Canceled => return .canceled, - error.SocketNotListening => return .closing, - else => { - bump(&self.stats.accept_errors); - log.debug("doh accept failed: {t}", .{err}); - retry_delay.sleep(io) catch return .canceled; - continue; - }, - }; - - const index = switch (self.claim(io, stream)) { - .slot => |index| index, - // See the module comment: no 503 without a handshake, so over - // capacity the stream is closed raw and the refusal counted. - .at_capacity => { - bump(&self.stats.rejected_at_capacity); - stream.close(io); - continue; - }, - .shutting_down => { - bump(&self.stats.rejected_at_shutdown); - stream.close(io); - return .closing; - }, - }; - - group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { - error.ConcurrencyUnavailable => { - bump(&self.stats.rejected_at_capacity); - self.finish(io, index); - continue; - }, - }; - - bump(&self.stats.connections); - } - - // The loop condition failed, which only `deinit` can cause. - return .closing; - } - - fn serveConn(self: *DohServer, io: std.Io, index: usize) void { - defer self.finish(io, index); - - const conn = &self.conns[index]; + /// One connection: pin, handshake, keep-alive loop, close_notify, release — + /// the ordering `listener.handshakeStage` documents. The core closes the + /// TCP stream after this returns. + fn serveOne(self: *DohServer, io: std.Io, index: usize) void { + const conn = &self.core.conns[index]; + const stats = &self.core.stats; + const gpa = self.core.gpa; // Pinned for the whole connection (ruling 6): a reload never frees the // generation this stream handshook against. const entry = self.certs.acquire(io); defer self.certs.release(io, entry); - var handshook = false; - switch (race(io, self.options.idle_timeout, handshake, .{ self.gpa, conn, &entry.ctx, io, &handshook })) { + const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io }; + switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) { .ok => {}, - // The select can report the expiry or the cancellation after the - // handshake has in fact succeeded. The flag is written before the - // race joins its tasks, so a TLS context that exists is closed on - // every path, exactly once. - .canceled => { - if (handshook) conn.tls.close(self.gpa); - return; - }, + .canceled => return, // Milestone-16 ruling 9: a stalled handshake is refused like a broken // one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive // wait below, so the two listeners export the same names for the // same events. .timed_out, .failed => { - if (handshook) conn.tls.close(self.gpa); - bump(&self.stats.tls_handshake_failures); + listener.bump(&self.stats.tls_handshake_failures); return; }, } // Flushes, sends close_notify and frees the TLS context on every exit - // path below; `finish` closes the TCP stream afterwards. - defer conn.tls.close(self.gpa); + // path below; the core closes the TCP stream afterwards. + defer conn.payload.tls.close(gpa); - var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer()); + var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer()); while (connection.reader.state == .ready) { // Milestone-16 ruling 10: the wait for the next request head is the @@ -339,10 +209,10 @@ pub const DohServer = struct { // it runs under the same budget as the handshake. The body read and // `handleRequest` below stay untimed. var head: ReceiveHeadResult = error.ReadFailed; - switch (race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) { + switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) { .ok => {}, .timed_out => { - bump(&self.stats.idle_timeouts); + listener.bump(&stats.idle_timeouts); return; }, // Cancellation is shutdown; `.failed` here is only the wrapper @@ -359,7 +229,7 @@ pub const DohServer = struct { error.HttpRequestTruncated, error.HttpHeadersInvalid, => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, }; @@ -380,7 +250,7 @@ pub const DohServer = struct { // The peer went away mid-response. Normal. error.WriteFailed => return, error.HttpExpectationFailed, error.ReadFailed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, }; @@ -392,6 +262,31 @@ pub const DohServer = struct { } } + /// The `listener.handshakeStage` stage: everything one mbedTLS handshake + /// needs, plus the close that undoes it. + const Handshake = struct { + conn: *Conn, + gpa: Allocator, + ctx: *tls_server.ServerContext, + io: std.Io, + + pub fn accept(self: Handshake) anyerror!void { + const conn = self.conn; + try conn.payload.tls.accept( + self.gpa, + self.ctx, + self.io, + &conn.stream, + &conn.read_buf, + &conn.write_buf, + ); + } + + pub fn close(self: Handshake) void { + self.conn.payload.tls.close(self.gpa); + } + }; + const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed }; /// What `serveConn`'s keep-alive loop does after the response went out. @@ -444,7 +339,7 @@ pub const DohServer = struct { return self.refuse(request, .bad_request, "bad request\n", &.{}, true); }, }; - const query = decodeDnsValue(value, &conn.query) catch { + const query = decodeDnsValue(value, &conn.payload.query) catch { return self.refuse(request, .bad_request, "bad request\n", &.{}, true); }; return self.answer(io, conn, request, query); @@ -459,17 +354,17 @@ pub const DohServer = struct { return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); }; const reader = try request.readerExpectContinue(&.{}); - const got = reader.readSliceShort(&conn.query) catch return error.ReadFailed; + const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed; // A full buffer is either a message of exactly the DNS maximum // or a chunked body that keeps going; one probe byte decides. - if (got == conn.query.len) { + if (got == conn.payload.query.len) { var probe: [1]u8 = undefined; const extra = reader.readSliceShort(&probe) catch return error.ReadFailed; if (extra != 0) { return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); } } - return self.answer(io, conn, request, conn.query[0..got]); + return self.answer(io, conn, request, conn.payload.query[0..got]); }, else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep), } @@ -490,8 +385,8 @@ pub const DohServer = struct { .tcp, address.NetAddress.fromIp(conn.peer), query, - &conn.reply, - &conn.scratch, + &conn.payload.reply, + &conn.payload.scratch, ); switch (outcome) { .drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false), @@ -514,7 +409,7 @@ pub const DohServer = struct { extra_headers: []const http.Header, keep_alive: bool, ) error{ WriteFailed, HttpExpectationFailed }!Next { - bump(&self.stats.bad_requests); + listener.bump(&self.stats.bad_requests); try request.respond(body, .{ .status = status, .keep_alive = keep_alive, @@ -525,73 +420,8 @@ pub const DohServer = struct { // `connection: close` either way, and the loop must agree. return if (keep_alive and request.head.keep_alive) .keep_open else .close; } - - fn claim(self: *DohServer, io: std.Io, stream: net.Stream) Claim { - // Uncancelable: this section takes no Io and never blocks on a peer, so - // losing the lock mid-update would leak a slot for nothing. - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - const outcome = decideClaim(self.conns, self.shutdown_begun); - switch (outcome) { - .slot => |index| { - self.conns[index].stream = stream; - self.conns[index].peer = stream.socket.address; - self.conns[index].conn_state = .active; - }, - .at_capacity, .shutting_down => {}, - } - return outcome; - } - - fn finish(self: *DohServer, io: std.Io, index: usize) void { - const conn = &self.conns[index]; - - self.mutex.lockUncancelable(io); - conn.conn_state = .closing; - self.mutex.unlock(io); - - // The socket is released even when this task is being torn down: the - // next cancelable call would otherwise skip the close. - const prev = io.swapCancelProtection(.blocked); - conn.stream.close(io); - _ = io.swapCancelProtection(prev); - - self.mutex.lockUncancelable(io); - conn.conn_state = .free; - self.mutex.unlock(io); - } - - /// Closes the door on new connections and unblocks the live ones under one - /// hold of the mutex, so no `claim` can slip between the two. - fn beginShutdown(self: *DohServer, io: std.Io) void { - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - self.shutdown_begun = true; - - for (self.conns) |*conn| { - if (conn.conn_state != .active) continue; - conn.stream.shutdown(io, .both) catch |err| { - log.debug("doh connection shutdown failed: {t}", .{err}); - }; - } - } }; -/// `handshook` is set only after `accept` returned, so `serveConn` knows on -/// every race outcome whether `conn.tls` holds a context that must be closed. -fn handshake( - gpa: Allocator, - conn: *DohServer.Conn, - ctx: *tls_server.ServerContext, - io: std.Io, - handshook: *bool, -) anyerror!void { - try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf); - handshook.* = true; -} - const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request; /// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an @@ -655,85 +485,6 @@ fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 { return dest[0..len]; } -/// The whole claim rule, without the mutex, so it is testable without a backend. -fn decideClaim(conns: []const DohServer.Conn, shutdown_begun: bool) Claim { - if (shutdown_begun) return .shutting_down; - for (conns, 0..) |*conn, index| { - if (conn.conn_state == .free) return .{ .slot = index }; - } - return .at_capacity; -} - -const Outcome = union(enum) { - op: anyerror!void, - expiry: std.Io.Cancelable!void, -}; - -const RaceResult = enum { ok, timed_out, failed, canceled }; - -/// Runs one connection operation against the budget and cancels the loser -/// (tcp_server's arrangement: no stream operation in 0.16.0 takes a timeout). -fn race( - io: std.Io, - budget: std.Io.Clock.Duration, - comptime f: anytype, - args: std.meta.ArgsTuple(@TypeOf(f)), -) RaceResult { - var outcomes: [2]Outcome = undefined; - var select: std.Io.Select(Outcome) = .init(io, &outcomes); - defer select.cancelDiscard(); - - select.concurrent(.op, f, args) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - - return switch (select.await() catch return .canceled) { - .op => |result| if (result) |_| .ok else |err| switch (err) { - error.Canceled => .canceled, - else => .failed, - }, - // A canceled sleep means this task is being torn down, not that the - // peer went idle. - .expiry => |result| if (result) |_| .timed_out else |_| .canceled, - }; -} - -fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { - return budget.sleep(io); -} - -fn bump(counter: *std.atomic.Value(u64)) void { - _ = counter.fetchAdd(1, .monotonic); -} - -/// The composition root's entry point: bind, serve, release. A bind failure is -/// warned and swallowed (ruling 1, the web precedent): DoH failing to come up -/// must not stop nxdns answering plain DNS. -pub fn serve( - gpa: Allocator, - io: std.Io, - endpoint: model.TlsEndpoint, - h: *handler.Handler, - certs: *cert_store.CertStore, -) void { - const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch { - log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind}); - return; - }; - - var server: DohServer = DohServer.listen(gpa, io, bind_address, h, certs, .{}) catch |err| { - log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err }); - return; - }; - defer server.deinit(gpa, io); - - log.info("doh listener on {f}", .{server.boundAddress()}); - server.serve(io); -} - // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -749,41 +500,6 @@ const local_tables_mod = @import("local_tables.zig"); const response = @import("../filter/response.zig"); const types = @import("../dns/types.zig"); -fn testConns(count: usize) ![]DohServer.Conn { - const conns = try testing.allocator.alloc(DohServer.Conn, count); - for (conns) |*conn| conn.conn_state = .free; - return conns; -} - -test "the connection pool hands out every slot once, then refuses" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); - conns[0].conn_state = .active; - try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); - conns[1].conn_state = .active; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); -} - -test "a closing slot is not reused until it is free" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - conns[0].conn_state = .closing; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); - conns[0].conn_state = .free; - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); -} - -test "shutdown outranks capacity and does not consume the slot" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); -} - test "framesBody sees framing in either header and none in content-length: 0" { try testing.expect(framesBody(.chunked, null)); try testing.expect(framesBody(.none, 4)); @@ -937,7 +653,7 @@ const Harness = struct { const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options); - errdefer hx.server.deinit(testing.allocator, hio); + errdefer hx.server.deinit(hio); hx.group = .init; try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio }); @@ -945,7 +661,7 @@ const Harness = struct { fn stop(hx: *Harness) void { const hio = hx.threaded.io(); - hx.server.deinit(testing.allocator, hio); + hx.server.deinit(hio); hx.group.await(hio) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -977,7 +693,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f)) defer select.cancelDiscard(); try select.concurrent(.work, f, args); - try select.concurrent(.expiry, expire, .{ io, test_budget }); + try select.concurrent(.expiry, listener.expire, .{ io, test_budget }); switch (try select.await()) { .work => |result| return result, diff --git a/src/server/dot_server.zig b/src/server/dot_server.zig index c12f9c7..866d02a 100644 --- a/src/server/dot_server.zig +++ b/src/server/dot_server.zig @@ -1,12 +1,14 @@ //! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream. //! -//! This file mirrors `tcp_server.zig` — same slots, same claim rule, same -//! shutdown paths, same idle race — with three differences: +//! The slot pool, the accept loop and the shutdown protocol are +//! `listener.Core`'s (milestone-18 ruling 1), the same ones tcp_server uses. +//! What this file adds over TCP/53: //! //! - After the TCP accept, the certificate generation is pinned with -//! `CertStore.acquire` and the mbedTLS handshake runs under the same race -//! budget as every other per-connection operation, so a client that stalls -//! mid-handshake cannot pin a connection slot. +//! `CertStore.acquire` and the mbedTLS handshake runs through +//! `listener.handshakeStage` under the same race budget as every other +//! per-connection operation, so a client that stalls mid-handshake cannot pin +//! a connection slot. //! - The framed-message loop reads and writes through //! `tls_server.ServerStream`, and closing the stream sends close_notify //! before the TCP close. A transport EOF without close_notify surfaces as a @@ -23,20 +25,15 @@ const std = @import("std"); const address = @import("../platform/address.zig"); const cert_store = @import("cert_store.zig"); const handler = @import("handler.zig"); +const listener = @import("listener.zig"); const tls_server = @import("../platform/tls_server.zig"); const transport = @import("../upstream/transport.zig"); -const log = std.log.scoped(.dot_server); - /// Plaintext staging for `ServerStream`: the framing bytes and the decrypted /// record tail pass through here, while whole messages go straight to -/// `Conn.query`/`Conn.reply`. +/// `Payload.query`/`Payload.reply`. const stream_buffer_len = 1024; -/// How long the accept loop waits after an unexpected accept failure, so a -/// persistent one cannot turn the loop into a spin. -const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; - pub const Options = struct { max_connections: u16 = 64, /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the @@ -44,16 +41,11 @@ pub const Options = struct { idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, }; +/// What DoT counts on top of `listener.CoreStats`: handshakes that failed or +/// outran the idle budget. `connections` in the core counts TCP connections +/// accepted, whether or not the handshake succeeded. pub const Stats = struct { - /// TCP connections accepted, whether or not the handshake succeeded. - connections: std.atomic.Value(u64) = .init(0), - /// Handshakes that failed or outran the idle budget. tls_handshake_failures: std.atomic.Value(u64) = .init(0), - idle_timeouts: std.atomic.Value(u64) = .init(0), - connection_errors: std.atomic.Value(u64) = .init(0), - rejected_at_capacity: std.atomic.Value(u64) = .init(0), - rejected_at_shutdown: std.atomic.Value(u64) = .init(0), - accept_errors: std.atomic.Value(u64) = .init(0), }; /// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup` @@ -65,62 +57,19 @@ pub const StatsSnapshot = struct { connection_errors: u64, }; -/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes -/// `.closing`, and the two meet at `stopped` so no task touches a connection -/// slot after it is freed. -const State = enum(u32) { idle, serving, closing }; - -/// `.closing` exists so `deinit` never shuts down a descriptor that its own -/// task is about to close: the transition to `.closing` happens under the mutex -/// before the close, and `deinit` only touches `.active` slots. -const ConnState = enum { free, active, closing }; - -/// Why the accept loop stopped, which decides what happens to the connections -/// still in flight. -const Stop = enum { - /// `deinit` published `.closing`. It has already shut every live connection - /// down, so each one is unblocked and finishing on its own. - closing, - /// This task is being canceled. Nothing has touched the connections. - canceled, -}; - -/// What the accept loop does with a stream it has just accepted. -const Claim = union(enum) { - /// The stream owns `conns[index]`. - slot: usize, - /// Every slot is taken. The stream is closed and the loop continues. - at_capacity, - /// `deinit` has started. The stream is closed and the loop returns. - shutting_down, -}; - pub const DotServer = struct { - server: std.Io.net.Server, + core: listener.Core(Config), handler: *handler.Handler, certs: *cert_store.CertStore, - /// Kept for the per-connection ssl context `ServerStream.accept` - /// allocates and `close` frees. - gpa: std.mem.Allocator, - conns: []Conn, - mutex: std.Io.Mutex, - /// Guarded by `mutex`. `deinit` sets it in the same critical section that - /// shuts the active connections down, so a stream that arrives after that - /// scan can never claim a slot the scan will not visit again. - shutdown_begun: bool, options: Options, stats: Stats, - state: std.atomic.Value(State), - stopped: std.Io.Event, /// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the /// `ServerStream` bookkeeping — so the default 64 connections stay inside /// the PLAN §18 budget. - pub const Conn = struct { + pub const Payload = struct { query: [transport.max_message_len]u8, reply: [transport.max_message_len]u8, - read_buf: [stream_buffer_len]u8, - write_buf: [stream_buffer_len]u8, /// The handler's per-query working memory. It belongs to the slot so /// that answering a message allocates nothing, and a connection is /// answered serially, so one query uses it at a time. @@ -128,16 +77,20 @@ pub const DotServer = struct { /// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it, /// and the slot never moves. tls: tls_server.ServerStream, - stream: std.Io.net.Stream, - /// The client, read off the accepted socket once at claim time: every - /// message on this connection comes from the same peer, and the handler - /// needs it for rate limiting, groups and the query log. - peer: std.Io.net.IpAddress, - /// Guarded by `DotServer.mutex`. - state: ConnState, }; - pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; + const Config = struct { + pub const Owner = DotServer; + pub const ConnPayload = Payload; + pub const serveConn = serveOne; + pub const read_buffer_len = stream_buffer_len; + pub const write_buffer_len = stream_buffer_len; + pub const log = std.log.scoped(.dot_server); + pub const name = "dot"; + }; + + pub const Conn = listener.Core(Config).Conn; + pub const ListenError = listener.Core(Config).ListenError; pub fn listen( gpa: std.mem.Allocator, @@ -147,148 +100,47 @@ pub const DotServer = struct { certs: *cert_store.CertStore, options: Options, ) ListenError!DotServer { - std.debug.assert(options.max_connections > 0); - - const conns = try gpa.alloc(Conn, options.max_connections); - errdefer gpa.free(conns); - for (conns) |*conn| conn.state = .free; - - const local = listen_address; - const server = try local.listen(io, .{ .reuse_address = true }); - return .{ - .server = server, + .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections), .handler = h, .certs = certs, - .gpa = gpa, - .conns = conns, - .mutex = .init, - .shutdown_begun = false, .options = options, .stats = .{}, - .state = .init(.idle), - .stopped = .unset, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress { - return self.server.socket.address; + return self.core.boundAddress(); } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *DotServer, io: std.Io) void { - if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; - - var group: std.Io.Group = .init; - switch (self.acceptLoop(io, &group)) { - // `deinit` shut every live connection down before it published - // `.closing`, so each one is already unblocked and ending on its - // own. Awaiting them means a half-written reply still goes out - // whole, and the wait is bounded by the shutdown, not the client. - .closing => { - const prev = io.swapCancelProtection(.blocked); - group.await(io) catch |err| switch (err) { - error.Canceled => unreachable, - }; - _ = io.swapCancelProtection(prev); - }, - // Nothing has shut these connections down: `deinit` cannot run - // until this task returns, and RFC 7766 lets a client hold a - // connection open forever by asking again inside the idle budget. - // Draining here would therefore let one client stall the whole - // process's shutdown for as long as it likes. `cancel` requests - // cancellation and joins, so the slots are still quiet — and the - // buffers still unreferenced — by the time `serve` returns; the - // price is the one reply that was mid-write. - .canceled => group.cancel(io), - } - - self.stopped.set(io); + self.core.serve(io); } pub fn deinit(self: *DotServer, io: std.Io) void { - const was_serving = self.state.swap(.closing, .acq_rel) == .serving; - - // Shutting the listening socket down is the documented way to unblock a - // pending `accept`: it fails with `error.SocketNotListening`. - const listener: std.Io.net.Stream = .{ .socket = self.server.socket }; - listener.shutdown(io, .both) catch |err| { - log.debug("dot listener shutdown failed: {t}", .{err}); - }; - - // A live connection is blocked in a read that only the idle budget - // would end, which is seconds away. Shutting each one down bounds this, - // and the same critical section closes the door on new connections. - self.beginShutdown(io); - - if (was_serving) self.stopped.waitUncancelable(io); - - self.server.deinit(io); - self.gpa.free(self.conns); + self.core.deinit(io); self.* = undefined; } pub fn snapshotStats(self: *const DotServer) StatsSnapshot { + const core = &self.core.stats; return .{ - .connections = self.stats.connections.load(.monotonic), + .connections = core.connections.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), - .idle_timeouts = self.stats.idle_timeouts.load(.monotonic), - .connection_errors = self.stats.connection_errors.load(.monotonic), + .idle_timeouts = core.idle_timeouts.load(.monotonic), + .connection_errors = core.connection_errors.load(.monotonic), }; } - fn acceptLoop(self: *DotServer, io: std.Io, group: *std.Io.Group) Stop { - while (self.state.load(.acquire) == .serving) { - const stream = self.server.accept(io) catch |err| switch (err) { - error.Canceled => return .canceled, - // `deinit` shuts the listening socket down to unblock exactly - // this call, so it is the shutdown path arriving early. - error.SocketNotListening => return .closing, - else => { - bump(&self.stats.accept_errors); - log.debug("dot accept failed: {t}", .{err}); - retry_delay.sleep(io) catch return .canceled; - continue; - }, - }; - - const index = switch (self.claim(io, stream)) { - .slot => |index| index, - // Refusing now is honest; a queue would only hide the overload. - .at_capacity => { - bump(&self.stats.rejected_at_capacity); - stream.close(io); - continue; - }, - // `deinit` will not see this stream in any slot, so serving it - // would hold `deinit` for the whole idle budget. - .shutting_down => { - bump(&self.stats.rejected_at_shutdown); - stream.close(io); - return .closing; - }, - }; - - group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { - error.ConcurrencyUnavailable => { - bump(&self.stats.rejected_at_capacity); - self.finish(io, index); - continue; - }, - }; - - bump(&self.stats.connections); - } - - // The loop condition failed, which only `deinit` can cause. - return .closing; - } - - fn serveConn(self: *DotServer, io: std.Io, index: usize) void { - defer self.finish(io, index); - - const conn = &self.conns[index]; + /// One connection: pin, handshake, serve, close_notify, release — the + /// ordering `listener.handshakeStage` documents. The core closes the TCP + /// stream after this returns. + fn serveOne(self: *DotServer, io: std.Io, index: usize) void { + const conn = &self.core.conns[index]; + const stats = &self.core.stats; + const gpa = self.core.gpa; const budget = self.options.idle_timeout; // Pins the certificate generation for the whole connection: a reload @@ -297,46 +149,38 @@ pub const DotServer = struct { const entry = self.certs.acquire(io); defer self.certs.release(io, entry); - var handshook = false; - switch (race(io, budget, handshake, .{ conn, self.gpa, &entry.ctx, io, &handshook })) { + const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io }; + switch (listener.handshakeStage(io, budget, stage)) { .ok => {}, - // The select can report the expiry or the cancellation after the - // handshake has in fact succeeded. The flag is written before the - // race joins its tasks, so a TLS context that exists is closed on - // every path, exactly once. - .canceled => { - if (handshook) conn.tls.close(self.gpa); - return; - }, + .canceled => return, // A stalled handshake is refused like a broken one: it must not // pin a connection slot for longer than the idle budget. .timed_out, .failed => { - if (handshook) conn.tls.close(self.gpa); - bump(&self.stats.tls_handshake_failures); + listener.bump(&self.stats.tls_handshake_failures); return; }, } - // Sends close_notify and frees the ssl context; `finish` closes the + // Sends close_notify and frees the ssl context; the core closes the // TCP stream afterwards. - defer conn.tls.close(self.gpa); + defer conn.payload.tls.close(gpa); - const reader = conn.tls.reader(); - const writer = conn.tls.writer(); + const reader = conn.payload.tls.reader(); + const writer = conn.payload.tls.writer(); while (true) { var prefix: [transport.prefix_len]u8 = undefined; var got: usize = 0; - switch (race(io, budget, readPrefix, .{ reader, &prefix, &got })) { + switch (listener.race(io, budget, listener.readPrefix, .{ reader, &prefix, &got })) { .ok => {}, .timed_out => { - bump(&self.stats.idle_timeouts); + listener.bump(&stats.idle_timeouts); return; }, .canceled => return, // A transport EOF without close_notify lands here too: the // stream reads it as a truncation, never as a clean end. .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } @@ -345,7 +189,7 @@ pub const DotServer = struct { // asking, which is the normal end of a connection, not a failure. if (got == 0) return; if (got != transport.prefix_len) { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; } @@ -353,16 +197,16 @@ pub const DotServer = struct { // the prefix is a u16 so it can never exceed `max_message_len`. const len = transport.parsePrefix(prefix); if (len == 0) { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; } - switch (race(io, budget, readBody, .{ reader, conn.query[0..len] })) { + switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) { .ok => {}, .canceled => return, // A half-sent message is a broken peer, not an idle one. .timed_out, .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } @@ -371,9 +215,9 @@ pub const DotServer = struct { io, .tcp, address.NetAddress.fromIp(conn.peer), - conn.query[0..len], - &conn.reply, - &conn.scratch, + conn.payload.query[0..len], + &conn.payload.reply, + &conn.payload.scratch, ); const bytes = switch (outcome) { // There is no framing for "no answer", so the connection ends. @@ -382,162 +226,43 @@ pub const DotServer = struct { }; const out = transport.framePrefix(@intCast(bytes.len)); - switch (race(io, budget, writeReply, .{ writer, &out, bytes })) { + switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) { .ok => {}, .canceled => return, .timed_out, .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } } } - fn claim(self: *DotServer, io: std.Io, stream: std.Io.net.Stream) Claim { - // Uncancelable: this section takes no Io and never blocks on a peer, so - // it cannot deadlock, and losing the lock mid-update would leak a slot. - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); + /// The `listener.handshakeStage` stage: everything one mbedTLS handshake + /// needs, plus the close that undoes it. + const Handshake = struct { + conn: *Conn, + gpa: std.mem.Allocator, + ctx: *tls_server.ServerContext, + io: std.Io, - const outcome = decideClaim(self.conns, self.shutdown_begun); - switch (outcome) { - .slot => |index| { - self.conns[index].stream = stream; - self.conns[index].peer = stream.socket.address; - self.conns[index].state = .active; - }, - .at_capacity, .shutting_down => {}, + pub fn accept(self: Handshake) anyerror!void { + const conn = self.conn; + try conn.payload.tls.accept( + self.gpa, + self.ctx, + self.io, + &conn.stream, + &conn.read_buf, + &conn.write_buf, + ); } - return outcome; - } - fn finish(self: *DotServer, io: std.Io, index: usize) void { - const conn = &self.conns[index]; - - self.mutex.lockUncancelable(io); - conn.state = .closing; - self.mutex.unlock(io); - - // The socket is released even when this task is being torn down: the - // next cancelable call would otherwise skip the close. - const prev = io.swapCancelProtection(.blocked); - conn.stream.close(io); - _ = io.swapCancelProtection(prev); - - self.mutex.lockUncancelable(io); - conn.state = .free; - self.mutex.unlock(io); - } - - /// Closes the door on new connections and unblocks the live ones. Both - /// happen under one hold of the mutex: a `claim` that runs before this - /// leaves an `.active` slot the loop below shuts down, and a `claim` that - /// runs after it reads `shutdown_begun` and takes no slot at all. - fn beginShutdown(self: *DotServer, io: std.Io) void { - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - self.shutdown_begun = true; - - for (self.conns) |*conn| { - if (conn.state != .active) continue; - conn.stream.shutdown(io, .both) catch |err| { - log.debug("dot connection shutdown failed: {t}", .{err}); - }; + pub fn close(self: Handshake) void { + self.conn.payload.tls.close(self.gpa); } - } + }; }; -/// The capacity rule, without the mutex, so it is testable without a backend. -fn firstFree(conns: []const DotServer.Conn) ?usize { - for (conns, 0..) |*conn, index| { - if (conn.state == .free) return index; - } - return null; -} - -/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free -/// slot is still refused once `deinit` has passed the connections. -fn decideClaim(conns: []const DotServer.Conn, shutdown_begun: bool) Claim { - if (shutdown_begun) return .shutting_down; - const index = firstFree(conns) orelse return .at_capacity; - return .{ .slot = index }; -} - -const Outcome = union(enum) { - op: anyerror!void, - expiry: std.Io.Cancelable!void, -}; - -const Result = enum { ok, timed_out, failed, canceled }; - -/// Runs one connection operation against the idle budget and cancels the loser. -fn race( - io: std.Io, - budget: std.Io.Clock.Duration, - comptime f: anytype, - args: std.meta.ArgsTuple(@TypeOf(f)), -) Result { - var outcomes: [2]Outcome = undefined; - var select: std.Io.Select(Outcome) = .init(io, &outcomes); - defer select.cancelDiscard(); - - select.concurrent(.op, f, args) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - - return switch (select.await() catch return .canceled) { - .op => |result| if (result) |_| .ok else |err| switch (err) { - error.Canceled => .canceled, - else => .failed, - }, - // A canceled sleep means this task is being torn down, not that the - // client went idle. - .expiry => |result| if (result) |_| .timed_out else |_| .canceled, - }; -} - -fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { - return budget.sleep(io); -} - -/// `handshook` is set only after `accept` returned, so `serveConn` knows on -/// the losing race paths whether a TLS context exists that must be closed. -fn handshake( - conn: *DotServer.Conn, - gpa: std.mem.Allocator, - ctx: *tls_server.ServerContext, - io: std.Io, - handshook: *bool, -) anyerror!void { - try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf); - handshook.* = true; -} - -/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client -/// that sent close_notify between messages, and only a partial prefix is an -/// error. -fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void { - out_len.* = try reader.readSliceShort(buf); -} - -fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void { - return reader.readSliceAll(buf); -} - -fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void { - try writer.writeAll(prefix); - try writer.writeAll(bytes); - try writer.flush(); -} - -fn bump(counter: *std.atomic.Value(u64)) void { - _ = counter.fetchAdd(1, .monotonic); -} - // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -550,78 +275,15 @@ const packet = @import("../dns/packet.zig"); const response = @import("../filter/response.zig"); const types = @import("../dns/types.zig"); -fn testConns(count: usize) ![]DotServer.Conn { - const conns = try testing.allocator.alloc(DotServer.Conn, count); - for (conns) |*conn| conn.state = .free; - return conns; -} - -test "the connection pool hands out every slot once" { - const conns = try testConns(3); - defer testing.allocator.free(conns); - - for (0..conns.len) |expected| { - const index = firstFree(conns) orelse return error.TestUnexpectedResult; - try testing.expectEqual(expected, index); - conns[index].state = .active; - } -} - -test "a full connection pool refuses instead of growing" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - for (conns) |*conn| conn.state = .active; - try testing.expectEqual(@as(?usize, null), firstFree(conns)); -} - -test "a closing slot is not reused until it is free" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - conns[0].state = .active; - conns[1].state = .closing; - try testing.expectEqual(@as(?usize, null), firstFree(conns)); - - conns[1].state = .free; - try testing.expectEqual(@as(?usize, 1), firstFree(conns)); -} - -test "a claim takes the first free slot before shutdown" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - conns[0].state = .active; - try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); -} - -test "a claim after shutdown is refused even with a free slot" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); - - // The refusal must not consume the slot: `deinit` frees it, nothing else. - try testing.expectEqual(@as(?usize, 0), firstFree(conns)); -} - -test "shutdown outranks capacity" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - conns[0].state = .active; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); -} - test "snapshotStats reports the ruling-10 counters" { var server: DotServer = undefined; + server.core.stats = .{}; server.stats = .{}; - bump(&server.stats.connections); - bump(&server.stats.connections); - bump(&server.stats.tls_handshake_failures); - bump(&server.stats.connection_errors); + listener.bump(&server.core.stats.connections); + listener.bump(&server.core.stats.connections); + listener.bump(&server.stats.tls_handshake_failures); + listener.bump(&server.core.stats.connection_errors); const snapshot = server.snapshotStats(); try testing.expectEqual(@as(u64, 2), snapshot.connections); @@ -743,7 +405,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f)) defer select.cancelDiscard(); try select.concurrent(.work, f, args); - try select.concurrent(.expiry, expire, .{ io, test_budget }); + try select.concurrent(.expiry, listener.expire, .{ io, test_budget }); switch (try select.await()) { .work => |result| return result, @@ -953,7 +615,7 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra try group.concurrent(io, DotServer.serve, .{ &server, io }); try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address }); - try waitForCounter(io, &server.stats.connection_errors, 1); + try waitForCounter(io, &server.core.stats.connection_errors, 1); const stats = server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); diff --git a/src/server/listener.zig b/src/server/listener.zig new file mode 100644 index 0000000..fb1fbcf --- /dev/null +++ b/src/server/listener.zig @@ -0,0 +1,568 @@ +//! The listener core the four stream listeners share. +//! +//! `tcp_server.zig`, `dot_server.zig`, `doh_server.zig` and `web/server.zig` +//! are the same machine wearing four transports: a fixed pre-allocated slot +//! pool, a claim rule where shutdown outranks capacity, an accept loop with one +//! error mapping, a mutex-ordered close dance, and — for everything that waits +//! on a peer — one select race against a budget. Milestone 18 ruling 1 puts +//! that machine here once, so a fix to it lands once. +//! +//! What stays outside: the per-connection serve function, the connection +//! payload (buffers, TLS context, arenas), and the TLS lifecycle. A TLS +//! listener's certificate pin, handshake, close_notify and release form one +//! ordered sequence that the Core has no business owning; what it does own is +//! `handshakeStage`, the exactly-once `handshook` flag whose absence was a real +//! leak in doh_server before the milestone-10 review hand-ported the fix. +//! +//! Shutdown is the one part worth reading twice. `deinit` publishes `.closing`, +//! shuts the listening socket down (which unblocks `accept` with +//! `error.SocketNotListening`) and shuts every `.active` connection down in the +//! same critical section that closes the door on new ones; `serve` then drains +//! its connection group so a half-written reply still goes out whole. A +//! *canceled* `serve` cannot drain, because a keep-alive peer has no deadline +//! of its own and one chatty client would stall the whole process's shutdown; +//! it cancels the group instead, at the cost of the one reply mid-write. +//! Either way `serve` returns only once no task can still touch a slot. + +const std = @import("std"); +const net = std.Io.net; +const Allocator = std.mem.Allocator; + +const transport = @import("../upstream/transport.zig"); + +/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes +/// `.closing`, and the two meet at `stopped` so no task touches a connection +/// slot after it is freed. +pub const State = enum(u32) { idle, serving, closing }; + +/// `.closing` exists so `deinit` never shuts down a descriptor that its own +/// task is about to close: the transition to `.closing` happens under the mutex +/// before the close, and `deinit` only touches `.active` slots. +pub const ConnState = enum { free, active, closing }; + +/// Why the accept loop stopped, which decides what happens to the connections +/// still in flight. +pub const Stop = enum { + /// `deinit` published `.closing`. It has already shut every live connection + /// down, so each one is unblocked and finishing on its own. + closing, + /// This task is being canceled. Nothing has touched the connections. + canceled, +}; + +/// What the accept loop does with a stream it has just accepted. +pub const Claim = union(enum) { + /// The stream owns `conns[index]`. + slot: usize, + /// Every slot is taken. The stream is refused and the loop continues. + at_capacity, + /// `deinit` has started. The stream is closed and the loop returns. + shutting_down, +}; + +/// How long the accept loop waits after an unexpected accept failure, so a +/// persistent one cannot turn the loop into a spin. +pub const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +pub fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +/// The counters every listener keeps. Listener-specific ones — +/// `tls_handshake_failures`, `bad_requests`, `requests` — live beside this in +/// the owning listener, and each listener's exported `Snapshot` stays a flat +/// hand-written struct so `/metrics` output does not depend on this layout. +/// +/// `idle_timeouts` is bumped by the three DNS listeners; the web listener has +/// no idle race (its port is LAN-facing and the cancel path bounds shutdown), +/// so its copy stays zero and it exports no family at all. +pub const CoreStats = struct { + connections: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + rejected_at_shutdown: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + idle_timeouts: std.atomic.Value(u64) = .init(0), +}; + +// --------------------------------------------------------------------------- +// the race harness +// --------------------------------------------------------------------------- + +pub const Outcome = union(enum) { + op: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +pub const Result = enum { ok, timed_out, failed, canceled }; + +/// Runs one connection operation against a budget and cancels the loser. No +/// stream read or write in 0.16.0 accepts a timeout, so every wait on a peer +/// that owes nxdns bytes goes through here. +pub fn race( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: std.meta.ArgsTuple(@TypeOf(f)), +) Result { + var outcomes: [2]Outcome = undefined; + var select: std.Io.Select(Outcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + select.concurrent(.op, f, args) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + + return switch (select.await() catch return .canceled) { + .op => |result| if (result) |_| .ok else |err| switch (err) { + error.Canceled => .canceled, + else => .failed, + }, + // A canceled sleep means this task is being torn down, not that the + // peer went idle. + .expiry => |result| if (result) |_| .timed_out else |_| .canceled, + }; +} + +pub fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { + return budget.sleep(io); +} + +// --------------------------------------------------------------------------- +// the TLS handshake stage +// --------------------------------------------------------------------------- + +/// Runs a TLS handshake under `budget` and owns the exactly-once cleanup of the +/// context it may have created. +/// +/// `stage` is anything with `accept(self) anyerror!void` and `close(self) void`. +/// The select can report the expiry or the cancellation *after* `accept` has in +/// fact succeeded, so a context that exists must be closed on every losing path +/// and on no other: the flag below is written before the race joins its tasks, +/// which is what makes "exactly once" true. Getting this wrong leaks one +/// mbedTLS ssl context per stalled handshake, which is what doh_server did +/// until the milestone-10 review hand-ported dot_server's fix — the duplication +/// this helper exists to end. +/// +/// The caller's required ordering, which stays in the caller because the +/// certificate pin and the plaintext close are the listener's own business: +/// +/// 1. pin the certificate generation (`CertStore.acquire`, released on exit), +/// 2. call `handshakeStage`, +/// 3. on `.ok` only: serve the connection, +/// 4. close the TLS stream (close_notify + free the context), +/// 5. release the pin, then let the slot's `finish` close the TCP stream. +/// +/// On any result other than `.ok` this function has already done step 4 for the +/// caller, and the caller must not repeat it. +pub fn handshakeStage(io: std.Io, budget: std.Io.Clock.Duration, stage: anytype) Result { + const Stage = @TypeOf(stage); + const run = struct { + fn accept(s: Stage, handshook: *bool) anyerror!void { + try s.accept(); + handshook.* = true; + } + }.accept; + + var handshook = false; + const result = race(io, budget, run, .{ stage, &handshook }); + if (result != .ok and handshook) stage.close(); + return result; +} + +// --------------------------------------------------------------------------- +// the framed-message helpers (RFC 1035 §4.2.2; tcp and dot) +// --------------------------------------------------------------------------- + +/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client +/// that closed cleanly between messages, and only a partial prefix is an error. +pub fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void { + out_len.* = try reader.readSliceShort(buf); +} + +pub fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void { + return reader.readSliceAll(buf); +} + +pub fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void { + try writer.writeAll(prefix); + try writer.writeAll(bytes); + try writer.flush(); +} + +// --------------------------------------------------------------------------- +// the claim rule +// --------------------------------------------------------------------------- + +/// The capacity rule, without the mutex, so it is testable without a backend. +/// `conns` is any slice whose element has a `state: ConnState`. +pub fn firstFree(conns: anytype) ?usize { + for (conns, 0..) |*conn, index| { + if (conn.state == .free) return index; + } + return null; +} + +/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free +/// slot is still refused once `deinit` has passed the connections. +pub fn decideClaim(conns: anytype, shutdown_begun: bool) Claim { + if (shutdown_begun) return .shutting_down; + const index = firstFree(conns) orelse return .at_capacity; + return .{ .slot = index }; +} + +// --------------------------------------------------------------------------- +// the core +// --------------------------------------------------------------------------- + +/// The slot pool, the accept loop and the shutdown protocol, parameterized over +/// the four things that genuinely differ between listeners. +/// +/// `Cfg` declares: +/// +/// - `Owner: type` — the listener struct that embeds this core in a field +/// named `core`. The accept loop recovers it with `@fieldParentPtr`, so an +/// owner must not move after `listen`. +/// - `ConnPayload: type` — the rest of one slot: message buffers, a TLS +/// context, a per-request arena. Never touched here. +/// - `serveConn: fn (*Owner, std.Io, usize) void` — one whole connection. The +/// core spawns it, and closes the slot when it returns. +/// - `read_buffer_len` / `write_buffer_len` — the stream staging buffers, which +/// every listener has and sizes differently. +/// - `log` — the owner's `std.log` scope, and `name` — the two or three letters +/// its messages already start with, so the log text does not change. +/// +/// Optional, absent for most listeners: +/// +/// - `refuse: fn (std.Io, net.Stream) void` — what an over-capacity accept does +/// with the stream. The default closes it, which is the only honest answer a +/// DNS listener can give; the web listener answers 503 first. +/// - `initPayload` / `deinitPayload` — for a payload that owns memory (the web +/// listener's per-connection arena). `initPayload` runs inside `listen`, +/// `deinitPayload` inside `deinit` after every connection task has joined. +pub fn Core(comptime Cfg: type) type { + return struct { + const Self = @This(); + + gpa: Allocator, + listener: net.Server, + conns: []Conn, + mutex: std.Io.Mutex, + /// Guarded by `mutex`. `deinit` sets it in the same critical section + /// that shuts the active connections down, so a stream that arrives + /// after that scan can never claim a slot the scan will not visit again. + shutdown_begun: bool, + stats: CoreStats, + run_state: std.atomic.Value(State), + stopped: std.Io.Event, + + pub const Conn = struct { + /// The stream staging buffers. For the plaintext listeners these + /// feed the socket reader and writer; for the TLS ones they are the + /// `ServerStream` plaintext buffers. + read_buf: [Cfg.read_buffer_len]u8, + write_buf: [Cfg.write_buffer_len]u8, + payload: Cfg.ConnPayload, + stream: net.Stream, + /// The client, read off the accepted socket once at claim time: + /// every message on this connection comes from the same peer. + peer: net.IpAddress, + /// Guarded by `Core.mutex`. + state: ConnState, + }; + + pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen( + gpa: Allocator, + io: std.Io, + listen_address: net.IpAddress, + max_connections: u16, + ) ListenError!Self { + std.debug.assert(max_connections > 0); + + const conns = try gpa.alloc(Conn, max_connections); + errdefer gpa.free(conns); + for (conns) |*conn| { + conn.state = .free; + if (@hasDecl(Cfg, "initPayload")) Cfg.initPayload(&conn.payload, gpa); + } + + const listener = try listen_address.listen(io, .{ .reuse_address = true }); + + return .{ + .gpa = gpa, + .listener = listener, + .conns = conns, + .mutex = .init, + .shutdown_begun = false, + .stats = .{}, + .run_state = .init(.idle), + .stopped = .unset, + }; + } + + pub fn deinit(self: *Self, io: std.Io) void { + const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; + + // Shutting the listening socket down is the documented way to + // unblock a pending `accept`: it fails with + // `error.SocketNotListening`. + const stream: net.Stream = .{ .socket = self.listener.socket }; + stream.shutdown(io, .both) catch |err| { + Cfg.log.debug(Cfg.name ++ " listener shutdown failed: {t}", .{err}); + }; + + // A live connection is blocked in a read that only its own budget + // would end, which is seconds away. Shutting each one down bounds + // this, and the same critical section closes the door on new ones. + self.beginShutdown(io); + + if (was_serving) self.stopped.waitUncancelable(io); + + self.listener.deinit(io); + if (@hasDecl(Cfg, "deinitPayload")) { + for (self.conns) |*conn| Cfg.deinitPayload(&conn.payload); + } + self.gpa.free(self.conns); + self.* = undefined; + } + + /// The kernel-assigned address. A port of 0 in `listen` resolves here. + pub fn boundAddress(self: *const Self) net.IpAddress { + return self.listener.socket.address; + } + + /// Accept loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *Self, io: std.Io) void { + if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + switch (self.acceptLoop(io, &group)) { + // `deinit` shut every live connection down before it published + // `.closing`, so each one is already unblocked and ending on + // its own. Awaiting them means a half-written reply still goes + // out whole, and the wait is bounded by the shutdown, not the + // peer. + .closing => { + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + }, + // Nothing has shut these connections down: `deinit` cannot run + // until this task returns, and a peer may hold a connection + // open indefinitely, so draining would let one client stall the + // whole process's shutdown. `cancel` requests cancellation and + // joins, so the slots are quiet — and the buffers still + // unreferenced — by the time `serve` returns; the price is the + // one reply that was mid-write. + .canceled => group.cancel(io), + } + + self.stopped.set(io); + } + + /// The listener that embeds this core. Valid because the core is a + /// field of it and neither may move after `listen`. + pub fn owner(self: *Self) *Cfg.Owner { + return @alignCast(@fieldParentPtr("core", self)); + } + + fn acceptLoop(self: *Self, io: std.Io, group: *std.Io.Group) Stop { + while (self.run_state.load(.acquire) == .serving) { + const stream = self.listener.accept(io) catch |err| switch (err) { + error.Canceled => return .canceled, + // `deinit` shuts the listening socket down to unblock + // exactly this call, so it is the shutdown path arriving + // early. + error.SocketNotListening => return .closing, + else => { + bump(&self.stats.accept_errors); + Cfg.log.debug(Cfg.name ++ " accept failed: {t}", .{err}); + retry_delay.sleep(io) catch return .canceled; + continue; + }, + }; + + const index = switch (self.claim(io, stream)) { + .slot => |index| index, + // Refusing now is honest; a queue would only hide the + // overload. + .at_capacity => { + bump(&self.stats.rejected_at_capacity); + if (@hasDecl(Cfg, "refuse")) Cfg.refuse(io, stream) else stream.close(io); + continue; + }, + // `deinit` will not see this stream in any slot, so serving + // it would hold `deinit` for a whole idle budget. + .shutting_down => { + bump(&self.stats.rejected_at_shutdown); + stream.close(io); + return .closing; + }, + }; + + group.concurrent(io, runConn, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.rejected_at_capacity); + self.finish(io, index); + continue; + }, + }; + + bump(&self.stats.connections); + } + + // The loop condition failed, which only `deinit` can cause. + return .closing; + } + + /// One connection task: the listener's own logic, then the slot close. + /// Every early return inside `Cfg.serveConn` — and its own defers, such + /// as a TLS close_notify — runs before the TCP stream is closed here. + fn runConn(self: *Self, io: std.Io, index: usize) void { + defer self.finish(io, index); + Cfg.serveConn(self.owner(), io, index); + } + + fn claim(self: *Self, io: std.Io, stream: net.Stream) Claim { + // Uncancelable: this section takes no Io and never blocks on a + // peer, so it cannot deadlock, and losing the lock mid-update would + // leak a slot. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const outcome = decideClaim(self.conns, self.shutdown_begun); + switch (outcome) { + .slot => |index| { + self.conns[index].stream = stream; + self.conns[index].peer = stream.socket.address; + self.conns[index].state = .active; + }, + .at_capacity, .shutting_down => {}, + } + return outcome; + } + + fn finish(self: *Self, io: std.Io, index: usize) void { + const conn = &self.conns[index]; + + self.mutex.lockUncancelable(io); + conn.state = .closing; + self.mutex.unlock(io); + + // The socket is released even when this task is being torn down: + // the next cancelable call would otherwise skip the close. + const prev = io.swapCancelProtection(.blocked); + conn.stream.close(io); + _ = io.swapCancelProtection(prev); + + self.mutex.lockUncancelable(io); + conn.state = .free; + self.mutex.unlock(io); + } + + /// Closes the door on new connections and unblocks the live ones. Both + /// happen under one hold of the mutex: a `claim` that runs before this + /// leaves an `.active` slot the loop below shuts down, and a `claim` + /// that runs after it reads `shutdown_begun` and takes no slot at all. + fn beginShutdown(self: *Self, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.shutdown_begun = true; + + for (self.conns) |*conn| { + if (conn.state != .active) continue; + conn.stream.shutdown(io, .both) catch |err| { + Cfg.log.debug(Cfg.name ++ " connection shutdown failed: {t}", .{err}); + }; + } + } + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- +// +// The claim rule is the whole of the shared state machine that can be tested +// without a backend, and it reads nothing but `state`, so these tests use a +// bare slot instead of instantiating a `Core`. They replace six copies that +// lived in tcp_server.zig and dot_server.zig and three more between +// doh_server.zig and web/server.zig. + +const testing = std.testing; + +const TestSlot = struct { state: ConnState }; + +fn testConns(count: usize) ![]TestSlot { + const conns = try testing.allocator.alloc(TestSlot, count); + for (conns) |*conn| conn.state = .free; + return conns; +} + +test "the connection pool hands out every slot once" { + const conns = try testConns(3); + defer testing.allocator.free(conns); + + for (0..conns.len) |expected| { + const index = firstFree(conns) orelse return error.TestUnexpectedResult; + try testing.expectEqual(expected, index); + conns[index].state = .active; + } +} + +test "a full connection pool refuses instead of growing" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + for (conns) |*conn| conn.state = .active; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); +} + +test "a closing slot is not reused until it is free" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + conns[1].state = .closing; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); + + conns[1].state = .free; + try testing.expectEqual(@as(?usize, 1), firstFree(conns)); + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); +} + +test "a claim takes the first free slot before shutdown" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); +} + +test "a claim after shutdown is refused even with a free slot" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); + + // The refusal must not consume the slot: `deinit` frees it, nothing else. + try testing.expectEqual(@as(?usize, 0), firstFree(conns)); +} + +test "shutdown outranks capacity" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); +} diff --git a/src/server/resolver_integration_test.zig b/src/server/resolver_integration_test.zig index f741dc9..1af5482 100644 --- a/src/server/resolver_integration_test.zig +++ b/src/server/resolver_integration_test.zig @@ -310,6 +310,6 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic)); udp.deinit(gpa, io); - tcp.deinit(gpa, io); + tcp.deinit(io); group.cancel(io); } diff --git a/src/server/tcp_server.zig b/src/server/tcp_server.zig index e57276f..8add337 100644 --- a/src/server/tcp_server.zig +++ b/src/server/tcp_server.zig @@ -5,67 +5,43 @@ //! implemented here: a connection is answered serially until the client closes //! it or the idle budget runs out. //! -//! Connection slots are fixed and pre-allocated. Over capacity the listener -//! closes the new stream immediately and counts it; it never queues, and it -//! never allocates per connection. +//! The slot pool, the accept loop and the shutdown protocol are +//! `listener.Core`'s (milestone-18 ruling 1); this file is the per-connection +//! loop and nothing else. Connection slots are fixed and pre-allocated. Over +//! capacity the listener closes the new stream immediately and counts it; it +//! never queues, and it never allocates per connection. //! //! No stream read or write in 0.16.0 accepts a timeout, so every per-connection -//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and -//! the loser is canceled. -//! -//! Shutdown takes one of two paths, and they end the live connections -//! differently on purpose: -//! -//! - `deinit` shuts every active stream down first, so the connections unblock -//! and finish by themselves. `serve` then drains them, and a reply that was -//! half written still goes out whole. -//! - A canceled `serve` cannot drain. `deinit` is what would shut the streams -//! down, and it cannot run until `serve` returns — the composition root -//! cancels its task group before it releases anything (app.zig). Meanwhile -//! RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by -//! asking again inside the idle budget, so draining would let one chatty -//! client stall the whole process's shutdown. The connections are canceled -//! instead, at the cost of the one reply that was mid-write. -//! -//! Either way `serve` returns only once no task can still touch a slot. +//! operation is raced against `Options.idle_timeout` through `listener.race` +//! and the loser is canceled. const std = @import("std"); const address = @import("../platform/address.zig"); const handler = @import("handler.zig"); +const listener = @import("listener.zig"); const transport = @import("../upstream/transport.zig"); -const log = std.log.scoped(.tcp_server); - /// The stream buffers only stage the framing bytes. A message longer than this /// is read straight into `Conn.query` and written straight from `Conn.reply`, /// so making them larger would buy nothing. const stream_buffer_len = 1024; -/// How long the accept loop waits after an unexpected accept failure, so a -/// persistent one cannot turn the loop into a spin. -const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; - pub const Options = struct { max_connections: u16 = 64, /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance. idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, }; -pub const Stats = struct { - accepted: std.atomic.Value(u64) = .init(0), - rejected_at_capacity: std.atomic.Value(u64) = .init(0), - rejected_at_shutdown: std.atomic.Value(u64) = .init(0), - accept_errors: std.atomic.Value(u64) = .init(0), - connection_errors: std.atomic.Value(u64) = .init(0), - idle_timeouts: std.atomic.Value(u64) = .init(0), -}; +/// TCP/53 keeps no counter of its own: the shared six are exactly what it +/// counts. +pub const Stats = listener.CoreStats; /// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the /// `nxdns_tcp_server_*` families. Every counter is exported, including the /// two refusals: a listener that turns clients away at capacity is the thing an /// operator most needs to see, and the module doc promises it is counted. pub const Snapshot = struct { - accepted: u64, + connections: u64, rejected_at_capacity: u64, rejected_at_shutdown: u64, accept_errors: u64, @@ -73,73 +49,36 @@ pub const Snapshot = struct { idle_timeouts: u64, }; -/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes -/// `.closing`, and the two meet at `stopped` so no task touches a connection -/// slot after it is freed. -const State = enum(u32) { idle, serving, closing }; - -/// `.closing` exists so `deinit` never shuts down a descriptor that its own -/// task is about to close: the transition to `.closing` happens under the mutex -/// before the close, and `deinit` only touches `.active` slots. -const ConnState = enum { free, active, closing }; - -/// Why the accept loop stopped, which decides what happens to the connections -/// still in flight. -const Stop = enum { - /// `deinit` published `.closing`. It has already shut every live connection - /// down, so each one is unblocked and finishing on its own. - closing, - /// This task is being canceled. Nothing has touched the connections. - canceled, -}; - -/// What the accept loop does with a stream it has just accepted. -const Claim = union(enum) { - /// The stream owns `conns[index]`. - slot: usize, - /// Every slot is taken. The stream is closed and the loop continues. - at_capacity, - /// `deinit` has started. The stream is closed and the loop returns. - shutting_down, -}; - pub const TcpServer = struct { - server: std.Io.net.Server, + core: listener.Core(Config), handler: *handler.Handler, - conns: []Conn, - mutex: std.Io.Mutex, - /// Guarded by `mutex`. `deinit` sets it in the same critical section that - /// shuts the active connections down, so a stream that arrives after that - /// scan can never claim a slot the scan will not visit again. - shutdown_begun: bool, options: Options, - stats: Stats, - state: std.atomic.Value(State), - stopped: std.Io.Event, /// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which /// is inside the PLAN §18 budget. The two message buffers cannot be shared /// or shrunk: the handler holds the query while the reply is built, and /// both ceilings are the 65535 bytes the length prefix can express. - pub const Conn = struct { + pub const Payload = struct { query: [transport.max_message_len]u8, reply: [transport.max_message_len]u8, - read_buf: [stream_buffer_len]u8, - write_buf: [stream_buffer_len]u8, /// The handler's per-query working memory. It belongs to the slot so /// that answering a message allocates nothing, and a connection is /// answered serially, so one query uses it at a time. scratch: handler.Scratch, - stream: std.Io.net.Stream, - /// The client, read off the accepted socket once at claim time: every - /// message on this connection comes from the same peer, and the handler - /// needs it for rate limiting, groups and the query log. - peer: std.Io.net.IpAddress, - /// Guarded by `TcpServer.mutex`. - state: ConnState, }; - pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; + const Config = struct { + pub const Owner = TcpServer; + pub const ConnPayload = Payload; + pub const serveConn = serveOne; + pub const read_buffer_len = stream_buffer_len; + pub const write_buffer_len = stream_buffer_len; + pub const log = std.log.scoped(.tcp_server); + pub const name = "tcp"; + }; + + pub const Conn = listener.Core(Config).Conn; + pub const ListenError = listener.Core(Config).ListenError; pub fn listen( gpa: std.mem.Allocator, @@ -148,151 +87,49 @@ pub const TcpServer = struct { h: *handler.Handler, options: Options, ) ListenError!TcpServer { - std.debug.assert(options.max_connections > 0); - - const conns = try gpa.alloc(Conn, options.max_connections); - errdefer gpa.free(conns); - for (conns) |*conn| conn.state = .free; - - const local = listen_address; - const server = try local.listen(io, .{ .reuse_address = true }); - return .{ - .server = server, + .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections), .handler = h, - .conns = conns, - .mutex = .init, - .shutdown_begun = false, .options = options, - .stats = .{}, - .state = .init(.idle), - .stopped = .unset, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress { - return self.server.socket.address; + return self.core.boundAddress(); } /// The counters, read one at a time. A scrape that lands mid-accept can see /// a connection counted before its outcome is; a lock would buy a /// consistency no consumer needs. pub fn snapshotStats(self: *const TcpServer) Snapshot { + const stats = &self.core.stats; return .{ - .accepted = self.stats.accepted.load(.monotonic), - .rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic), - .rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic), - .accept_errors = self.stats.accept_errors.load(.monotonic), - .connection_errors = self.stats.connection_errors.load(.monotonic), - .idle_timeouts = self.stats.idle_timeouts.load(.monotonic), + .connections = stats.connections.load(.monotonic), + .rejected_at_capacity = stats.rejected_at_capacity.load(.monotonic), + .rejected_at_shutdown = stats.rejected_at_shutdown.load(.monotonic), + .accept_errors = stats.accept_errors.load(.monotonic), + .connection_errors = stats.connection_errors.load(.monotonic), + .idle_timeouts = stats.idle_timeouts.load(.monotonic), }; } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *TcpServer, io: std.Io) void { - if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; - - var group: std.Io.Group = .init; - switch (self.acceptLoop(io, &group)) { - // `deinit` shut every live connection down before it published - // `.closing`, so each one is already unblocked and ending on its - // own. Awaiting them means a half-written reply still goes out - // whole, and the wait is bounded by the shutdown, not the client. - .closing => { - const prev = io.swapCancelProtection(.blocked); - group.await(io) catch |err| switch (err) { - error.Canceled => unreachable, - }; - _ = io.swapCancelProtection(prev); - }, - // Nothing has shut these connections down: `deinit` cannot run - // until this task returns, and RFC 7766 lets a client hold a - // connection open forever by asking again inside the idle budget. - // Draining here would therefore let one client stall the whole - // process's shutdown for as long as it likes. `cancel` requests - // cancellation and joins, so the slots are still quiet — and the - // buffers still unreferenced — by the time `serve` returns; the - // price is the one reply that was mid-write. - .canceled => group.cancel(io), - } - - self.stopped.set(io); + self.core.serve(io); } - pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void { - const was_serving = self.state.swap(.closing, .acq_rel) == .serving; - - // Shutting the listening socket down is the documented way to unblock a - // pending `accept`: it fails with `error.SocketNotListening`. - const listener: std.Io.net.Stream = .{ .socket = self.server.socket }; - listener.shutdown(io, .both) catch |err| { - log.debug("tcp listener shutdown failed: {t}", .{err}); - }; - - // A live connection is blocked in a read that only the idle budget - // would end, which is seconds away. Shutting each one down bounds this, - // and the same critical section closes the door on new connections. - self.beginShutdown(io); - - if (was_serving) self.stopped.waitUncancelable(io); - - self.server.deinit(io); - gpa.free(self.conns); + pub fn deinit(self: *TcpServer, io: std.Io) void { + self.core.deinit(io); self.* = undefined; } - fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop { - while (self.state.load(.acquire) == .serving) { - const stream = self.server.accept(io) catch |err| switch (err) { - error.Canceled => return .canceled, - // `deinit` shuts the listening socket down to unblock exactly - // this call, so it is the shutdown path arriving early. - error.SocketNotListening => return .closing, - else => { - bump(&self.stats.accept_errors); - log.debug("tcp accept failed: {t}", .{err}); - retry_delay.sleep(io) catch return .canceled; - continue; - }, - }; - - const index = switch (self.claim(io, stream)) { - .slot => |index| index, - // Refusing now is honest; a queue would only hide the overload. - .at_capacity => { - bump(&self.stats.rejected_at_capacity); - stream.close(io); - continue; - }, - // `deinit` will not see this stream in any slot, so serving it - // would hold `deinit` for the whole idle budget. - .shutting_down => { - bump(&self.stats.rejected_at_shutdown); - stream.close(io); - return .closing; - }, - }; - - group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { - error.ConcurrencyUnavailable => { - bump(&self.stats.rejected_at_capacity); - self.finish(io, index); - continue; - }, - }; - - bump(&self.stats.accepted); - } - - // The loop condition failed, which only `deinit` can cause. - return .closing; - } - - fn serveConn(self: *TcpServer, io: std.Io, index: usize) void { - defer self.finish(io, index); - - const conn = &self.conns[index]; + /// One connection, answered serially until the client closes it, the idle + /// budget runs out, or a framing error ends it. The core closes the slot + /// when this returns. + fn serveOne(self: *TcpServer, io: std.Io, index: usize) void { + const conn = &self.core.conns[index]; + const stats = &self.core.stats; var reader = conn.stream.reader(io, &conn.read_buf); var writer = conn.stream.writer(io, &conn.write_buf); const budget = self.options.idle_timeout; @@ -300,15 +137,15 @@ pub const TcpServer = struct { while (true) { var prefix: [transport.prefix_len]u8 = undefined; var got: usize = 0; - switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) { + switch (listener.race(io, budget, listener.readPrefix, .{ &reader.interface, &prefix, &got })) { .ok => {}, .timed_out => { - bump(&self.stats.idle_timeouts); + listener.bump(&stats.idle_timeouts); return; }, .canceled => return, .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } @@ -317,7 +154,7 @@ pub const TcpServer = struct { // is the normal end of a connection, not a failure. if (got == 0) return; if (got != transport.prefix_len) { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; } @@ -325,16 +162,16 @@ pub const TcpServer = struct { // the prefix is a u16 so it can never exceed `max_message_len`. const len = transport.parsePrefix(prefix); if (len == 0) { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; } - switch (race(io, budget, readBody, .{ &reader.interface, conn.query[0..len] })) { + switch (listener.race(io, budget, listener.readBody, .{ &reader.interface, conn.payload.query[0..len] })) { .ok => {}, .canceled => return, // A half-sent message is a broken peer, not an idle one. .timed_out, .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } @@ -343,9 +180,9 @@ pub const TcpServer = struct { io, .tcp, address.NetAddress.fromIp(conn.peer), - conn.query[0..len], - &conn.reply, - &conn.scratch, + conn.payload.query[0..len], + &conn.payload.reply, + &conn.payload.scratch, ); const bytes = switch (outcome) { // There is no framing for "no answer", so the connection ends. @@ -354,210 +191,14 @@ pub const TcpServer = struct { }; const out = transport.framePrefix(@intCast(bytes.len)); - switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) { + switch (listener.race(io, budget, listener.writeReply, .{ &writer.interface, &out, bytes })) { .ok => {}, .canceled => return, .timed_out, .failed => { - bump(&self.stats.connection_errors); + listener.bump(&stats.connection_errors); return; }, } } } - - fn claim(self: *TcpServer, io: std.Io, stream: std.Io.net.Stream) Claim { - // Uncancelable: this section takes no Io and never blocks on a peer, so - // it cannot deadlock, and losing the lock mid-update would leak a slot. - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - const outcome = decideClaim(self.conns, self.shutdown_begun); - switch (outcome) { - .slot => |index| { - self.conns[index].stream = stream; - self.conns[index].peer = stream.socket.address; - self.conns[index].state = .active; - }, - .at_capacity, .shutting_down => {}, - } - return outcome; - } - - fn finish(self: *TcpServer, io: std.Io, index: usize) void { - const conn = &self.conns[index]; - - self.mutex.lockUncancelable(io); - conn.state = .closing; - self.mutex.unlock(io); - - // The socket is released even when this task is being torn down: the - // next cancelable call would otherwise skip the close. - const prev = io.swapCancelProtection(.blocked); - conn.stream.close(io); - _ = io.swapCancelProtection(prev); - - self.mutex.lockUncancelable(io); - conn.state = .free; - self.mutex.unlock(io); - } - - /// Closes the door on new connections and unblocks the live ones. Both - /// happen under one hold of the mutex: a `claim` that runs before this - /// leaves an `.active` slot the loop below shuts down, and a `claim` that - /// runs after it reads `shutdown_begun` and takes no slot at all. - fn beginShutdown(self: *TcpServer, io: std.Io) void { - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - self.shutdown_begun = true; - - for (self.conns) |*conn| { - if (conn.state != .active) continue; - conn.stream.shutdown(io, .both) catch |err| { - log.debug("tcp connection shutdown failed: {t}", .{err}); - }; - } - } }; - -/// The capacity rule, without the mutex, so it is testable without a backend. -fn firstFree(conns: []const TcpServer.Conn) ?usize { - for (conns, 0..) |*conn, index| { - if (conn.state == .free) return index; - } - return null; -} - -/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free -/// slot is still refused once `deinit` has passed the connections. -fn decideClaim(conns: []const TcpServer.Conn, shutdown_begun: bool) Claim { - if (shutdown_begun) return .shutting_down; - const index = firstFree(conns) orelse return .at_capacity; - return .{ .slot = index }; -} - -const Outcome = union(enum) { - op: anyerror!void, - expiry: std.Io.Cancelable!void, -}; - -const Result = enum { ok, timed_out, failed, canceled }; - -/// Runs one connection operation against the idle budget and cancels the loser. -fn race( - io: std.Io, - budget: std.Io.Clock.Duration, - comptime f: anytype, - args: std.meta.ArgsTuple(@TypeOf(f)), -) Result { - var outcomes: [2]Outcome = undefined; - var select: std.Io.Select(Outcome) = .init(io, &outcomes); - defer select.cancelDiscard(); - - select.concurrent(.op, f, args) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return .failed, - }; - - return switch (select.await() catch return .canceled) { - .op => |result| if (result) |_| .ok else |err| switch (err) { - error.Canceled => .canceled, - else => .failed, - }, - // A canceled sleep means this task is being torn down, not that the - // client went idle. - .expiry => |result| if (result) |_| .timed_out else |_| .canceled, - }; -} - -fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { - return budget.sleep(io); -} - -/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client -/// that closed cleanly between messages, and only a partial prefix is an error. -fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void { - out_len.* = try reader.readSliceShort(buf); -} - -fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void { - return reader.readSliceAll(buf); -} - -fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void { - try writer.writeAll(prefix); - try writer.writeAll(bytes); - try writer.flush(); -} - -fn bump(counter: *std.atomic.Value(u64)) void { - _ = counter.fetchAdd(1, .monotonic); -} - -const testing = std.testing; - -fn testConns(count: usize) ![]TcpServer.Conn { - const conns = try testing.allocator.alloc(TcpServer.Conn, count); - for (conns) |*conn| conn.state = .free; - return conns; -} - -test "the connection pool hands out every slot once" { - const conns = try testConns(3); - defer testing.allocator.free(conns); - - for (0..conns.len) |expected| { - const index = firstFree(conns) orelse return error.TestUnexpectedResult; - try testing.expectEqual(expected, index); - conns[index].state = .active; - } -} - -test "a full connection pool refuses instead of growing" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - for (conns) |*conn| conn.state = .active; - try testing.expectEqual(@as(?usize, null), firstFree(conns)); -} - -test "a closing slot is not reused until it is free" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - conns[0].state = .active; - conns[1].state = .closing; - try testing.expectEqual(@as(?usize, null), firstFree(conns)); - - conns[1].state = .free; - try testing.expectEqual(@as(?usize, 1), firstFree(conns)); -} - -test "a claim takes the first free slot before shutdown" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - conns[0].state = .active; - try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); -} - -test "a claim after shutdown is refused even with a free slot" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); - - // The refusal must not consume the slot: `deinit` frees it, nothing else. - try testing.expectEqual(@as(?usize, 0), firstFree(conns)); -} - -test "shutdown outranks capacity" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - conns[0].state = .active; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); -} diff --git a/src/server/tcp_server_integration_test.zig b/src/server/tcp_server_integration_test.zig index 2612442..d9c0486 100644 --- a/src/server/tcp_server_integration_test.zig +++ b/src/server/tcp_server_integration_test.zig @@ -184,11 +184,11 @@ test "two length-prefixed queries share one connection" { try bounded(io, twoQueriesOnOneConnection, .{ io, server_address }); - try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); - try testing.expectEqual(@as(u64, 0), server.stats.rejected_at_capacity.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic)); try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); - server.deinit(gpa, io); + server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -218,12 +218,12 @@ test "the claimed slot records the connecting client" { // was written after `claim` filled the slot in, so this read races nothing. // Without a real peer the handler would rate-limit, group and log every TCP // client under whatever the uninitialized slot happened to hold. - const peer = server.conns[0].peer; + const peer = server.core.conns[0].peer; try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer)); try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes); try testing.expect(peer.ip4.port != 0); - server.deinit(gpa, io); + server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -317,7 +317,7 @@ test "a canceled serve does not wait for a live connection" { try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server)); client_group.cancel(io); - server.deinit(gpa, io); + server.deinit(io); // Checked last: the connection had to be answered for the test to mean // anything, and the server is torn down before a failure is reported. @@ -327,7 +327,7 @@ test "a canceled serve does not wait for a live connection" { /// The first slot the server would hand out, read after `serve` has returned so /// nothing can be writing it. fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize { - for (server.conns, 0..) |*conn, index| { + for (server.core.conns, 0..) |*conn, index| { if (conn.state == .free) return index; } return null; @@ -356,11 +356,11 @@ test "an idle connection is closed and counted" { try bounded(io, waitForServerClose, .{ io, server_address }); - try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); - try testing.expectEqual(@as(u64, 1), server.stats.idle_timeouts.load(.monotonic)); - try testing.expectEqual(@as(u64, 0), server.stats.connection_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), server.core.stats.idle_timeouts.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic)); - server.deinit(gpa, io); + server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -389,10 +389,10 @@ test "a zero-length message is a connection error" { try bounded(io, sendZeroLength, .{ io, server_address }); - try testing.expectEqual(@as(u64, 1), server.stats.connection_errors.load(.monotonic)); - try testing.expectEqual(@as(u64, 0), server.stats.idle_timeouts.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), server.core.stats.connection_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.core.stats.idle_timeouts.load(.monotonic)); - server.deinit(gpa, io); + server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -436,7 +436,7 @@ test "deinit ends a serve loop that is blocked on accept" { try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); // No client ever connects, so `serve` is inside an accept when this runs. - server.deinit(gpa, io); + server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; diff --git a/src/storage/repositories/clients_repo.zig b/src/storage/repositories/clients_repo.zig index 46b0ce3..b27594a 100644 --- a/src/storage/repositories/clients_repo.zig +++ b/src/storage/repositories/clients_repo.zig @@ -38,35 +38,23 @@ const list_clients_sql = /// Every string in the result is a heap copy owned by `gpa`. pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) { - var stmt = try database.prepare(list_clients_sql); - defer stmt.deinit(); + return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient); +} - var out: std.ArrayList(model.Client) = .empty; - // `errdefer`s run in reverse: `freeClients` is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeClients(gpa, out.items); - - while (try stmt.step()) { - const ip = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(ip); - // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which - // is exactly the model's default. - const name = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(name); - const group = try stmt.columnTextAlloc(gpa, 2); - errdefer gpa.free(group); - try out.append(gpa, .{ .ip = ip, .name = name, .group = group }); - } - return out; +fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client { + const ip = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(ip); + // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is + // exactly the model's default. + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + const group = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(group); + return .{ .ip = ip, .name = name, .group = group }; } pub fn freeClients(gpa: Allocator, items: []const model.Client) void { - for (items) |item| { - gpa.free(item.ip); - gpa.free(item.name); - gpa.free(item.group); - } + crud.freeRows(model.Client, gpa, items); } const insert_client_sql = @@ -154,31 +142,22 @@ const list_client_prefixes_sql = ; pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) { - var stmt = try database.prepare(list_client_prefixes_sql); - defer stmt.deinit(); + return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix); +} - var out: std.ArrayList(model.ClientPrefix) = .empty; - errdefer out.deinit(gpa); - errdefer freeClientPrefixes(gpa, out.items); - - while (try stmt.step()) { - const prefix = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(prefix); - const group = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(group); - // The column is a 64-bit integer; the model field is `i32`. A value - // outside that range means something other than nxdns wrote the row. - const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch; - try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority }); - } - return out; +fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix { + const prefix = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(prefix); + const group = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(group); + // The column is a 64-bit integer; the model field is `i32`. A value outside + // that range means something other than nxdns wrote the row. + const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch; + return .{ .prefix = prefix, .group = group, .priority = priority }; } pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void { - for (items) |item| { - gpa.free(item.prefix); - gpa.free(item.group); - } + crud.freeRows(model.ClientPrefix, gpa, items); } pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void { @@ -257,29 +236,15 @@ const get_client_sql = /// Every client, materialised ones included. Every string is a heap copy owned /// by `gpa`. pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) { - var stmt = try database.prepare(list_client_rows_sql); - defer stmt.deinit(); - - var out: std.ArrayList(ClientRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeClientRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readClientRow(&stmt, gpa); - errdefer freeClientRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow); } pub fn freeClientRow(gpa: Allocator, row: ClientRow) void { - gpa.free(row.ip); - gpa.free(row.name); - gpa.free(row.group); + crud.freeRow(ClientRow, gpa, row); } pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void { - for (items) |item| freeClientRow(gpa, item); + crud.freeRows(ClientRow, gpa, items); } pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow { @@ -383,39 +348,32 @@ const list_client_prefix_rows_sql = /// Same order as `listClientPrefixes`; every string is a heap copy owned by /// `gpa`. pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) { - var stmt = try database.prepare(list_client_prefix_rows_sql); - defer stmt.deinit(); + return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow); +} - var out: std.ArrayList(ClientPrefixRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeClientPrefixRows(gpa, out.items); - - while (try stmt.step()) { - const prefix = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(prefix); - const group = try stmt.columnTextAlloc(gpa, 3); - errdefer gpa.free(group); - // The column is a 64-bit integer; the row field is `i32`. A value - // outside that range means something other than nxdns wrote the row. - const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch; - try out.append(gpa, .{ - .id = stmt.columnInt(0), - .prefix = prefix, - .group_id = stmt.columnInt(2), - .group = group, - .priority = priority, - }); - } - return out; +fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow { + const prefix = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(prefix); + const group = try stmt.columnTextAlloc(gpa, 3); + errdefer gpa.free(group); + // The column is a 64-bit integer; the row field is `i32`. A value outside + // that range means something other than nxdns wrote the row. + const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch; + return .{ + .id = stmt.columnInt(0), + .prefix = prefix, + .group_id = stmt.columnInt(2), + .group = group, + .priority = priority, + }; } pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void { - gpa.free(row.prefix); - gpa.free(row.group); + crud.freeRow(ClientPrefixRow, gpa, row); } pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void { - for (items) |item| freeClientPrefixRow(gpa, item); + crud.freeRows(ClientPrefixRow, gpa, items); } /// Replaces the whole prefix table inside a transaction (ruling 9 makes diff --git a/src/storage/repositories/crud.zig b/src/storage/repositories/crud.zig index f73e891..61f02aa 100644 --- a/src/storage/repositories/crud.zig +++ b/src/storage/repositories/crud.zig @@ -8,8 +8,14 @@ //! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the //! handler layer maps it to 409. Each mutation documents which constraint of //! `config_schema.ddl_v1` can fire. +//! +//! `listRows` and `freeRows` are the read half. Every `list*` function in this +//! directory reads rows into a `std.ArrayList` under the same unwind rules, and +//! the errdefer ordering those rules need is easy to write backwards. It is +//! written once here. const std = @import("std"); +const Allocator = std.mem.Allocator; const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); @@ -25,6 +31,102 @@ pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void { if (database.changes() == 0) return error.NotFound; } +/// Reads every row `sql` produces into a list, with the memory-safety +/// choreography every `list*` function in this directory shares. +/// +/// `readRow` allocates the row's owning fields from `gpa` and carries its own +/// per-column `errdefer`s, so a row that fails halfway releases the columns it +/// already read. This function owns everything around that: a failure after the +/// first append releases the rows already in the list and then the list itself. +/// +/// The result is the caller's: free the rows with `freeRows` (or the repository +/// shim over it) and then `deinit` the list. +pub fn listRows( + comptime Row: type, + database: *db.Db, + gpa: Allocator, + comptime sql: []const u8, + comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row, +) db.Error!std.ArrayList(Row) { + return listRowsBound(Row, database, gpa, sql, readRow, .{}); +} + +/// `listRows` for a statement with parameters. `args` is a tuple bound to +/// positions 1..n in order. +pub fn listRowsBound( + comptime Row: type, + database: *db.Db, + gpa: Allocator, + comptime sql: []const u8, + comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row, + args: anytype, +) db.Error!std.ArrayList(Row) { + var stmt = try database.prepare(sql); + defer stmt.deinit(); + inline for (args, 0..) |arg, i| try bindArg(&stmt, i + 1, arg); + + var out: std.ArrayList(Row) = .empty; + // Order matters: `errdefer`s run in reverse, so the free pass is declared + // *after* `deinit` to run *before* it. The other order reads `out.items` + // after the backing array is gone. + errdefer out.deinit(gpa); + errdefer freeRows(Row, gpa, out.items); + + while (try stmt.step()) { + const row = try readRow(&stmt, gpa); + errdefer freeRow(Row, gpa, row); + try out.append(gpa, row); + } + return out; +} + +fn bindArg(stmt: *db.Stmt, idx: c_int, arg: anytype) db.Error!void { + const Arg = @TypeOf(arg); + if (Arg == []const u8 or Arg == []u8) return stmt.bindText(idx, arg); + return switch (@typeInfo(Arg)) { + .bool => stmt.bindInt(idx, @intFromBool(arg)), + .int, .comptime_int => stmt.bindInt(idx, arg), + else => @compileError("crud.listRowsBound: cannot bind a " ++ @typeName(Arg)), + }; +} + +/// Releases every owning field of every row `listRows` produced. +pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void { + for (items) |item| freeRow(Row, gpa, item); +} + +/// Releases the owning fields of one row. +/// +/// A repository row owns its heap memory in exactly two shapes: `[]const u8` +/// and `?[]const u8` (`SourceRow.checksum` is the optional one — a shallow +/// slice-only reflection would leak its payload). Every other field must be a +/// plain value the row does not own. A field of any other shape is a +/// `@compileError`, so a row that grows a nested allocation cannot start +/// leaking silently: whoever adds it has to teach this function first. +pub fn freeRow(comptime Row: type, gpa: Allocator, row: Row) void { + switch (@typeInfo(Row)) { + .@"struct" => |info| inline for (info.fields) |field| { + freeField(field.type, @typeName(Row) ++ "." ++ field.name, gpa, @field(row, field.name)); + }, + else => freeField(Row, @typeName(Row), gpa, row), + } +} + +fn freeField(comptime Field: type, comptime where: []const u8, gpa: Allocator, value: Field) void { + if (Field == []const u8 or Field == []u8) return gpa.free(value); + if (Field == ?[]const u8 or Field == ?[]u8) return if (value) |owned| gpa.free(owned); + comptime assertUnowning(Field, where); +} + +fn assertUnowning(comptime Field: type, comptime where: []const u8) void { + switch (@typeInfo(Field)) { + .bool, .int, .float, .@"enum", .void => {}, + .optional => |info| assertUnowning(info.child, where), + else => @compileError("crud.freeRow: " ++ where ++ " is a " ++ @typeName(Field) ++ + ", which is neither a plain value nor an owning slice; teach freeRow how to release it"), + } +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -78,3 +180,91 @@ test "execStrict reports NotFound for an id no row holds" { try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones")); } + +/// Carries both owning shapes a repository row may hold: a `[]const u8` that is +/// always there, and the `?[]const u8` of `SourceRow.checksum`. +const TestRow = struct { + id: i64, + url: []const u8, + checksum: ?[]const u8, +}; + +const test_rows_sql = "SELECT id, url, checksum FROM blocklist_sources ORDER BY id"; + +fn readTestRow(stmt: *db.Stmt, gpa: Allocator) db.Error!TestRow { + const url = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(url); + const checksum = try stmt.columnTextAllocOrNull(gpa, 2); + errdefer if (checksum) |value| gpa.free(value); + return .{ .id = stmt.columnInt(0), .url = url, .checksum = checksum }; +} + +/// Two rows: the first carries a checksum, the second leaves it NULL, so one +/// read exercises both arms of the optional. +fn seedTestRows(database: *db.Db) !void { + try database.exec( + \\INSERT INTO blocklist_sources (id, url, name, checksum) VALUES + \\ (1, 'https://a.example/list.txt', 'A', 'aaaa'), + \\ (2, 'https://b.example/list.txt', 'B', NULL); + ); +} + +test "listRows reads every row and freeRows releases both owning shapes" { + var database = try openTable(); + defer database.close(); + try seedTestRows(&database); + + var rows = try listRows(TestRow, &database, testing.allocator, test_rows_sql, readTestRow); + defer rows.deinit(testing.allocator); + defer freeRows(TestRow, testing.allocator, rows.items); + + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url); + // The leak detector is what proves this payload is released. + try testing.expectEqualStrings("aaaa", rows.items[0].checksum.?); + try testing.expectEqual(@as(?[]const u8, null), rows.items[1].checksum); +} + +fn listRowsUnderFailure(gpa: Allocator) !void { + var database = try openTable(); + defer database.close(); + try seedTestRows(&database); + + var rows = try listRows(TestRow, &database, gpa, test_rows_sql, readTestRow); + defer rows.deinit(gpa); + defer freeRows(TestRow, gpa, rows.items); + + // A row with a non-null checksum must be in the result, or the failure + // injection never reaches the optional's allocation. + try testing.expect(rows.items[0].checksum != null); +} + +test "listRows is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listRowsUnderFailure, .{}); +} + +test "listRowsBound binds its arguments in tuple order" { + var database = try openTable(); + defer database.close(); + try seedTestRows(&database); + + var rows = try listRowsBound( + TestRow, + &database, + testing.allocator, + "SELECT id, url, checksum FROM blocklist_sources WHERE id > ?1 AND name = ?2", + readTestRow, + .{ @as(i64, 1), @as([]const u8, "B") }, + ); + defer rows.deinit(testing.allocator); + defer freeRows(TestRow, testing.allocator, rows.items); + + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqual(@as(i64, 2), rows.items[0].id); +} + +test "freeRows over a row type with no owning field is a no-op" { + // `listGroupSourceIds` reads a bare `i64`; the reflection must accept a Row + // that is not a struct at all. + freeRows(i64, testing.allocator, &.{ 1, 2, 3 }); +} diff --git a/src/storage/repositories/groups_repo.zig b/src/storage/repositories/groups_repo.zig index e2451f9..31f67d1 100644 --- a/src/storage/repositories/groups_repo.zig +++ b/src/storage/repositories/groups_repo.zig @@ -27,26 +27,23 @@ const InsertContext = context.InsertContext; /// Every string in the result is a heap copy owned by `gpa`; free the whole /// list with `freeGroups` and then `deinit` the list itself. pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) { - var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name"); - defer stmt.deinit(); + return crud.listRows( + model.Group, + database, + gpa, + "SELECT name, safe_search FROM groups ORDER BY name", + readGroup, + ); +} - var out: std.ArrayList(model.Group) = .empty; - // Order matters: `errdefer`s run in reverse, so `freeGroups` must be - // declared *after* `deinit` to run *before* it. The other order reads - // `out.items` after the backing array is gone. - errdefer out.deinit(gpa); - errdefer freeGroups(gpa, out.items); - - while (try stmt.step()) { - const name = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(name); - try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) }); - } - return out; +fn readGroup(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Group { + const name = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(name); + return .{ .name = name, .safe_search = stmt.columnBool(1) }; } pub fn freeGroups(gpa: Allocator, items: []const model.Group) void { - for (items) |item| gpa.free(item.name); + crud.freeRows(model.Group, gpa, items); } pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void { @@ -91,28 +88,19 @@ const list_group_sources_sql = /// The two foreign keys are `NOT NULL` and enforced, so the join is total: a /// `group_sources` row can never be dropped by it. pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) { - var stmt = try database.prepare(list_group_sources_sql); - defer stmt.deinit(); + return crud.listRows(model.GroupSource, database, gpa, list_group_sources_sql, readGroupSource); +} - var out: std.ArrayList(model.GroupSource) = .empty; - errdefer out.deinit(gpa); - errdefer freeGroupSources(gpa, out.items); - - while (try stmt.step()) { - const group = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(group); - const source_url = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(source_url); - try out.append(gpa, .{ .group = group, .source_url = source_url }); - } - return out; +fn readGroupSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.GroupSource { + const group = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(group); + const source_url = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(source_url); + return .{ .group = group, .source_url = source_url }; } pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void { - for (items) |item| { - gpa.free(item.group); - gpa.free(item.source_url); - } + crud.freeRows(model.GroupSource, gpa, items); } pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void { @@ -145,31 +133,27 @@ pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool }; /// Same order as `listGroups`; every string is a heap copy owned by `gpa`. pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) { - var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name"); - defer stmt.deinit(); + return crud.listRows( + GroupRow, + database, + gpa, + "SELECT id, name, safe_search FROM groups ORDER BY name", + readGroupRow, + ); +} - var out: std.ArrayList(GroupRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeGroupRows(gpa, out.items); - - while (try stmt.step()) { - const name = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(name); - try out.append(gpa, .{ - .id = stmt.columnInt(0), - .name = name, - .safe_search = stmt.columnBool(2), - }); - } - return out; +fn readGroupRow(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupRow { + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + return .{ .id = stmt.columnInt(0), .name = name, .safe_search = stmt.columnBool(2) }; } pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void { - gpa.free(row.name); + crud.freeRow(GroupRow, gpa, row); } pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void { - for (items) |item| freeGroupRow(gpa, item); + crud.freeRows(GroupRow, gpa, items); } pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow { @@ -177,11 +161,7 @@ pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow { defer stmt.deinit(); try stmt.bindInt(1, id); if (!try stmt.step()) return null; - return .{ - .id = stmt.columnInt(0), - .name = try stmt.columnTextAlloc(gpa, 1), - .safe_search = stmt.columnBool(2), - }; + return try readGroupRow(&stmt, gpa); } /// `error.Constraint`: `groups.name` is UNIQUE. @@ -220,14 +200,19 @@ pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void { /// `group_id` yields an empty list, not an error: the caller that needs the /// distinction reads the group itself. pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) { - var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id"); - defer stmt.deinit(); - try stmt.bindInt(1, group_id); + return crud.listRowsBound( + i64, + database, + gpa, + "SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id", + readSourceId, + .{group_id}, + ); +} - var out: std.ArrayList(i64) = .empty; - errdefer out.deinit(gpa); - while (try stmt.step()) try out.append(gpa, stmt.columnInt(0)); - return out; +fn readSourceId(stmt: *db.Stmt, gpa: Allocator) db.Error!i64 { + _ = gpa; + return stmt.columnInt(0); } /// Replaces one group's whole source assignment inside a transaction, so a diff --git a/src/storage/repositories/local_repo.zig b/src/storage/repositories/local_repo.zig index 989d395..ad4d4c4 100644 --- a/src/storage/repositories/local_repo.zig +++ b/src/storage/repositories/local_repo.zig @@ -25,34 +25,23 @@ const list_local_records_sql = /// Every string in the result is a heap copy owned by `gpa`. pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) { - var stmt = try database.prepare(list_local_records_sql); - defer stmt.deinit(); + return crud.listRows(model.LocalRecord, database, gpa, list_local_records_sql, readLocalRecord); +} - var out: std.ArrayList(model.LocalRecord) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeLocalRecords(gpa, out.items); - - while (try stmt.step()) { - const name = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(name); - const value = try stmt.columnTextAlloc(gpa, 2); - errdefer gpa.free(value); - // The DDL's CHECK constraint makes the decode total for any row nxdns - // wrote; `error.Mismatch` covers a row that something else wrote. - const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch; - const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch; - try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl }); - } - return out; +fn readLocalRecord(stmt: *db.Stmt, gpa: Allocator) db.Error!model.LocalRecord { + // The DDL's CHECK constraint makes the decode total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote. + const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch; + const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch; + const name = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(name); + const value = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(value); + return .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl }; } pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void { - for (items) |item| { - gpa.free(item.name); - gpa.free(item.value); - } + crud.freeRows(model.LocalRecord, gpa, items); } const insert_local_record_sql = @@ -83,28 +72,25 @@ pub fn countLocalRecords(database: *db.Db) db.Error!i64 { // --------------------------------------------------------------------------- pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) { - var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone"); - defer stmt.deinit(); + return crud.listRows( + model.ForwardZone, + database, + gpa, + "SELECT zone, resolver FROM forward_zones ORDER BY zone", + readForwardZone, + ); +} - var out: std.ArrayList(model.ForwardZone) = .empty; - errdefer out.deinit(gpa); - errdefer freeForwardZones(gpa, out.items); - - while (try stmt.step()) { - const zone = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(zone); - const resolver = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(resolver); - try out.append(gpa, .{ .zone = zone, .resolver = resolver }); - } - return out; +fn readForwardZone(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ForwardZone { + const zone = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(zone); + const resolver = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(resolver); + return .{ .zone = zone, .resolver = resolver }; } pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void { - for (items) |item| { - gpa.free(item.zone); - gpa.free(item.resolver); - } + crud.freeRows(model.ForwardZone, gpa, items); } pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void { @@ -145,28 +131,15 @@ const list_local_record_rows_sql = /// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`. pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) { - var stmt = try database.prepare(list_local_record_rows_sql); - defer stmt.deinit(); - - var out: std.ArrayList(LocalRecordRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeLocalRecordRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readLocalRecordRow(&stmt, gpa); - errdefer freeLocalRecordRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows(LocalRecordRow, database, gpa, list_local_record_rows_sql, readLocalRecordRow); } pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void { - gpa.free(row.name); - gpa.free(row.value); + crud.freeRow(LocalRecordRow, gpa, row); } pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void { - for (items) |item| freeLocalRecordRow(gpa, item); + crud.freeRows(LocalRecordRow, gpa, items); } pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow { @@ -232,28 +205,21 @@ pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const /// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`. pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) { - var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone"); - defer stmt.deinit(); - - var out: std.ArrayList(ForwardZoneRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeForwardZoneRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readForwardZoneRow(&stmt, gpa); - errdefer freeForwardZoneRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows( + ForwardZoneRow, + database, + gpa, + "SELECT id, zone, resolver FROM forward_zones ORDER BY zone", + readForwardZoneRow, + ); } pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void { - gpa.free(row.zone); - gpa.free(row.resolver); + crud.freeRow(ForwardZoneRow, gpa, row); } pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void { - for (items) |item| freeForwardZoneRow(gpa, item); + crud.freeRows(ForwardZoneRow, gpa, items); } pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow { diff --git a/src/storage/repositories/rules_repo.zig b/src/storage/repositories/rules_repo.zig index 5ce39e4..508c417 100644 --- a/src/storage/repositories/rules_repo.zig +++ b/src/storage/repositories/rules_repo.zig @@ -37,34 +37,23 @@ const list_sql = /// Every string in the result is a heap copy owned by `gpa`. pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) { - var stmt = try database.prepare(list_sql); - defer stmt.deinit(); + return crud.listRows(model.Rule, database, gpa, list_sql, readRule); +} - var out: std.ArrayList(model.Rule) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeRules(gpa, out.items); - - while (try stmt.step()) { - const group = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(group); - const pattern = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(pattern); - // The DDL's CHECK constraints make both decodes total for any row nxdns - // wrote; `error.Mismatch` covers a row that something else wrote. - const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch; - const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch; - try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action }); - } - return out; +fn readRule(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Rule { + // The DDL's CHECK constraints make both decodes total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote. + const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch; + const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch; + const group = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(group); + const pattern = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(pattern); + return .{ .group = group, .pattern = pattern, .kind = kind, .action = action }; } pub fn freeRules(gpa: Allocator, items: []const model.Rule) void { - for (items) |item| { - gpa.free(item.group); - gpa.free(item.pattern); - } + crud.freeRows(model.Rule, gpa, items); } const insert_sql = @@ -132,28 +121,15 @@ const get_rule_sql = /// Same order as `listRules`; every string is a heap copy owned by `gpa`. pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) { - var stmt = try database.prepare(list_rule_rows_sql); - defer stmt.deinit(); - - var out: std.ArrayList(RuleRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeRuleRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readRuleRow(&stmt, gpa); - errdefer freeRuleRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows(RuleRow, database, gpa, list_rule_rows_sql, readRuleRow); } pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void { - gpa.free(row.group); - gpa.free(row.pattern); + crud.freeRow(RuleRow, gpa, row); } pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void { - for (items) |item| freeRuleRow(gpa, item); + crud.freeRows(RuleRow, gpa, items); } pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow { diff --git a/src/storage/repositories/settings_repo.zig b/src/storage/repositories/settings_repo.zig index cf8e511..8e35baa 100644 --- a/src/storage/repositories/settings_repo.zig +++ b/src/storage/repositories/settings_repo.zig @@ -15,38 +15,34 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const InsertContext = context.InsertContext; /// Both strings of every pair are heap copies owned by `gpa`. pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) { - var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key"); - defer stmt.deinit(); + return crud.listRows( + model.SettingPair, + database, + gpa, + "SELECT key, value FROM settings ORDER BY key", + readSetting, + ); +} - var out: std.ArrayList(model.SettingPair) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeSettings(gpa, out.items); - - while (try stmt.step()) { - const key = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(key); - const value = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(value); - try out.append(gpa, .{ .key = key, .value = value }); - } - return out; +fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair { + const key = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(key); + const value = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(value); + return .{ .key = key, .value = value }; } /// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose /// `key` is a comptime string and must never be freed; that list is the caller's /// to release, field by field. pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void { - for (items) |item| { - gpa.free(item.key); - gpa.free(item.value); - } + crud.freeRows(model.SettingPair, gpa, items); } pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void { diff --git a/src/storage/repositories/sources_repo.zig b/src/storage/repositories/sources_repo.zig index fec3730..8122be0 100644 --- a/src/storage/repositories/sources_repo.zig +++ b/src/storage/repositories/sources_repo.zig @@ -25,35 +25,24 @@ const list_sql = /// Every string in the result is a heap copy owned by `gpa`. pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) { - var stmt = try database.prepare(list_sql); - defer stmt.deinit(); + return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource); +} - var out: std.ArrayList(model.BlocklistSource) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeBlocklistSources(gpa, out.items); - - while (try stmt.step()) { - const url = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(url); - const name = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(name); - try out.append(gpa, .{ - .url = url, - .name = name, - .enabled = stmt.columnBool(2), - .is_suggested = stmt.columnBool(3), - }); - } - return out; +fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource { + const url = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(url); + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + return .{ + .url = url, + .name = name, + .enabled = stmt.columnBool(2), + .is_suggested = stmt.columnBool(3), + }; } pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void { - for (items) |item| { - gpa.free(item.url); - gpa.free(item.name); - } + crud.freeRows(model.BlocklistSource, gpa, items); } const insert_sql = @@ -125,21 +114,7 @@ const list_rows_sql = row_columns_sql ++ " ORDER BY url"; /// order `listBlocklistSources` uses. Every string is a heap copy owned by /// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list. pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) { - var stmt = try database.prepare(list_rows_sql); - defer stmt.deinit(); - - var out: std.ArrayList(SourceRow) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeSourceRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readSourceRow(&stmt, gpa); - errdefer freeSourceRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow); } fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow { @@ -163,14 +138,13 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow { }; } +/// Frees `url`, `name` and the `checksum` payload when it is not null. pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void { - gpa.free(row.url); - gpa.free(row.name); - if (row.checksum) |value| gpa.free(value); + crud.freeRow(SourceRow, gpa, row); } pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void { - for (items) |item| freeSourceRow(gpa, item); + crud.freeRows(SourceRow, gpa, items); } const update_stats_sql = @@ -438,6 +412,12 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void { var rows = try listSourceRows(&database, gpa); defer rows.deinit(gpa); defer freeSourceRows(gpa, rows.items); + + // `checksum` is the one allocated optional in this directory. Without a + // non-null one in the result the injection never reaches its allocation and + // this test stops covering the shape it exists for. Row id 1 sorts last: + // `seedSources` inserts `c.example` first and the list orders by url. + try testing.expect(rows.items[2].checksum != null); } test "listSourceRows is leak-safe under allocation failure" { diff --git a/src/storage/repositories/upstreams_repo.zig b/src/storage/repositories/upstreams_repo.zig index 6a7d940..7284737 100644 --- a/src/storage/repositories/upstreams_repo.zig +++ b/src/storage/repositories/upstreams_repo.zig @@ -21,38 +21,31 @@ const InsertContext = context.InsertContext; /// Every string in the result is a heap copy owned by `gpa`. pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) { - var stmt = try database.prepare( + return crud.listRows( + model.UpstreamServer, + database, + gpa, "SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url", + readUpstream, ); - defer stmt.deinit(); +} - var out: std.ArrayList(model.UpstreamServer) = .empty; - // `errdefer`s run in reverse: the free pass is declared last so it runs - // before the backing array is released. - errdefer out.deinit(gpa); - errdefer freeUpstreams(gpa, out.items); - - while (try stmt.step()) { - const url = try stmt.columnTextAlloc(gpa, 0); - errdefer gpa.free(url); - const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch; - const tls_name = try stmt.columnTextAlloc(gpa, 3); - errdefer gpa.free(tls_name); - try out.append(gpa, .{ - .url = url, - .priority = priority, - .enabled = stmt.columnBool(2), - .tls_name = tls_name, - }); - } - return out; +fn readUpstream(stmt: *db.Stmt, gpa: Allocator) db.Error!model.UpstreamServer { + const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch; + const url = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(url); + const tls_name = try stmt.columnTextAlloc(gpa, 3); + errdefer gpa.free(tls_name); + return .{ + .url = url, + .priority = priority, + .enabled = stmt.columnBool(2), + .tls_name = tls_name, + }; } pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void { - for (items) |item| { - gpa.free(item.url); - gpa.free(item.tls_name); - } + crud.freeRows(model.UpstreamServer, gpa, items); } pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void { @@ -101,28 +94,15 @@ const get_upstream_sql = /// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`. pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) { - var stmt = try database.prepare(list_upstream_rows_sql); - defer stmt.deinit(); - - var out: std.ArrayList(UpstreamRow) = .empty; - errdefer out.deinit(gpa); - errdefer freeUpstreamRows(gpa, out.items); - - while (try stmt.step()) { - const row = try readUpstreamRow(&stmt, gpa); - errdefer freeUpstreamRow(gpa, row); - try out.append(gpa, row); - } - return out; + return crud.listRows(UpstreamRow, database, gpa, list_upstream_rows_sql, readUpstreamRow); } pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void { - gpa.free(row.url); - gpa.free(row.tls_name); + crud.freeRow(UpstreamRow, gpa, row); } pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void { - for (items) |item| freeUpstreamRow(gpa, item); + crud.freeRows(UpstreamRow, gpa, items); } pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow { diff --git a/src/tests.zig b/src/tests.zig index 42975a5..3b58f2d 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -24,6 +24,7 @@ comptime { _ = @import("upstream/pool.zig"); _ = @import("upstream/dot_client.zig"); _ = @import("upstream/dot_client_live_test.zig"); + _ = @import("server/listener.zig"); _ = @import("server/handler.zig"); _ = @import("server/udp_server.zig"); _ = @import("server/tcp_server.zig"); diff --git a/src/upstream/doh_client.zig b/src/upstream/doh_client.zig index 4cdd15b..d1e6a09 100644 --- a/src/upstream/doh_client.zig +++ b/src/upstream/doh_client.zig @@ -20,6 +20,13 @@ pub const media_type = "application/dns-message"; pub const min_request_buf = 512; pub const min_transfer_buf = 1024; +/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an +/// upstream with. They live here rather than beside either caller because a +/// probe that used a different buffer than the running server would answer a +/// question nobody asked. +pub const default_request_buf_len = 1024; +pub const default_transfer_buf_len = 4096; + pub const DohClient = struct { /// Caller-owned; shared across endpoints, pools connections. http: *std.http.Client, @@ -110,11 +117,11 @@ pub const DohClient = struct { defer req.deinit(); req.sendBodyComplete(self.request_buf[0..query.len]) catch |err| - return mapError(err, .send); + return mapError(sendCause(&req, err), .send); // An empty redirect buffer is legal under `.not_allowed`: a redirect // is an error before the location is ever read. - var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive); + var resp = req.receiveHead(&.{}) catch |err| return mapError(headCause(&req, err), .receive); if (resp.head.status != .ok) return error.HttpStatus; // `head.content_type` points into memory that `resp.reader` invalidates, @@ -129,7 +136,7 @@ pub const DohClient = struct { var ended = false; while (len < response_buf.len) { const n = body.readSliceShort(response_buf[len..]) catch |err| - return mapError(err, .receive); + return mapError(bodyCause(&resp, err), .receive); len += n; if (n == 0) { ended = true; @@ -140,7 +147,8 @@ pub const DohClient = struct { // `response_buf` filled exactly. One more read separates a message // that fits from one that was cut off. var probe: [1]u8 = undefined; - const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive); + const n = body.readSliceShort(&probe) catch |err| + return mapError(bodyCause(&resp, err), .receive); if (n != 0) return error.ResponseTooLarge; } @@ -166,6 +174,48 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError { }; } +const Connection = std.http.Client.Connection; +const Request = std.http.Client.Request; +const Response = std.http.Client.Response; + +/// `std.Io.Writer` collapses every send failure to `error.WriteFailed` and +/// stashes the cause on the connection's socket writer. Unwrapping it is what +/// keeps `error.Canceled` and the local resource errors out of the peer fault +/// group, exactly as `concreteWrite` does for DoT. +fn sendCause(req: *const Request, err: anyerror) anyerror { + if (err != error.WriteFailed) return err; + const connection = req.connection orelse return err; + return connection.stream_writer.err orelse err; +} + +/// `receiveHead` collapses a transport failure to `error.ReadFailed` and names +/// `Connection.getReadError` as the accessor for the concrete cause. +fn headCause(req: *const Request, err: anyerror) anyerror { + if (err != error.ReadFailed) return err; + const connection = req.connection orelse return err; + return readCause(connection, err); +} + +/// A body read reports two different kinds of failure through the same +/// `error.ReadFailed`. An HTTP framing fault lands on the response, and only a +/// read that never reached the framing leaves the connection's cause, so the +/// response is consulted first. +fn bodyCause(resp: *const Response, err: anyerror) anyerror { + if (err != error.ReadFailed) return err; + if (resp.bodyErr()) |cause| return cause; + const connection = resp.request.connection orelse return err; + return readCause(connection, err); +} + +/// `Connection.getReadError` reads the socket reader's stashed cause with `.?`. +/// On a plain connection that is its only source, so calling it with nothing +/// stashed would panic rather than return null; the guard keeps this unwrap +/// total on the path this client can reach without TLS. +fn readCause(connection: *const Connection, err: anyerror) anyerror { + if (connection.protocol == .plain and connection.stream_reader.err == null) return err; + return connection.getReadError() orelse err; +} + /// RFC 8484 §6: the response media type is `application/dns-message`. The /// header may carry parameters (`; charset=…`) and the type is case-insensitive /// per RFC 9110 §8.3.1. @@ -269,3 +319,136 @@ test "mapError maps remaining errors by phase" { try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive)); try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive)); } + +/// Only the fields the unwrap helpers read are set. The rest of a `Connection` +/// is two buffered streams, a host name and a pool node, none of which the +/// helpers touch. +/// +/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's +/// stashed cause through `@fieldParentPtr`, which on a stub would read memory +/// that was never a `Tls`. What the test is about — that the accessor is +/// consulted at all — is the same on both protocols. +fn stubConnection( + read_err: ?std.Io.net.Stream.Reader.Error, + write_err: ?std.Io.net.Stream.Writer.Error, +) Connection { + var connection: Connection = undefined; + connection.protocol = .plain; + connection.stream_reader.err = read_err; + connection.stream_writer.err = write_err; + return connection; +} + +fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request { + var req: Request = undefined; + req.connection = connection; + req.reader.body_err = body_err; + return req; +} + +test "the send unwrap keeps a cancelled write out of the peer fault group" { + var connection = stubConnection(null, error.Canceled); + var req = stubRequest(&connection, null); + const mapped = mapError(sendCause(&req, error.WriteFailed), .send); + try testing.expectEqual(transport.ExchangeError.Canceled, mapped); + try testing.expectEqual(transport.Group.cancellation, transport.group(mapped)); +} + +test "the send unwrap keeps a local resource write failure out of the peer fault group" { + var connection = stubConnection(null, error.SystemResources); + var req = stubRequest(&connection, null); + const mapped = mapError(sendCause(&req, error.WriteFailed), .send); + try testing.expectEqual(transport.ExchangeError.SystemResources, mapped); + try testing.expectEqual(transport.Group.local_resource, transport.group(mapped)); +} + +test "the send unwrap reports a peer side cause as a send fault" { + var connection = stubConnection(null, error.ConnectionResetByPeer); + var req = stubRequest(&connection, null); + try testing.expectEqual( + transport.ExchangeError.SendFailed, + mapError(sendCause(&req, error.WriteFailed), .send), + ); +} + +test "the head unwrap keeps a local resource read failure out of the peer fault group" { + var connection = stubConnection(error.SystemResources, null); + var req = stubRequest(&connection, null); + const mapped = mapError(headCause(&req, error.ReadFailed), .receive); + try testing.expectEqual(transport.ExchangeError.SystemResources, mapped); + try testing.expectEqual(transport.Group.local_resource, transport.group(mapped)); + + var canceled = stubConnection(error.Canceled, null); + var canceled_req = stubRequest(&canceled, null); + try testing.expectEqual( + transport.ExchangeError.Canceled, + mapError(headCause(&canceled_req, error.ReadFailed), .receive), + ); +} + +test "the head unwrap reports a peer side cause as a receive fault" { + var connection = stubConnection(error.ConnectionResetByPeer, null); + var req = stubRequest(&connection, null); + try testing.expectEqual( + transport.ExchangeError.ReceiveFailed, + mapError(headCause(&req, error.ReadFailed), .receive), + ); +} + +test "the body unwrap keeps a cancelled read out of the peer fault group" { + var connection = stubConnection(error.Canceled, null); + var req = stubRequest(&connection, null); + const resp: Response = .{ .request = &req, .head = undefined }; + const mapped = mapError(bodyCause(&resp, error.ReadFailed), .receive); + try testing.expectEqual(transport.ExchangeError.Canceled, mapped); + try testing.expectEqual(transport.Group.cancellation, transport.group(mapped)); +} + +test "the body unwrap prefers an http framing fault over the connection" { + // A truncated chunk is the peer's doing and the connection carries no + // cause at all, so reading the connection first would report the wrong + // thing on the one path where both could be set. + var connection = stubConnection(null, null); + var req = stubRequest(&connection, error.HttpChunkTruncated); + const resp: Response = .{ .request = &req, .head = undefined }; + try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed)); + try testing.expectEqual( + transport.ExchangeError.ReceiveFailed, + mapError(bodyCause(&resp, error.ReadFailed), .receive), + ); +} + +test "the unwraps report the collapsed error when no cause was stored" { + var connection = stubConnection(null, null); + var req = stubRequest(&connection, null); + const resp: Response = .{ .request = &req, .head = undefined }; + + try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed)); + try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed)); + try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed)); + + try testing.expectEqual( + transport.ExchangeError.SendFailed, + mapError(sendCause(&req, error.WriteFailed), .send), + ); + try testing.expectEqual( + transport.ExchangeError.ReceiveFailed, + mapError(bodyCause(&resp, error.ReadFailed), .receive), + ); +} + +test "the unwraps pass a non-collapsed error through untouched" { + // A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any + // other error already names itself, so the stash must not be read over it. + var connection = stubConnection(error.Canceled, error.Canceled); + var req = stubRequest(&connection, error.HttpChunkInvalid); + const resp: Response = .{ .request = &req, .head = undefined }; + + try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream)); + try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid)); + try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream)); + try testing.expectEqual( + transport.ExchangeError.ReceiveFailed, + mapError(headCause(&req, error.HttpHeadersInvalid), .receive), + ); +} diff --git a/src/upstream/dot_client.zig b/src/upstream/dot_client.zig index 007318e..0470d2b 100644 --- a/src/upstream/dot_client.zig +++ b/src/upstream/dot_client.zig @@ -179,9 +179,9 @@ pub const DotClient = struct { var stream = address.connect(io, .{ .mode = .stream }) catch |err| { log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })}); - return mapPhase(err, error.ConnectFailed); + return transport.mapPhase(err, error.ConnectFailed); }; - defer closeStream(io, &stream); + defer transport.closeBlocked(io, &stream); // `TlsStream` is pinned: it holds its reader and writer by value and the // TLS client points at them, so it must not move after `init`. @@ -205,9 +205,9 @@ pub const DotClient = struct { .verify_name = self.verify_name, .cause = cause, } })}); - return mapPhase(cause, error.TlsFailed); + return transport.mapPhase(cause, error.TlsFailed); }; - defer closeTls(io, &tls_stream); + defer transport.closeBlocked(io, &tls_stream); const writer = tls_stream.writer(); const prefix = transport.framePrefix(@intCast(query.len)); @@ -241,7 +241,7 @@ pub const DotClient = struct { /// cancellation into `error.CertificateBundleLoadFailure`. That name cannot /// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a /// local resource failure that must not count against the upstream's - /// health. Scanning here keeps the concrete error for `mapPhase`. + /// health. Scanning here keeps the concrete error for `transport.mapPhase`. fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void { { try self.bundle_lock.lockShared(io); @@ -259,31 +259,11 @@ pub const DotClient = struct { self.bundle.deinit(self.gpa); self.bundle.* = .empty; log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })}); - return mapPhase(err, error.TlsFailed); + return transport.mapPhase(err, error.TlsFailed); }; } }; -/// The pool cancels this task when the attempt budget expires. The next -/// cancelable `Io` call in the `defer` chain would then return `error.Canceled` -/// and skip the close, leaking the socket, so the close runs with cancellation -/// blocked. -fn closeStream(io: std.Io, stream: *net.Stream) void { - const prev = io.swapCancelProtection(.blocked); - defer _ = io.swapCancelProtection(prev); - stream.close(io); -} - -fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void { - const prev = io.swapCancelProtection(.blocked); - defer _ = io.swapCancelProtection(prev); - stream.close(); -} - -fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError { - return transport.mapLocal(err) orelse phase; -} - /// The handshake reads and writes through the socket reader and writer, so a /// cancelled or resource-starved handshake surfaces as `error.ReadFailed` / /// `error.WriteFailed` with the cause stashed on those two. Without this, @@ -318,11 +298,11 @@ fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror { } fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { - return mapPhase(concreteWrite(stream, err), error.SendFailed); + return transport.mapPhase(concreteWrite(stream, err), error.SendFailed); } fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { - return mapPhase(concreteRead(stream, err), error.ReceiveFailed); + return transport.mapPhase(concreteRead(stream, err), error.ReceiveFailed); } const testing = std.testing; @@ -460,14 +440,14 @@ fn stubStream( test "the handshake unwrap keeps a cancelled read out of the peer fault group" { var stream = stubStream(error.Canceled, null, null); - const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed); + const mapped = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed); try testing.expectEqual(transport.ExchangeError.Canceled, mapped); try testing.expectEqual(transport.Group.cancellation, transport.group(mapped)); } test "the handshake unwrap keeps a local resource write failure out of the peer fault group" { var stream = stubStream(null, error.SystemResources, null); - const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed); + const mapped = transport.mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed); try testing.expectEqual(transport.ExchangeError.SystemResources, mapped); try testing.expectEqual(transport.Group.local_resource, transport.group(mapped)); } @@ -476,13 +456,13 @@ test "the handshake unwrap reports a peer side cause as a TLS fault" { var reset = stubStream(error.ConnectionResetByPeer, null, null); try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed), + transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed), ); var refused = stubStream(null, error.ConnectionRefused, null); try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed), + transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed), ); } @@ -492,7 +472,7 @@ test "the handshake unwrap reports a TLS fault when no cause was stored" { try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed)); try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed), + transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed), ); } @@ -505,11 +485,11 @@ test "the handshake unwrap passes other errors through untouched" { try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled)); try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed), + transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed), ); try testing.expectEqual( transport.ExchangeError.Canceled, - mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed), + transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed), ); } @@ -547,24 +527,24 @@ test "a CA bundle scan failure keeps local resource errors out of the peer fault for (local) |err| { try testing.expectEqual( transport.Group.local_resource, - transport.group(mapPhase(err, error.TlsFailed)), + transport.group(transport.mapPhase(err, error.TlsFailed)), ); } try testing.expectEqual( transport.ExchangeError.Canceled, - mapPhase(error.Canceled, error.TlsFailed), + transport.mapPhase(error.Canceled, error.TlsFailed), ); // A missing or corrupt bundle is not this process running out of anything, // so it stays a TLS fault. try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(error.FileNotFound, error.TlsFailed), + transport.mapPhase(error.FileNotFound, error.TlsFailed), ); try testing.expectEqual( transport.ExchangeError.TlsFailed, - mapPhase(error.MissingEndCertificateMarker, error.TlsFailed), + transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed), ); } diff --git a/src/upstream/pool.zig b/src/upstream/pool.zig index 0dedc0c..7660c9b 100644 --- a/src/upstream/pool.zig +++ b/src/upstream/pool.zig @@ -181,28 +181,10 @@ pub const Pool = struct { query: []const u8, response_buf: []u8, ) transport.ExchangeError![]u8 { - var outcomes: [2]LoopOutcome = undefined; - var race: std.Io.Select(LoopOutcome) = .init(io, &outcomes); - defer race.cancelDiscard(); - - race.concurrent(.loop, exchangeLoopLen, .{ + const len = try transport.raceWithin(io, self.timeouts.total, exchangeLoopLen, .{ self, io, query, response_buf, - }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - race.concurrent(.expiry, expire, .{ io, self.timeouts.total }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - - switch (try race.await()) { - .loop => |result| return response_buf[0..try result], - .expiry => |result| { - // A canceled sleep means this whole task is being torn down, - // not that the budget ran out. - try result; - return error.Timeout; - }, - } + }); + return response_buf[0..len]; } /// The two-pass failover loop, as a raceable task. It returns the reply's @@ -300,9 +282,7 @@ pub const Pool = struct { return count; } - /// One exchange raced against the per-attempt budget. No stream read or - /// write in 0.16.0 takes a timeout, so the budget is a second task and the - /// loser is canceled. + /// One exchange raced against the per-attempt budget. fn attempt( self: *Pool, io: std.Io, @@ -310,28 +290,9 @@ pub const Pool = struct { query: []const u8, response_buf: []u8, ) transport.ExchangeError![]u8 { - var outcomes: [2]Outcome = undefined; - var race: std.Io.Select(Outcome) = .init(io, &outcomes); - defer race.cancelDiscard(); - - race.concurrent(.exchange, transport.Client.exchange, .{ + return transport.raceWithin(io, self.timeouts.attempt, transport.Client.exchange, .{ entry_client, io, query, response_buf, - }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - race.concurrent(.expiry, expire, .{ io, self.timeouts.attempt }) catch |err| switch (err) { - error.ConcurrencyUnavailable => return error.SystemResources, - }; - - switch (try race.await()) { - .exchange => |result| return result, - .expiry => |result| { - // A canceled sleep means this whole task is being torn down, - // not that the upstream is slow. - try result; - return error.Timeout; - }, - } + }); } fn entryAvailable( @@ -367,20 +328,6 @@ pub const Pool = struct { } }; -const Outcome = union(enum) { - exchange: transport.ExchangeError![]u8, - expiry: std.Io.Cancelable!void, -}; - -const LoopOutcome = union(enum) { - loop: transport.ExchangeError!usize, - expiry: std.Io.Cancelable!void, -}; - -fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { - return duration.sleep(io); -} - const testing = std.testing; /// A query for example.com A: id 0x1234, RD set, one question. diff --git a/src/upstream/transport.zig b/src/upstream/transport.zig index 14493df..00a5f1f 100644 --- a/src/upstream/transport.zig +++ b/src/upstream/transport.zig @@ -229,6 +229,97 @@ pub fn mapLocal(err: anyerror) ?ExchangeError { }; } +/// Names the peer fault of the phase the call site is in, unless the error is +/// one `mapLocal` claims for this process. Every transport classifies its +/// failures through this one function. +pub fn mapPhase(err: anyerror, phase: PeerFault) ExchangeError { + return mapLocal(err) orelse phase; +} + +/// Closes `target` with cancellation blocked. +/// +/// A transport's close runs from a `defer` chain that a lost timeout race is +/// unwinding. The next cancelable `Io` call in that chain returns +/// `error.Canceled` and skips the close, leaking the descriptor, so the close +/// swaps cancellation protection for the duration. +/// +/// `net.Stream` and `net.Socket` close through an `Io`; `tls_client.TlsStream` +/// owns the one it was built with and takes none. Both shapes are accepted so +/// that one helper covers every close in the transports. +pub fn closeBlocked(io: std.Io, target: anytype) void { + const prev = io.swapCancelProtection(.blocked); + defer _ = io.swapCancelProtection(prev); + const Target = @typeInfo(@TypeOf(target)).pointer.child; + if (@typeInfo(@TypeOf(Target.close)).@"fn".params.len == 2) { + target.close(io); + } else { + target.close(); + } +} + +/// The payload of `f`'s return type, which `raceWithin` requires to be +/// `ExchangeError!T`. A raced function with any other error set would let a +/// failure reach the pool without passing through `group`. +fn RacedPayload(comptime f: anytype) type { + const info = @typeInfo(@TypeOf(f)); + if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f))); + const Return = info.@"fn".return_type orelse + @compileError("raceWithin needs a function with a concrete return type"); + const union_info = switch (@typeInfo(Return)) { + .error_union => |u| u, + else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)), + }; + if (union_info.error_set != ExchangeError) + @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)); + return union_info.payload; +} + +/// Runs `f(args...)` raced against `budget`, and cancels the loser. +/// +/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second +/// task rather than a socket option. This is the one copy of that harness: the +/// pool races an attempt and its whole failover loop through it, and the +/// forward client races its TCP exchange. +/// +/// `error.Timeout` means the budget won. A canceled sleep means the whole task +/// is being torn down rather than the budget running out, so it stays +/// `error.Canceled`. A backend that cannot start a second task is +/// `error.SystemResources`, which `group` keeps off the peer's health. +pub fn raceWithin( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: anytype, +) ExchangeError!RacedPayload(f) { + const Outcome = union(enum) { + raced: ExchangeError!RacedPayload(f), + expiry: std.Io.Cancelable!void, + }; + + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + race.concurrent(.raced, f, args) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SystemResources, + }; + race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SystemResources, + }; + + switch (try race.await()) { + .raced => |result| return result, + .expiry => |result| { + try result; + return error.Timeout; + }, + } +} + +fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { + return budget.sleep(io); +} + /// A thing that sends one DNS message and returns one validated DNS message. /// Implemented by DohClient, DotClient, Pool, and test fakes. pub const Client = struct { @@ -430,6 +521,117 @@ test "mapLocal folds only local and cancellation errors" { try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed)); } +test "mapPhase names the phase unless the error is this process's own" { + try testing.expectEqual(ExchangeError.Canceled, mapPhase(error.Canceled, error.ReceiveFailed)); + try testing.expectEqual( + ExchangeError.SystemResources, + mapPhase(error.SystemResources, error.ConnectFailed), + ); + try testing.expectEqual( + ExchangeError.ConnectFailed, + mapPhase(error.ConnectionRefused, error.ConnectFailed), + ); + try testing.expectEqual( + Group.local_resource, + group(mapPhase(error.OutOfMemory, error.SendFailed)), + ); +} + +/// Stands in for whatever the pool or the forward client races. The variants +/// are the three ways such a task ends: in time, too late, or with a failure of +/// its own. +fn racedReply(io: std.Io, delay_ms: i64, result: ExchangeError!usize) ExchangeError!usize { + if (delay_ms != 0) { + const duration: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(delay_ms), .clock = .awake }; + try duration.sleep(io); + } + return result; +} + +test "raceWithin returns the raced value when it finishes inside the budget" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake }; + const len = try raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, 7) }); + try testing.expectEqual(@as(usize, 7), len); +} + +test "raceWithin returns Timeout when the budget wins" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake }; + const started = std.Io.Clock.awake.now(io); + try testing.expectError( + error.Timeout, + raceWithin(io, budget, racedReply, .{ io, 30_000, @as(ExchangeError!usize, 7) }), + ); + const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds; + + // Far under the raced task's own sleep, so the budget is provably what + // ended the call rather than the task finishing on its own. + try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s); +} + +test "raceWithin passes the raced task's own failure through" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake }; + // A local resource error keeps its group: the race must not turn it into + // the peer fault the budget would have produced. + const failed = raceWithin(io, budget, racedReply, .{ + io, 0, @as(ExchangeError!usize, error.OutOfMemory), + }); + try testing.expectError(error.OutOfMemory, failed); + try testing.expectError( + error.ConnectFailed, + raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, error.ConnectFailed) }), + ); +} + +test "closeBlocked closes a target of either close shape" { + // The two shapes the transports use: a socket or a plain stream, which + // closes through the `Io`, and a `TlsStream`, which owns the one it was + // built with. Dispatching on the wrong one is a compile error, so + // instantiating both is the check. + // + // That the close runs with cancellation blocked is not asserted here: the + // Threaded backend's `swapCancelProtection` is a no-op off one of its own + // task threads, so a unit test cannot observe the state it sets. + const WithIo = struct { + closed: bool = false, + + fn close(self: *@This(), io: std.Io) void { + _ = io; + self.closed = true; + } + }; + const WithoutIo = struct { + closed: bool = false, + + fn close(self: *@This()) void { + self.closed = true; + } + }; + + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var with_io: WithIo = .{}; + closeBlocked(io, &with_io); + try testing.expect(with_io.closed); + + var without_io: WithoutIo = .{}; + closeBlocked(io, &without_io); + try testing.expect(without_io.closed); +} + test "a fake client satisfies the Client interface" { const Fake = struct { calls: usize = 0, diff --git a/src/web/handlers/blocklists.zig b/src/web/handlers/blocklists.zig index 987b28f..9a92145 100644 --- a/src/web/handlers/blocklists.zig +++ b/src/web/handlers/blocklists.zig @@ -87,10 +87,7 @@ pub fn applyCreate( arena: Allocator, item: model.BlocklistSource, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); @@ -109,10 +106,7 @@ pub fn applyUpdate( id: i64, item: model.BlocklistSource, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); @@ -124,10 +118,7 @@ pub fn applyUpdate( } pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = sources_repo.deleteSource(database, id); @@ -189,32 +180,19 @@ pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.Sour // routes // --------------------------------------------------------------------------- -pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"), - }; +const resource = mutations.Resource(.{ + .Row = sources_repo.SourceRow, + .list = sources_repo.listSourceRows, + .get = sources_repo.getSource, + .remove = applyDelete, + .label = "a blocklist", + .plural = "blocklists", + .envelope = "blocklists", +}); - const rows = sources_repo.listSourceRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists"); - - return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{}); -} - -pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"), - }; - - const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, found, &.{}); -} +pub const list = resource.list; +pub const get = resource.get; +pub const remove = resource.remove; pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| @@ -251,13 +229,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr }, &.{}); } -pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDelete(state, io, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a blocklist"); - } - return http_util.respondEmpty(request, .no_content); -} - /// `POST /api/blocklists/update`. pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses); diff --git a/src/web/handlers/clients.zig b/src/web/handlers/clients.zig index 0582060..23057f1 100644 --- a/src/web/handlers/clients.zig +++ b/src/web/handlers/clients.zig @@ -61,10 +61,7 @@ pub fn applyUpdate( id: i64, edit: clients_repo.ClientEdit, ) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = clients_repo.updateClient(database, id, edit); @@ -75,10 +72,7 @@ pub fn applyUpdate( } pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = clients_repo.deleteClient(database, id); @@ -94,10 +88,7 @@ pub fn applyReplacePrefixes( arena: Allocator, items: []const clients_repo.ClientPrefixInput, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; // Canonical duplicates are the same UNIQUE collision the database would // report for identical text, so they answer 409 (ruling 9) before the @@ -152,32 +143,33 @@ fn checkPrefixSet( // routes // --------------------------------------------------------------------------- -pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing clients"), - }; +const resource = mutations.Resource(.{ + .Row = clients_repo.ClientRow, + .list = clients_repo.listClientRows, + .get = clients_repo.getClient, + .remove = applyDelete, + .label = "a client", + .plural = "clients", + .envelope = "clients", +}); - const rows = clients_repo.listClientRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing clients"); +pub const list = resource.list; +pub const get = resource.get; +pub const remove = resource.remove; - return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{}); -} +/// The prefixes are one list resource with no `/{id}` route: the whole set is +/// read and replaced (ruling 9), so there is nothing to get or delete by id. +const prefixes_resource = mutations.Resource(.{ + .Row = clients_repo.ClientPrefixRow, + .list = clients_repo.listClientPrefixRows, + .get = null, + .remove = null, + .label = "a client prefix", + .plural = "client prefixes", + .envelope = "client_prefixes", +}); -pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a client"), - }; - - const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a client"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, found, &.{}); -} +pub const listPrefixes = prefixes_resource.list; pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(ClientBody, request) catch |err| @@ -199,26 +191,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr return http_util.respondJson(request, .ok, found, &.{}); } -pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDelete(state, io, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a client"); - } - return http_util.respondEmpty(request, .no_content); -} - -pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"), - }; - - const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes"); - - return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{}); -} - pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(PrefixesBody, request) catch |err| return mutations.respondBadBody(request, err); diff --git a/src/web/handlers/groups.zig b/src/web/handlers/groups.zig index 12d7732..dd783c7 100644 --- a/src/web/handlers/groups.zig +++ b/src/web/handlers/groups.zig @@ -54,10 +54,7 @@ pub fn applyCreate( arena: Allocator, item: model.Group, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkGroupName(arena, item.name)) |problem| { return .{ .fail = .{ .invalid = problem } }; } @@ -78,10 +75,7 @@ pub fn applyUpdate( id: i64, item: model.Group, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkGroupName(arena, item.name)) |problem| { return .{ .invalid = problem }; } @@ -112,10 +106,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group) } pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const outcome = deleteLocked(database, arena, id); @@ -148,10 +139,7 @@ pub fn applySetSources( id: i64, source_ids: []const i64, ) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const outcome = groups_repo.setGroupSources(database, id, source_ids); @@ -165,32 +153,19 @@ pub fn applySetSources( // routes // --------------------------------------------------------------------------- -pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing groups"), - }; +const resource = mutations.Resource(.{ + .Row = groups_repo.GroupRow, + .list = groups_repo.listGroupRows, + .get = groups_repo.getGroup, + .remove = applyDelete, + .label = "a group", + .plural = "groups", + .envelope = "groups", +}); - const rows = groups_repo.listGroupRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing groups"); - - return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{}); -} - -pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a group"), - }; - - const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a group"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, found, &.{}); -} +pub const list = resource.list; +pub const get = resource.get; +pub const remove = resource.remove; pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| @@ -223,20 +198,11 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr }, &.{}); } -pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDelete(state, io, request.arena, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a group"); - } - return http_util.respondEmpty(request, .no_content); -} - /// `GET /api/groups/{id}/sources` — the assignment the PUT replaces. pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a group"), - }; + const database = mutations.requireConfigDb(state) catch + return mutations.respondFailure(request, mutations.no_config_db, "reading a group"); const id = request.id.?; const row = groups_repo.getGroup(database, request.arena, id) catch |err| diff --git a/src/web/handlers/local.zig b/src/web/handlers/local.zig index 94a073a..ad6b77c 100644 --- a/src/web/handlers/local.zig +++ b/src/web/handlers/local.zig @@ -94,10 +94,7 @@ pub fn applyCreateRecord( arena: Allocator, item: model.LocalRecord, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); @@ -116,10 +113,7 @@ pub fn applyUpdateRecord( id: i64, item: model.LocalRecord, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); @@ -131,10 +125,7 @@ pub fn applyUpdateRecord( } pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); @@ -154,10 +145,7 @@ pub fn applyCreateZone( arena: Allocator, item: model.ForwardZone, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); @@ -176,10 +164,7 @@ pub fn applyUpdateZone( id: i64, item: model.ForwardZone, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); @@ -191,10 +176,7 @@ pub fn applyUpdateZone( } pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); @@ -208,35 +190,20 @@ pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id // local records: routes // --------------------------------------------------------------------------- -pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing local records"), - }; +const records_resource = mutations.Resource(.{ + .Row = local_repo.LocalRecordRow, + .list = local_repo.listLocalRecordRows, + .get = local_repo.getLocalRecord, + .remove = applyDeleteRecord, + .label = "a local record", + .plural = "local records", + .envelope = "local_records", + .view = RecordView.from, +}); - const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing local records"); - - const views = try request.arena.alloc(RecordView, rows.items.len); - for (views, rows.items) |*view, row| view.* = .from(row); - - return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{}); -} - -pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"), - }; - - const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a local record"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, RecordView.from(found), &.{}); -} +pub const listRecords = records_resource.list; +pub const getRecord = records_resource.get; +pub const removeRecord = records_resource.remove; pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(RecordBody, request) catch |err| @@ -279,43 +246,23 @@ pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) Hand }, &.{}); } -pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a local record"); - } - return http_util.respondEmpty(request, .no_content); -} - // --------------------------------------------------------------------------- // forward zones: routes // --------------------------------------------------------------------------- -pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"), - }; +const zones_resource = mutations.Resource(.{ + .Row = local_repo.ForwardZoneRow, + .list = local_repo.listForwardZoneRows, + .get = local_repo.getForwardZone, + .remove = applyDeleteZone, + .label = "a forward zone", + .plural = "forward zones", + .envelope = "forward_zones", +}); - const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones"); - - return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{}); -} - -pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"), - }; - - const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, found, &.{}); -} +pub const listZones = zones_resource.list; +pub const getZone = zones_resource.get; +pub const removeZone = zones_resource.remove; pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(ZoneBody, request) catch |err| @@ -348,13 +295,6 @@ pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) Handle }, &.{}); } -pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a forward zone"); - } - return http_util.respondEmpty(request, .no_content); -} - // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- diff --git a/src/web/handlers/mutations.zig b/src/web/handlers/mutations.zig index d989ce9..bd94a9a 100644 --- a/src/web/handlers/mutations.zig +++ b/src/web/handlers/mutations.zig @@ -101,16 +101,156 @@ pub fn dbFailure(err: db.Error, conflict: []const u8) Failure { }; } -/// The config connection, or the 503 a state without one earns. -pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } { - if (state.config_db) |database| return .{ .database = database }; - return .{ .fail = .{ .unavailable = "no configuration database" } }; +/// The 503 a state with no config connection earns. One constant, because every +/// caller reports the same missing collaborator in the same words. +pub const no_config_db: Failure = .{ .unavailable = "no configuration database" }; + +/// The config connection, or `error.NoConfigDb` for the caller to turn into +/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`, +/// or a written response. +pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db { + return state.config_db orelse error.NoConfigDb; } pub fn nowSeconds(io: std.Io) i64 { return std.Io.Clock.real.now(io).toSeconds(); } +// --------------------------------------------------------------------------- +// the identical half of an id-addressed resource +// --------------------------------------------------------------------------- + +/// The `list`, `get` and `remove` handlers every id-addressed resource in this +/// directory writes the same way: take the config connection or answer 503, call +/// one repository function, and turn what comes back into the response. Nothing +/// a resource decides for itself is here — the create and update bodies, the +/// constraint texts, and the four reload flavors stay hand-written beside the +/// descriptor that names these three. +/// +/// `desc` is an anonymous struct literal rather than a typed struct because +/// `anytype` is not legal as a struct *field* type and the members are functions +/// of five signatures. Every member is checked below, so a descriptor that is +/// missing one or spells one wrong is a compile error that names it. +/// +/// Members: +/// +/// - `Row: type` — what the repository returns for one row. +/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`. +/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource +/// with no `/{id}` route. +/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an +/// `Allocator` before the id for a delete decision that reads rows, or `null`. +/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as +/// their object in the log context a 500 carries. +/// - `plural: []const u8` — "upstreams": what "listing" takes as its object. +/// - `envelope: []const u8` — the JSON key the list arrives under. +/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its +/// row spells the difference here; without it the row is serialised as it is. +pub fn Resource(comptime desc: anytype) type { + const Desc = @TypeOf(desc); + for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| { + if (!@hasField(Desc, name)) { + @compileError("resource descriptor has no `" ++ name ++ "`"); + } + } + if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type"); + const Row = desc.Row; + + expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)); + if (!isNull(@TypeOf(desc.get))) { + expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row); + } + if (!isNull(@TypeOf(desc.remove))) { + const Remove = @TypeOf(desc.remove); + if (removeTakesArena(Remove)) { + expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure); + } else { + expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure); + } + } + _ = @as([]const u8, desc.label); + _ = @as([]const u8, desc.plural); + _ = @as([]const u8, desc.envelope); + + const has_view = @hasField(Desc, "view"); + if (has_view) { + const info = @typeInfo(@TypeOf(desc.view)).@"fn"; + if (info.params.len != 1 or info.params[0].type.? != Row) { + @compileError("resource descriptor `view` must take one " ++ @typeName(Row)); + } + } + const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row; + + const names: [1][:0]const u8 = .{desc.envelope}; + const types: [1]type = .{[]const View}; + const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}}; + const Envelope = @Struct(.auto, null, &names, &types, &attrs); + + const list_what = "listing " ++ desc.plural; + const get_what = "reading " ++ desc.label; + const remove_what = "deleting " ++ desc.label; + + return struct { + pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = requireConfigDb(state) catch + return respondFailure(request, no_config_db, list_what); + + const rows = desc.list(database, request.arena) catch |err| + return respondFailure(request, .{ .internal = err }, list_what); + + const items: []const View = if (has_view) views: { + const views = try request.arena.alloc(View, rows.items.len); + for (views, rows.items) |*view, row| view.* = desc.view(row); + break :views views; + } else rows.items; + + var payload: Envelope = undefined; + @field(payload, desc.envelope) = items; + return http_util.respondJson(request, .ok, payload, &.{}); + } + + pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow; + pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow; + + fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = requireConfigDb(state) catch + return respondFailure(request, no_config_db, get_what); + + const row = desc.get(database, request.arena, request.id.?) catch |err| + return respondFailure(request, .{ .internal = err }, get_what); + const found = row orelse return respondFailure(request, .not_found, ""); + + const body: View = if (has_view) desc.view(found) else found; + return http_util.respondJson(request, .ok, body, &.{}); + } + + fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const failure = if (comptime removeTakesArena(@TypeOf(desc.remove))) + desc.remove(state, io, request.arena, request.id.?) + else + desc.remove(state, io, request.id.?); + + if (failure) |value| return respondFailure(request, value, remove_what); + return http_util.respondEmpty(request, .no_content); + } + }; +} + +fn isNull(comptime T: type) bool { + return T == @TypeOf(null); +} + +fn removeTakesArena(comptime T: type) bool { + return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4; +} + +fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void { + if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++ + @typeName(Expected) ++ ", found " ++ @typeName(Actual)); +} + // --------------------------------------------------------------------------- // applying a change to the running server (ruling 12) // --------------------------------------------------------------------------- @@ -439,6 +579,18 @@ test "the schema the bench opens already holds the default group" { try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'")); } +test "a state with no configuration database is a 503, not a crash" { + var bench: Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state)); + + var bare: server.WebState = .{ .gpa = testing.allocator }; + try testing.expectError(error.NoConfigDb, requireConfigDb(&bare)); + try testing.expectEqualStrings("no configuration database", no_config_db.unavailable); +} + test "a database error maps to the status its cause deserves" { try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x")); try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict); diff --git a/src/web/handlers/rules.zig b/src/web/handlers/rules.zig index 3980938..a01d1c4 100644 --- a/src/web/handlers/rules.zig +++ b/src/web/handlers/rules.zig @@ -60,10 +60,7 @@ pub fn applyCreate( arena: Allocator, item: rules_repo.RuleInput, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { return .{ .fail = .{ .invalid = problem } }; } @@ -84,10 +81,7 @@ pub fn applyUpdate( id: i64, item: rules_repo.RuleInput, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { return .{ .invalid = problem }; } @@ -101,10 +95,7 @@ pub fn applyUpdate( } pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = rules_repo.deleteRule(database, id); @@ -142,35 +133,20 @@ const RuleView = struct { } }; -pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing rules"), - }; +const resource = mutations.Resource(.{ + .Row = rules_repo.RuleRow, + .list = rules_repo.listRuleRows, + .get = rules_repo.getRule, + .remove = applyDelete, + .label = "a rule", + .plural = "rules", + .envelope = "rules", + .view = RuleView.from, +}); - const rows = rules_repo.listRuleRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing rules"); - - const views = try request.arena.alloc(RuleView, rows.items.len); - for (views, rows.items) |*view, row| view.* = .from(row); - - return http_util.respondJson(request, .ok, .{ .rules = views }, &.{}); -} - -pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"), - }; - - const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading a rule"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, RuleView.from(found), &.{}); -} +pub const list = resource.list; +pub const get = resource.get; +pub const remove = resource.remove; pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| @@ -213,13 +189,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr }, &.{}); } -pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDelete(state, io, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting a rule"); - } - return http_util.respondEmpty(request, .no_content); -} - // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- diff --git a/src/web/handlers/settings.zig b/src/web/handlers/settings.zig index e6f9dc9..06ca7da 100644 --- a/src/web/handlers/settings.zig +++ b/src/web/handlers/settings.zig @@ -281,10 +281,7 @@ pub fn applyPut( arena: Allocator, patch: Patch, ) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; // Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this // handler does, and its input is the parsed patch alone — nothing under the @@ -449,10 +446,8 @@ pub const hash_stall_control = if (builtin.is_test) struct { // --------------------------------------------------------------------------- pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"), - }; + const database = mutations.requireConfigDb(state) catch + return mutations.respondFailure(request, mutations.no_config_db, "reading the settings"); // Under the same lock the mutation handlers hold: a PUT rewrites every // settings row in one transaction on this shared connection, and SQLite's diff --git a/src/web/handlers/upstreams.zig b/src/web/handlers/upstreams.zig index cc1e411..ce6fc25 100644 --- a/src/web/handlers/upstreams.zig +++ b/src/web/handlers/upstreams.zig @@ -44,10 +44,7 @@ pub fn applyCreate( arena: Allocator, item: model.UpstreamServer, ) error{OutOfMemory}!Created { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return .{ .fail = failure }, - }; + const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); @@ -65,10 +62,7 @@ pub fn applyUpdate( id: i64, item: model.UpstreamServer, ) error{OutOfMemory}!?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); @@ -96,10 +90,7 @@ pub fn applyUpdate( /// answers nothing, and `validate.validate` refuses that configuration at /// startup — so allowing it here would only produce a box that will not boot. pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return failure, - }; + const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); @@ -142,32 +133,19 @@ fn countEnabledExcept( // routes // --------------------------------------------------------------------------- -pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"), - }; +const resource = mutations.Resource(.{ + .Row = upstreams_repo.UpstreamRow, + .list = upstreams_repo.listUpstreamRows, + .get = upstreams_repo.getUpstream, + .remove = applyDelete, + .label = "an upstream", + .plural = "upstreams", + .envelope = "upstreams", +}); - const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams"); - - return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{}); -} - -pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - _ = io; - const database = switch (mutations.configDb(state)) { - .database => |value| value, - .fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"), - }; - - const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err| - return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream"); - const found = row orelse return mutations.respondFailure(request, .not_found, ""); - - return http_util.respondJson(request, .ok, found, &.{}); -} +pub const list = resource.list; +pub const get = resource.get; +pub const remove = resource.remove; pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Body, request) catch |err| @@ -206,13 +184,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr }, &.{}); } -pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { - if (applyDelete(state, io, request.arena, request.id.?)) |failure| { - return mutations.respondFailure(request, failure, "deleting an upstream"); - } - return http_util.respondEmpty(request, .no_content); -} - fn toModel(body: Body) model.UpstreamServer { return .{ .url = body.url, diff --git a/src/web/metrics.zig b/src/web/metrics.zig index 4d50b49..7af58ab 100644 --- a/src/web/metrics.zig +++ b/src/web/metrics.zig @@ -776,7 +776,7 @@ test "the plain-DNS listener families carry every counter of both listeners" { .send_errors = 5, }, .tcp_listener = .{ - .accepted = 12, + .connections = 12, .rejected_at_capacity = 6, .rejected_at_shutdown = 7, .accept_errors = 8, @@ -793,7 +793,7 @@ test "the plain-DNS listener families carry every counter of both listeners" { try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n")); - try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n")); @@ -822,13 +822,13 @@ test "one family covers all four listeners, summed" { udp4.stats.dropped_no_slot.store(2, .monotonic); var tcp6: tcp_server.TcpServer = undefined; - tcp6.stats = .{}; - tcp6.stats.accepted.store(4, .monotonic); + tcp6.core.stats = .{}; + tcp6.core.stats.connections.store(4, .monotonic); var tcp4: tcp_server.TcpServer = undefined; - tcp4.stats = .{}; - tcp4.stats.accepted.store(5, .monotonic); - tcp4.stats.idle_timeouts.store(3, .monotonic); + tcp4.core.stats = .{}; + tcp4.core.stats.connections.store(5, .monotonic); + tcp4.core.stats.idle_timeouts.store(3, .monotonic); const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?; try testing.expectEqual(@as(u64, 17), udp.received); @@ -836,7 +836,7 @@ test "one family covers all four listeners, summed" { try testing.expectEqual(@as(u64, 0), udp.send_errors); const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?; - try testing.expectEqual(@as(u64, 9), tcp.accepted); + try testing.expectEqual(@as(u64, 9), tcp.connections); try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts); // No listener at all is a missing family, not a family of zeros. diff --git a/src/web/server.zig b/src/web/server.zig index c6bd499..4bc56a3 100644 --- a/src/web/server.zig +++ b/src/web/server.zig @@ -1,20 +1,11 @@ //! The admin HTTP listener. //! -//! One `std.http.Server` per connection over our own accept loop: a listener -//! task in the app's group, an inner `Io.Group` of connection tasks, and a -//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`. -//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is -//! tcp_server.zig's, for the same reason. -//! -//! Shutdown takes one of two paths: -//! -//! - `deinit` shuts the listening socket down (which unblocks `accept` with -//! `error.SocketNotListening`) and then shuts every live connection down, so -//! each one unblocks and finishes its response. `serve` drains them. -//! - A canceled `serve` cannot drain: HTTP keep-alive lets a browser hold a -//! connection open indefinitely with no request on it, so waiting would let -//! one idle tab stall the whole process's shutdown. The connection group is -//! canceled instead. +//! One `std.http.Server` per connection over the shared `listener.Core` accept +//! loop (milestone-18 ruling 1): a listener task in the app's group, an inner +//! `Io.Group` of connection tasks, and a keep-alive loop per connection that +//! ends on `error.HttpConnectionClosing`. The shape is +//! lib/std/Build/WebServer.zig:152-185; the slot pool and the shutdown split +//! come from the core, which documents both. //! //! Connection slots are fixed and pre-allocated, and each one owns every buffer //! a request needs, so serving allocates only what a handler asks the @@ -41,6 +32,7 @@ const dns_handler = @import("../server/handler.zig"); const doh_server = @import("../server/doh_server.zig"); const dot_server = @import("../server/dot_server.zig"); const http_util = @import("http_util.zig"); +const listener_core = @import("../server/listener.zig"); const local_tables_mod = @import("../server/local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const manager_mod = @import("../filter/manager.zig"); @@ -69,10 +61,6 @@ pub const default_max_connections: u16 = 64; /// connections cost 4 MiB rather than 64. const arena_retain_bytes = 64 * 1024; -/// How long the accept loop waits after an unexpected accept failure, so a -/// persistent one cannot turn the loop into a spin. -const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; - const over_capacity_body = "{\"error\":\"too many connections\"}"; const over_capacity_response = std.fmt.comptimePrint( "HTTP/1.1 503 Service Unavailable\r\n" ++ @@ -232,12 +220,10 @@ pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Reques return .ok; } +/// What the admin listener counts on top of `listener.CoreStats`. Nothing +/// exports these: there is no `nxdns_web_*` family, they exist for the +/// integration tests and for a future one. pub const Stats = struct { - accepted: std.atomic.Value(u64) = .init(0), - rejected_at_capacity: std.atomic.Value(u64) = .init(0), - rejected_at_shutdown: std.atomic.Value(u64) = .init(0), - accept_errors: std.atomic.Value(u64) = .init(0), - connection_errors: std.atomic.Value(u64) = .init(0), requests: std.atomic.Value(u64) = .init(0), }; @@ -245,41 +231,15 @@ pub const Options = struct { max_connections: u16 = default_max_connections, }; -/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims -/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`. -const State = enum(u32) { idle, serving, closing }; - -/// `.closing` exists so `deinit` never shuts down a descriptor its own task is -/// about to close. -const ConnState = enum { free, active, closing }; - -/// Why the accept loop stopped, which decides what happens to the connections -/// still in flight. -const Stop = enum { closing, canceled }; - -const Claim = union(enum) { - slot: usize, - at_capacity, - shutting_down, -}; - pub const Server = struct { + core: listener_core.Core(Config), state: *WebState, - listener: net.Server, - conns: []Conn, - mutex: std.Io.Mutex, - /// Guarded by `mutex`, set in the same critical section that shuts the live - /// connections down. - shutdown_begun: bool, stats: Stats, - run_state: std.atomic.Value(State), - stopped: std.Io.Event, /// One slot's fixed cost. The head copies exist because every string in - /// `request.head` dies on the first body read (http/Server.zig:594). - pub const Conn = struct { - recv_buf: [recv_buffer_len]u8, - send_buf: [send_buffer_len]u8, + /// `request.head` dies on the first body read (http/Server.zig:594). The + /// receive and send buffers belong to the core. + pub const Payload = struct { target_buf: [http_util.max_target_len]u8, cookie_buf: [http_util.max_cookie_len]u8, accept_encoding_buf: [http_util.max_header_value_len]u8, @@ -288,13 +248,31 @@ pub const Server = struct { /// Per-request working memory, reset between requests on the same /// connection so a keep-alive client cannot grow it without bound. arena: std.heap.ArenaAllocator, - stream: net.Stream, - peer: net.IpAddress, - /// Guarded by `Server.mutex`. - conn_state: ConnState, + + fn init(payload: *Payload, gpa: Allocator) void { + payload.arena = .init(gpa); + } + + fn deinit(payload: *Payload) void { + payload.arena.deinit(); + } }; - pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; + const Config = struct { + pub const Owner = Server; + pub const ConnPayload = Payload; + pub const serveConn = serveOne; + pub const read_buffer_len = recv_buffer_len; + pub const write_buffer_len = send_buffer_len; + pub const log = std.log.scoped(.web_server); + pub const name = "web"; + pub const refuse = refuseOverCapacity; + pub const initPayload = Payload.init; + pub const deinitPayload = Payload.deinit; + }; + + pub const Conn = listener_core.Core(Config).Conn; + pub const ListenError = listener_core.Core(Config).ListenError; pub fn listen( gpa: Allocator, @@ -303,128 +281,35 @@ pub const Server = struct { state: *WebState, options: Options, ) ListenError!Server { - std.debug.assert(options.max_connections > 0); - - const conns = try gpa.alloc(Conn, options.max_connections); - errdefer gpa.free(conns); - for (conns) |*conn| { - conn.conn_state = .free; - conn.arena = .init(gpa); - } - - const listener = try listen_address.listen(io, .{ .reuse_address = true }); - return .{ + .core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections), .state = state, - .listener = listener, - .conns = conns, - .mutex = .init, - .shutdown_begun = false, .stats = .{}, - .run_state = .init(.idle), - .stopped = .unset, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const Server) net.IpAddress { - return self.listener.socket.address; + return self.core.boundAddress(); } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *Server, io: std.Io) void { - if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; - - var group: std.Io.Group = .init; - switch (self.acceptLoop(io, &group)) { - // `deinit` shut every live connection down before it published - // `.closing`, so each one is unblocked and finishing on its own. - // Awaiting them means a half-written response still goes out whole. - .closing => { - const prev = io.swapCancelProtection(.blocked); - group.await(io) catch |err| switch (err) { - error.Canceled => unreachable, - }; - _ = io.swapCancelProtection(prev); - }, - // Nothing has shut these connections down, and an idle keep-alive - // connection has no deadline of its own, so draining could wait - // forever. Cancel joins, so the slots are quiet by the time `serve` - // returns; the price is the one response that was mid-write. - .canceled => group.cancel(io), - } - - self.stopped.set(io); + self.core.serve(io); } - pub fn deinit(self: *Server, gpa: Allocator, io: std.Io) void { - const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; - - // Shutting the listening socket down is the documented way to unblock a - // pending `accept`: it fails with `error.SocketNotListening`. - const listener: net.Stream = .{ .socket = self.listener.socket }; - listener.shutdown(io, .both) catch |err| { - log.debug("web listener shutdown failed: {t}", .{err}); - }; - - // Ruling 11 of milestone 16, before `beginShutdown`: a live-query task - // parked in `Hub.wait` is waiting on an event, not on its socket, so - // shutting the connection down does not reach it. Without this the - // drain below waits out one heartbeat interval per idle stream. + pub fn deinit(self: *Server, io: std.Io) void { + // Ruling 11 of milestone 16, before the core shuts the connections + // down: a live-query task parked in `Hub.wait` is waiting on an event, + // not on its socket, so shutting the connection down does not reach it. + // Without this the drain waits out one heartbeat interval per idle + // stream. if (self.state.hub) |hub| hub.close(io); - self.beginShutdown(io); - - if (was_serving) self.stopped.waitUncancelable(io); - - self.listener.deinit(io); - for (self.conns) |*conn| conn.arena.deinit(); - gpa.free(self.conns); + self.core.deinit(io); self.* = undefined; } - fn acceptLoop(self: *Server, io: std.Io, group: *std.Io.Group) Stop { - while (self.run_state.load(.acquire) == .serving) { - const stream = self.listener.accept(io) catch |err| switch (err) { - error.Canceled => return .canceled, - error.SocketNotListening => return .closing, - else => { - bump(&self.stats.accept_errors); - log.debug("web accept failed: {t}", .{err}); - retry_delay.sleep(io) catch return .canceled; - continue; - }, - }; - - const index = switch (self.claim(io, stream)) { - .slot => |index| index, - .at_capacity => { - bump(&self.stats.rejected_at_capacity); - refuse(io, stream); - continue; - }, - .shutting_down => { - bump(&self.stats.rejected_at_shutdown); - stream.close(io); - return .closing; - }, - }; - - group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { - error.ConcurrencyUnavailable => { - bump(&self.stats.rejected_at_capacity); - self.finish(io, index); - continue; - }, - }; - - bump(&self.stats.accepted); - } - - // The loop condition failed, which only `deinit` can cause. - return .closing; - } - /// Ruling 7: over capacity the client is told so, never silently dropped. /// /// The response is written from the accept loop, because refusing must not @@ -437,7 +322,7 @@ pub const Server = struct { /// would mean a blocking read on the accept loop with no bound but the /// client's goodwill, which is a worse failure than a lost error page on a /// server that is already at capacity. - fn refuse(io: std.Io, stream: net.Stream) void { + fn refuseOverCapacity(io: std.Io, stream: net.Stream) void { var buf: [over_capacity_response.len]u8 = undefined; var writer = stream.writer(io, &buf); writer.interface.writeAll(over_capacity_response) catch {}; @@ -445,12 +330,12 @@ pub const Server = struct { stream.close(io); } - fn serveConn(self: *Server, io: std.Io, index: usize) void { - defer self.finish(io, index); - - const conn = &self.conns[index]; - var reader = conn.stream.reader(io, &conn.recv_buf); - var writer = conn.stream.writer(io, &conn.send_buf); + /// One connection's keep-alive loop. The core closes the slot when this + /// returns. + fn serveOne(self: *Server, io: std.Io, index: usize) void { + const conn = &self.core.conns[index]; + var reader = conn.stream.reader(io, &conn.read_buf); + var writer = conn.stream.writer(io, &conn.write_buf); var connection: http.Server = .init(&reader.interface, &writer.interface); while (connection.reader.state == .ready) { @@ -461,11 +346,11 @@ pub const Server = struct { // worth a counter. error.ReadFailed => return, error.HttpHeadersOversize => { - bump(&self.stats.connection_errors); + listener_core.bump(&self.core.stats.connection_errors); return; }, error.HttpRequestTruncated, error.HttpHeadersInvalid => { - bump(&self.stats.connection_errors); + listener_core.bump(&self.core.stats.connection_errors); return; }, }; @@ -484,17 +369,17 @@ pub const Server = struct { request.head.content_length = 0; } - bump(&self.stats.requests); + listener_core.bump(&self.stats.requests); // Retained with a limit, not wholesale: a single 1 MiB body would // otherwise keep a megabyte per slot alive for as long as the // browser holds the connection. - _ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes }); + _ = conn.payload.arena.reset(.{ .retain_with_limit = arena_retain_bytes }); self.handleRequest(io, conn, &request) catch |err| switch (err) { // Ruling 28: the peer went away mid-response. Normal. error.WriteFailed => return, error.HttpExpectationFailed, error.OutOfMemory => { - bump(&self.stats.connection_errors); + listener_core.bump(&self.core.stats.connection_errors); return; }, }; @@ -509,26 +394,26 @@ pub const Server = struct { conn: *Conn, request: *http.Server.Request, ) http_util.HandlerError!void { - const arena = conn.arena.allocator(); + const arena = conn.payload.arena.allocator(); const target = request.head.target; - if (target.len > conn.target_buf.len) { + if (target.len > conn.payload.target_buf.len) { var view = bareRequest(request, conn, arena); return http_util.respondError(&view, .uri_too_long, "target too long"); } - @memcpy(conn.target_buf[0..target.len], target); - const copied = conn.target_buf[0..target.len]; + @memcpy(conn.payload.target_buf[0..target.len], target); + const copied = conn.payload.target_buf[0..target.len]; const split = std.mem.findScalar(u8, copied, '?') orelse copied.len; const raw_path = copied[0..split]; const query = if (split == copied.len) copied[split..] else copied[split + 1 ..]; - const cookie = copyCookie(request, &conn.cookie_buf); - const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf); - const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf); + const cookie = copyCookie(request, &conn.payload.cookie_buf); + const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf); + const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf); const peer = address.NetAddress.fromIp(conn.peer); - const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf); + const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf); const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) { .addr => |addr| addr, .bad_forwarded_for => { @@ -585,58 +470,6 @@ pub const Server = struct { .arena = arena, }; } - - fn claim(self: *Server, io: std.Io, stream: net.Stream) Claim { - // Uncancelable: this section takes no Io and never blocks on a peer, so - // losing the lock mid-update would leak a slot for nothing. - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - const outcome = decideClaim(self.conns, self.shutdown_begun); - switch (outcome) { - .slot => |index| { - self.conns[index].stream = stream; - self.conns[index].peer = stream.socket.address; - self.conns[index].conn_state = .active; - }, - .at_capacity, .shutting_down => {}, - } - return outcome; - } - - fn finish(self: *Server, io: std.Io, index: usize) void { - const conn = &self.conns[index]; - - self.mutex.lockUncancelable(io); - conn.conn_state = .closing; - self.mutex.unlock(io); - - // The socket is released even when this task is being torn down: the - // next cancelable call would otherwise skip the close. - const prev = io.swapCancelProtection(.blocked); - conn.stream.close(io); - _ = io.swapCancelProtection(prev); - - self.mutex.lockUncancelable(io); - conn.conn_state = .free; - self.mutex.unlock(io); - } - - /// Closes the door on new connections and unblocks the live ones under one - /// hold of the mutex, so no `claim` can slip between the two. - fn beginShutdown(self: *Server, io: std.Io) void { - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - - self.shutdown_begun = true; - - for (self.conns) |*conn| { - if (conn.conn_state != .active) continue; - conn.stream.shutdown(io, .both) catch |err| { - log.debug("web connection shutdown failed: {t}", .{err}); - }; - } - } }; /// Copies one header value into `buf`. A value too long for its budget reads as @@ -758,19 +591,6 @@ fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 { return buf[0..len]; } -/// The whole claim rule, without the mutex, so it is testable without a backend. -fn decideClaim(conns: []const Server.Conn, shutdown_begun: bool) Claim { - if (shutdown_begun) return .shutting_down; - for (conns, 0..) |*conn, index| { - if (conn.conn_state == .free) return .{ .slot = index }; - } - return .at_capacity; -} - -fn bump(counter: *std.atomic.Value(u64)) void { - _ = counter.fetchAdd(1, .monotonic); -} - /// The composition root's entry point: bind, serve, release. /// /// A bind failure is warned and swallowed. The admin UI failing to come up must @@ -786,7 +606,7 @@ pub fn serve(state: *WebState, io: std.Io) void { log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err }); return; }; - defer server.deinit(state.gpa, io); + defer server.deinit(io); log.info("web interface listening on {f}", .{server.boundAddress()}); server.serve(io); @@ -794,41 +614,6 @@ pub fn serve(state: *WebState, io: std.Io) void { const testing = std.testing; -fn testConns(count: usize) ![]Server.Conn { - const conns = try testing.allocator.alloc(Server.Conn, count); - for (conns) |*conn| conn.conn_state = .free; - return conns; -} - -test "the connection pool hands out every slot once, then refuses" { - const conns = try testConns(2); - defer testing.allocator.free(conns); - - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); - conns[0].conn_state = .active; - try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); - conns[1].conn_state = .active; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); -} - -test "a closing slot is not reused until it is free" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - conns[0].conn_state = .closing; - try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); - conns[0].conn_state = .free; - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); -} - -test "shutdown outranks capacity and does not consume the slot" { - const conns = try testConns(1); - defer testing.allocator.free(conns); - - try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); - try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); -} - test "the over-capacity response is a well formed 503" { try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 ")); const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?; diff --git a/src/web/server_integration_test.zig b/src/web/server_integration_test.zig index 2028d7c..aeffb14 100644 --- a/src/web/server_integration_test.zig +++ b/src/web/server_integration_test.zig @@ -238,6 +238,19 @@ fn get(path: []const u8, buf: []u8) []const u8 { return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable; } +/// The listener's counters, read once after `f` finished and before the +/// listener is torn down. Plain values, because they are a report of a run that +/// is over: the shared core's counters and the web listener's own one land in +/// the same struct here. +const Counters = struct { + connections: u64, + rejected_at_capacity: u64, + rejected_at_shutdown: u64, + accept_errors: u64, + connection_errors: u64, + requests: u64, +}; + /// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under /// the budget, then shuts the listener down through the drain path. fn withServer( @@ -247,7 +260,7 @@ fn withServer( max_connections: u16, comptime f: anytype, extra: anytype, -) !server.Stats { +) !Counters { const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections }); const address = web.boundAddress(); @@ -257,16 +270,16 @@ fn withServer( const result = bounded(io, f, .{ io, address } ++ extra); - const stats: server.Stats = .{ - .accepted = .init(web.stats.accepted.load(.monotonic)), - .rejected_at_capacity = .init(web.stats.rejected_at_capacity.load(.monotonic)), - .rejected_at_shutdown = .init(web.stats.rejected_at_shutdown.load(.monotonic)), - .accept_errors = .init(web.stats.accept_errors.load(.monotonic)), - .connection_errors = .init(web.stats.connection_errors.load(.monotonic)), - .requests = .init(web.stats.requests.load(.monotonic)), + const stats: Counters = .{ + .connections = web.core.stats.connections.load(.monotonic), + .rejected_at_capacity = web.core.stats.rejected_at_capacity.load(.monotonic), + .rejected_at_shutdown = web.core.stats.rejected_at_shutdown.load(.monotonic), + .accept_errors = web.core.stats.accept_errors.load(.monotonic), + .connection_errors = web.core.stats.connection_errors.load(.monotonic), + .requests = web.stats.requests.load(.monotonic), }; - web.deinit(gpa, io); + web.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; @@ -302,9 +315,9 @@ test "one connection carries two requests" { const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{}); // One accept for two requests is the whole point of keep-alive. - try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic)); - try testing.expectEqual(@as(u64, 2), stats.requests.load(.monotonic)); - try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 2), stats.requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void { @@ -352,7 +365,7 @@ test "routing answers 404, 405 with allow, and rejects malformed targets" { var state = testState(gpa); const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{}); - try testing.expectEqual(@as(u64, 5), stats.requests.load(.monotonic)); + try testing.expectEqual(@as(u64, 5), stats.requests); } fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void { @@ -448,7 +461,7 @@ test "a POST with no content-length and no transfer-encoding is an empty body, n var state = testState(gpa); const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{}); - try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void { @@ -509,8 +522,8 @@ test "a connection over the cap is told 503, not silently dropped" { var state = testState(gpa); const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{}); - try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic)); - try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity); } fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void { @@ -642,7 +655,7 @@ test "cancellation returns promptly with an idle keep-alive connection open" { const elapsed = start.durationTo(std.Io.Clock.awake.now(io)); client.cancel(io); - web.deinit(gpa, io); + web.deinit(io); try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds()); } diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig index 3b84d84..053121d 100644 --- a/src/web/web_integration_test.zig +++ b/src/web/web_integration_test.zig @@ -402,7 +402,7 @@ const Env = struct { const gpa = self.gpa; const ioh = self.threaded.io(); - self.web.deinit(gpa, ioh); + self.web.deinit(ioh); self.group.await(ioh) catch |err| switch (err) { error.Canceled => unreachable, }; diff --git a/tests/fuzz/blocklist_fuzz.zig b/tests/fuzz/blocklist_fuzz.zig index e5d8e44..a057e4f 100644 --- a/tests/fuzz/blocklist_fuzz.zig +++ b/tests/fuzz/blocklist_fuzz.zig @@ -29,7 +29,10 @@ const std = @import("std"); const parsers = @import("parsers"); +const smith_encode = @import("smith_encode.zig"); +const sliceInput = smith_encode.sliceInput; +const pairInput = smith_encode.pairInput; const wildcard = parsers.wildcard; const Smith = std.testing.Smith; @@ -132,9 +135,8 @@ fn labelCount(text: []const u8) usize { // corpus // --------------------------------------------------------------------------- // -// `Smith` does not consume a corpus entry as raw parser input. It reads a byte -// stream in which a slice is a little-endian `u32` length followed by that many -// bytes, so every entry below is length-prefixed. The five targets share one +// `Smith` does not consume a corpus entry as raw parser input, so every entry +// below goes through the `smith_encode.zig` encoders. The five targets share one // corpus: each starts with a slice, and the wildcard target reads a second one // that falls back to empty when an entry carries only the first. @@ -156,31 +158,6 @@ const long_line = "a" ** 5000 ++ ".example.com"; const element_hiding = "example.com##.ad-banner"; const scheme_anchor = "|https://ads.example.com/track"; -/// Encodes `bytes` as a single `Smith.slice` value. -fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { - return &struct { - const value: [4 + bytes.len]u8 = blk: { - var buf: [4 + bytes.len]u8 = undefined; - std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); - buf[4..].* = bytes[0..bytes.len].*; - break :blk buf; - }; - }.value; -} - -/// Encodes two `Smith.slice` values back to back, which is what the wildcard -/// target reads. -fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 { - return &struct { - const value: [8 + a.len + b.len]u8 = blk: { - var buf: [8 + a.len + b.len]u8 = undefined; - buf[0 .. 4 + a.len].* = sliceInput(a).*; - buf[4 + a.len ..].* = sliceInput(b).*; - break :blk buf; - }; - }.value; -} - const corpus = [_][]const u8{ sliceInput(hosts_line), sliceInput(abp_line), @@ -197,17 +174,3 @@ const corpus = [_][]const u8{ pairInput("*.example.com", "example.com.evil.net"), pairInput("ad*.example.com", "ads.example.com"), }; - -test "a corpus entry carries its own length" { - const encoded = sliceInput(abp_line); - try std.testing.expectEqual(@as(u32, abp_line.len), std.mem.readInt(u32, encoded[0..4], .little)); - try std.testing.expectEqualSlices(u8, abp_line, encoded[4..]); -} - -test "a paired corpus entry carries both lengths" { - const encoded = pairInput("*.a.b", "x.a.b"); - try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[0..4], .little)); - try std.testing.expectEqualSlices(u8, "*.a.b", encoded[4..9]); - try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[9..13], .little)); - try std.testing.expectEqualSlices(u8, "x.a.b", encoded[13..]); -} diff --git a/tests/fuzz/compiler_fuzz.zig b/tests/fuzz/compiler_fuzz.zig index c202b98..97fc7c5 100644 --- a/tests/fuzz/compiler_fuzz.zig +++ b/tests/fuzz/compiler_fuzz.zig @@ -30,7 +30,9 @@ const std = @import("std"); const core = @import("core"); +const smith_encode = @import("smith_encode.zig"); +const sliceInput = smith_encode.sliceInput; const compiler = core.compiler; const Smith = std.testing.Smith; @@ -132,11 +134,10 @@ fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void { // corpus // --------------------------------------------------------------------------- // -// `Smith` does not consume a corpus entry as raw input. It reads a byte stream -// in which a slice is a little-endian `u32` length followed by that many bytes, -// so every entry below is length-prefixed. An entry that carries only the slice -// leaves the format index and the reader-buffer length at the low end of their -// ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the +// `Smith` does not consume a corpus entry as raw input, so every entry below +// goes through the `smith_encode.zig` encoder. An entry that carries only the +// slice leaves the format index and the reader-buffer length at the low end of +// their ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the // common case. /// Past `compiler.max_line_len`, so the discard arm at compiler.zig:72 replays @@ -148,18 +149,6 @@ const long_line = "a" ** 5000 ++ ".example.com"; const long_line_unterminated = "0.0.0.0 kept.example.com\n" ++ long_line; const long_line_terminated = long_line_unterminated ++ "\n0.0.0.0 after.example.com\n"; -/// Encodes `bytes` as a single `Smith.slice` value. -fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { - return &struct { - const value: [4 + bytes.len]u8 = blk: { - var buf: [4 + bytes.len]u8 = undefined; - std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); - buf[4..].* = bytes[0..bytes.len].*; - break :blk buf; - }; - }.value; -} - const corpus = [_][]const u8{ sliceInput(long_line_unterminated), sliceInput(long_line_terminated), @@ -173,12 +162,3 @@ test "the unterminated corpus entry ends on an over-long line" { const last = std.mem.findScalarLast(u8, long_line_unterminated, '\n').? + 1; try std.testing.expect(long_line_unterminated.len - last > compiler.max_line_len); } - -test "a corpus entry carries its own length" { - const encoded = sliceInput(long_line); - try std.testing.expectEqual( - @as(u32, long_line.len), - std.mem.readInt(u32, encoded[0..4], .little), - ); - try std.testing.expectEqualSlices(u8, long_line, encoded[4..]); -} diff --git a/tests/fuzz/corpus.zig b/tests/fuzz/corpus.zig index 86fc527..e4b9bf7 100644 --- a/tests/fuzz/corpus.zig +++ b/tests/fuzz/corpus.zig @@ -14,6 +14,9 @@ const std = @import("std"); const dns = @import("dns"); +const smith_encode = @import("smith_encode.zig"); + +const sliceInput = smith_encode.sliceInput; /// A query for example.com A with an EDNS(0) OPT record advertising 4096 /// bytes: id 0x1234, RD set, one question, one additional. @@ -119,18 +122,6 @@ const max_jumps = dns.types.max_compression_jumps; pub const chain_at_cap = pointerChain(max_jumps); pub const chain_past_cap = pointerChain(max_jumps + 1); -/// Encodes `bytes` as a single `Smith.slice` value. -fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { - return &struct { - const value: [4 + bytes.len]u8 = blk: { - var buf: [4 + bytes.len]u8 = undefined; - std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); - buf[4..].* = bytes[0..bytes.len].*; - break :blk buf; - }; - }.value; -} - /// Encodes `bytes` as a `Smith.slice` value followed by one integer, which the /// name target reads as an offset and the TTL target as an elapsed time. fn sliceIntInput(comptime bytes: []const u8, comptime int: u64) *const [12 + bytes.len]u8 { @@ -181,8 +172,12 @@ test "the pointer chain has the documented shape" { try std.testing.expectEqual(@as(u16, 0xc000), std.mem.readInt(u16, chain_at_cap[3..5], .big)); } -test "a slice input carries its own length" { - const encoded = sliceInput(query); +test "a slice-plus-integer input carries the length, the bytes and the integer" { + const encoded = sliceIntInput(query, 29); try std.testing.expectEqual(@as(u32, query.len), std.mem.readInt(u32, encoded[0..4], .little)); - try std.testing.expectEqualSlices(u8, query, encoded[4..]); + try std.testing.expectEqualSlices(u8, query, encoded[4 .. 4 + query.len]); + try std.testing.expectEqual( + @as(u64, 29), + std.mem.readInt(u64, encoded[4 + query.len ..][0..8], .little), + ); } diff --git a/tests/fuzz/http_util_fuzz.zig b/tests/fuzz/http_util_fuzz.zig index 8bb65b6..46c2c47 100644 --- a/tests/fuzz/http_util_fuzz.zig +++ b/tests/fuzz/http_util_fuzz.zig @@ -29,7 +29,10 @@ const std = @import("std"); const http_util = @import("http_util"); +const smith_encode = @import("smith_encode.zig"); +const sliceInput = smith_encode.sliceInput; +const pairInput = smith_encode.pairInput; const Smith = std.testing.Smith; /// A target longer than this is a 414 before it reaches any parser @@ -149,9 +152,8 @@ fn expectPrefixOf(result: []const u8, buffer: []const u8) !void { // corpus // --------------------------------------------------------------------------- // -// `Smith` does not consume a corpus entry as raw parser input. It reads a byte -// stream in which a slice is a little-endian `u32` length followed by that many -// bytes, so every entry below is length-prefixed. The three targets share one +// `Smith` does not consume a corpus entry as raw parser input, so every entry +// below goes through the `smith_encode.zig` encoders. The three targets share one // corpus: each starts with a slice, and the query target reads a second one that // falls back to empty when an entry carries only the first. @@ -169,31 +171,6 @@ const deep_path = "/1/2/3/4/5/6/7/8/9"; /// through, and a plus that means different things under the two rules. const bad_escapes = "/%2/%/%zz/a+b"; -/// Encodes `bytes` as a single `Smith.slice` value. -fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { - return &struct { - const value: [4 + bytes.len]u8 = blk: { - var buf: [4 + bytes.len]u8 = undefined; - std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); - buf[4..].* = bytes[0..bytes.len].*; - break :blk buf; - }; - }.value; -} - -/// Encodes two `Smith.slice` values back to back, which is what the query target -/// reads as its query string and its key. -fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 { - return &struct { - const value: [8 + a.len + b.len]u8 = blk: { - var buf: [8 + a.len + b.len]u8 = undefined; - buf[0 .. 4 + a.len].* = sliceInput(a).*; - buf[4 + a.len ..].* = sliceInput(b).*; - break :blk buf; - }; - }.value; -} - const corpus = [_][]const u8{ sliceInput(api_path), sliceInput(encoded_slash), @@ -207,20 +184,3 @@ const corpus = [_][]const u8{ pairInput("domain=" ++ "x" ** 1024, "domain"), pairInput("domain=%zz", "domain"), }; - -test "a corpus entry carries its own length" { - const encoded = sliceInput(api_path); - try std.testing.expectEqual( - @as(u32, api_path.len), - std.mem.readInt(u32, encoded[0..4], .little), - ); - try std.testing.expectEqualSlices(u8, api_path, encoded[4..]); -} - -test "a paired corpus entry carries both lengths" { - const encoded = pairInput("a=1", "a"); - try std.testing.expectEqual(@as(u32, 3), std.mem.readInt(u32, encoded[0..4], .little)); - try std.testing.expectEqualSlices(u8, "a=1", encoded[4..7]); - try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, encoded[7..11], .little)); - try std.testing.expectEqualSlices(u8, "a", encoded[11..]); -} diff --git a/tests/fuzz/smith_encode.zig b/tests/fuzz/smith_encode.zig new file mode 100644 index 0000000..ec15fa0 --- /dev/null +++ b/tests/fuzz/smith_encode.zig @@ -0,0 +1,64 @@ +//! The `std.testing.Smith` byte encoding, shared by every fuzz corpus. +//! +//! `Smith` does not consume a corpus entry as raw parser input. It reads a byte +//! stream in which a slice is a little-endian `u32` length followed by that many +//! bytes, and an integer is a little-endian `u64`. Every corpus entry in +//! `tests/fuzz/` is therefore length-prefixed, and every fuzz file used to spell +//! the same encoder out. +//! +//! This file imports nothing but `std` on purpose. The fuzz targets root +//! separate modules over different parts of `src/` — `blocklist_fuzz.zig` cannot +//! import `corpus.zig`, because `corpus.zig` needs the `dns` module that the +//! blocklist target's build does not have. Each fuzz module reaches this file by +//! relative path and compiles its own copy, so the dedup is at the source level; +//! the self-tests below run once per fuzz artifact. + +const std = @import("std"); + +/// Encodes `bytes` as a single `Smith.slice` value. +pub fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { + return &struct { + const value: [4 + bytes.len]u8 = blk: { + var buf: [4 + bytes.len]u8 = undefined; + std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); + buf[4..].* = bytes[0..bytes.len].*; + break :blk buf; + }; + }.value; +} + +/// Encodes two `Smith.slice` values back to back, which is what a target that +/// reads two entities takes: the pattern and the domain, or the query string and +/// the key. +pub fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 { + return &struct { + const value: [8 + a.len + b.len]u8 = blk: { + var buf: [8 + a.len + b.len]u8 = undefined; + buf[0 .. 4 + a.len].* = sliceInput(a).*; + buf[4 + a.len ..].* = sliceInput(b).*; + break :blk buf; + }; + }.value; +} + +test "a slice input carries its own length" { + const encoded = sliceInput("ads.example.com"); + try std.testing.expectEqual( + @as(u32, "ads.example.com".len), + std.mem.readInt(u32, encoded[0..4], .little), + ); + try std.testing.expectEqualSlices(u8, "ads.example.com", encoded[4..]); +} + +test "a paired input carries both lengths" { + const encoded = pairInput("*.a.b", "x.a.b"); + try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[0..4], .little)); + try std.testing.expectEqualSlices(u8, "*.a.b", encoded[4..9]); + try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[9..13], .little)); + try std.testing.expectEqualSlices(u8, "x.a.b", encoded[13..]); +} + +test "an empty slice input is four bytes of zero" { + const encoded = sliceInput(""); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, encoded); +} diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx index f2c460d..f2475ee 100644 --- a/web/src/auth/LoginPage.tsx +++ b/web/src/auth/LoginPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { useRouter, useSearch } from "@tanstack/react-router"; import { ApiError } from "@/lib/api"; import { useAuth } from "@/auth/store"; +import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; export function safeRedirect(raw: string | undefined): string { if (raw === undefined) return "/"; @@ -94,13 +95,13 @@ export default function LoginPage() { required value={password} onChange={(event) => setPassword(event.target.value)} - className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900" + className={inputClass} /> diff --git a/web/src/features/blocklists/BlocklistForm.tsx b/web/src/features/blocklists/BlocklistForm.tsx index 6cb5096..c7745f4 100644 --- a/web/src/features/blocklists/BlocklistForm.tsx +++ b/web/src/features/blocklists/BlocklistForm.tsx @@ -1,9 +1,7 @@ import { useState, type FormEvent } from "react"; import InlineError from "@/lib/InlineError"; import type { Blocklist, BlocklistInput } from "@/lib/types"; - -const INPUT_CLASS = - "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"; +import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; interface BlocklistFormProps { initial?: Blocklist; @@ -45,7 +43,7 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel required value={url} onChange={(event) => setUrl(event.target.value)} - className={INPUT_CLASS} + className={inputClass} />
@@ -58,27 +56,24 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel required value={name} onChange={(event) => setName(event.target.value)} - className={INPUT_CLASS} + className={inputClass} />
- {onCancel !== undefined && ( - )} diff --git a/web/src/features/blocklists/BlocklistsPage.tsx b/web/src/features/blocklists/BlocklistsPage.tsx index 31c7617..a6501fe 100644 --- a/web/src/features/blocklists/BlocklistsPage.tsx +++ b/web/src/features/blocklists/BlocklistsPage.tsx @@ -13,9 +13,15 @@ import { import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types"; import BlocklistForm from "./BlocklistForm"; import SourceStatusSection from "./SourceStatusSection"; - -const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700"; -const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"; +import { + dangerLinkButtonClass, + focusRing, + linkButtonClass, + primaryButtonClass, + tableWrapClass, + tdClass, + thClass, +} from "@/ui/classes"; export default function BlocklistsPage() { const queryClient = useQueryClient(); @@ -66,7 +72,7 @@ export default function BlocklistsPage() { type="button" onClick={() => updateNow.mutate()} disabled={updateNow.isPending} - className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" + className={primaryButtonClass} > {updateNow.isPending ? "Updating…" : "Update now"} @@ -81,18 +87,18 @@ export default function BlocklistsPage() { {blocklists.length === 0 ? (

No blocklist sources yet. Add one below.

) : ( -
+
- - - - - - - - + + + + + + + @@ -100,7 +106,7 @@ export default function BlocklistsPage() { {blocklists.map((b) => ( - - - - - - - + + + -
NameURLEnabledDomainsWildcardsSkipped regexLast updated + NameURLEnabledDomainsWildcardsSkipped regexLast updated Actions
+ {b.name} {b.is_suggested && ( @@ -108,32 +114,33 @@ export default function BlocklistsPage() { )} + {b.url} + toggleEnabled(b)} + className={focusRing} /> {b.domain_count}{b.wildcard_count}{b.skipped_regex_count} + {b.domain_count}{b.wildcard_count}{b.skipped_regex_count} {b.last_updated === null ? "never" : formatTime(b.last_updated)} +
@@ -141,7 +148,7 @@ export default function BlocklistsPage() { type="button" onClick={() => deleteBlocklist(b)} disabled={remove.isPending} - className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400" + className={dangerLinkButtonClass} > Delete diff --git a/web/src/features/blocklists/SourceStatusSection.tsx b/web/src/features/blocklists/SourceStatusSection.tsx index a3948a2..916f89b 100644 --- a/web/src/features/blocklists/SourceStatusSection.tsx +++ b/web/src/features/blocklists/SourceStatusSection.tsx @@ -1,8 +1,6 @@ import { formatTime } from "@/lib/format"; import type { SourceStatus } from "@/lib/types"; - -const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700"; -const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"; +import { tdClass, thClass } from "@/ui/classes"; function formatAttempt(unixSeconds: number): string { return unixSeconds === 0 ? "never" : formatTime(unixSeconds); @@ -28,26 +26,26 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus - - - - - - - - + + + + + + + + {sources.map((source) => ( - - - - - - - - + + + + +
SourceStateLast attemptLast successDomainsWildcardsSkipped regexLast errorSourceStateLast attemptLast successDomainsWildcardsSkipped regexLast error
+ {namesById.get(source.id) ?? source.url} {source.url} + {formatAttempt(source.last_attempt)}{formatAttempt(source.last_success)}{source.domains}{source.wildcards}{source.skipped_regex} + {formatAttempt(source.last_attempt)}{formatAttempt(source.last_success)}{source.domains}{source.wildcards}{source.skipped_regex} {source.last_error === "" ? ( ) : ( diff --git a/web/src/features/clients/ClientEditDialog.tsx b/web/src/features/clients/ClientEditDialog.tsx index dc87414..0dc0871 100644 --- a/web/src/features/clients/ClientEditDialog.tsx +++ b/web/src/features/clients/ClientEditDialog.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { clientUpdateMutation } from "@/lib/queries"; import type { Client, Group } from "@/lib/types"; import InlineError from "@/lib/InlineError"; +import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes"; interface Props { client: Client; @@ -10,8 +11,7 @@ interface Props { onClose: () => void; } -const inputClass = - "mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"; +const dialogInputClass = `mt-1 w-full ${smallInputClass}`; export default function ClientEditDialog({ client, groups, onClose }: Props) { const queryClient = useQueryClient(); @@ -44,7 +44,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) { type="text" value={name} onChange={(event) => setName(event.target.value)} - className={inputClass} + className={dialogInputClass} autoFocus /> @@ -53,7 +53,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) { @@ -70,14 +69,14 @@ export default function ClientsPage() { setConfirmingId(null); deleteMutation.mutate(client.id); }} - className={`${buttonClass} text-red-700 dark:text-red-400`} + className={`${smallButtonClass} text-red-700 dark:text-red-400`} > Confirm delete @@ -87,14 +86,14 @@ export default function ClientsPage() { diff --git a/web/src/features/clients/PrefixesEditor.tsx b/web/src/features/clients/PrefixesEditor.tsx index 75dee06..882ad25 100644 --- a/web/src/features/clients/PrefixesEditor.tsx +++ b/web/src/features/clients/PrefixesEditor.tsx @@ -4,14 +4,13 @@ import { clientPrefixesPutMutation } from "@/lib/queries"; import type { ClientPrefix, Group } from "@/lib/types"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import InlineError from "@/lib/InlineError"; +import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; interface Props { prefixes: ClientPrefix[]; groups: Group[]; } -const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"; - export default function PrefixesEditor({ prefixes, groups }: Props) { const queryClient = useQueryClient(); const mutation = useMutation(clientPrefixesPutMutation(queryClient)); @@ -50,7 +49,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) { onChange={(event) => dispatch({ type: "edit", index, patch: { prefix: event.target.value } }) } - className={`${inputClass} w-52`} + className={`${smallInputClass} w-52`} />
diff --git a/web/src/features/local/LocalDnsPage.tsx b/web/src/features/local/LocalDnsPage.tsx index 48354cd..732d8da 100644 --- a/web/src/features/local/LocalDnsPage.tsx +++ b/web/src/features/local/LocalDnsPage.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import RecordsTab from "@/features/local/RecordsTab"; import ZonesTab from "@/features/local/ZonesTab"; +import { focusRing } from "@/ui/classes"; type Tab = "records" | "zones"; @@ -25,7 +26,7 @@ function TabButton({ aria-controls={controls} aria-selected={selected} onClick={onClick} - className={`-mb-px border-b-2 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${ + className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${ selected ? "border-blue-600 text-blue-600 dark:text-blue-400" : "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" diff --git a/web/src/features/local/RecordsTab.tsx b/web/src/features/local/RecordsTab.tsx index 9a36044..7d8f5a8 100644 --- a/web/src/features/local/RecordsTab.tsx +++ b/web/src/features/local/RecordsTab.tsx @@ -1,5 +1,5 @@ import { useId, useState, type FormEvent } from "react"; -import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { localRecordCreateMutation, localRecordDeleteMutation, @@ -8,20 +8,18 @@ import { } from "@/lib/queries"; import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types"; import InlineError from "@/lib/InlineError"; +import { useCrudForm } from "@/ui/useCrudForm"; +import { + formCardClass, + inputClass, + largeButtonClass, + largePrimaryButtonClass, + rowButtonClass, + tableWrapClass, +} from "@/ui/classes"; const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"]; -const inputClass = - "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"; -const primaryButtonClass = - "rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"; -const secondaryButtonClass = - "rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"; -const rowButtonClass = - "rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"; - -type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord }; - function RecordForm({ initial, busy, @@ -49,10 +47,7 @@ function RecordForm({ } return ( - +

{initial === undefined ? "New record" : `Edit ${initial.name}`}

- -
@@ -128,50 +123,29 @@ function RecordForm({ export default function RecordsTab() { const records = useSuspenseQuery(localRecordsQuery()).data; - const queryClient = useQueryClient(); - const create = useMutation(localRecordCreateMutation(queryClient)); - const update = useMutation(localRecordUpdateMutation(queryClient)); - const remove = useMutation(localRecordDeleteMutation(queryClient)); - const [form, setForm] = useState(null); - - function openForm(next: FormState) { - create.reset(); - update.reset(); - setForm(next); - } - - function onSubmit(input: LocalRecordInput) { - if (form === null) return; - if (form.mode === "create") { - create.mutate(input, { onSuccess: () => setForm(null) }); - } else { - update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) }); - } - } - - function onDelete(record: LocalRecord) { - if (!window.confirm(`Delete record "${record.name}"?`)) return; - remove.mutate(record.id); - } + const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< + LocalRecord, + LocalRecordInput + >({ + create: localRecordCreateMutation, + update: localRecordUpdateMutation, + remove: localRecordDeleteMutation, + confirmDelete: (record) => `Delete record "${record.name}"?`, + }); return (

Answers served directly for LAN names. Changes apply live.

-
{form?.mode === "create" && ( - setForm(null)} - /> + )} -
+
@@ -202,14 +176,14 @@ export default function RecordsTab() { )} {records.map((record) => ( - {form?.mode === "edit" && form.record.id === record.id ? ( + {form?.mode === "edit" && form.entity.id === record.id ? ( ) : ( @@ -221,7 +195,7 @@ export default function RecordsTab() {
setForm(null)} + onCancel={closeForm} /> - @@ -89,31 +84,15 @@ function ZoneForm({ export default function ZonesTab() { const zones = useSuspenseQuery(forwardZonesQuery()).data; - const queryClient = useQueryClient(); - const create = useMutation(forwardZoneCreateMutation(queryClient)); - const update = useMutation(forwardZoneUpdateMutation(queryClient)); - const remove = useMutation(forwardZoneDeleteMutation(queryClient)); - const [form, setForm] = useState(null); - - function openForm(next: FormState) { - create.reset(); - update.reset(); - setForm(next); - } - - function onSubmit(input: ForwardZoneInput) { - if (form === null) return; - if (form.mode === "create") { - create.mutate(input, { onSuccess: () => setForm(null) }); - } else { - update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) }); - } - } - - function onDelete(zone: ForwardZone) { - if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return; - remove.mutate(zone.id); - } + const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< + ForwardZone, + ForwardZoneInput + >({ + create: forwardZoneCreateMutation, + update: forwardZoneUpdateMutation, + remove: forwardZoneDeleteMutation, + confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`, + }); return (
@@ -121,20 +100,15 @@ export default function ZonesTab() {

Names under these zones go to their own resolver. Changes apply live.

-
{form?.mode === "create" && ( - setForm(null)} - /> + )} -
+
@@ -159,14 +133,14 @@ export default function ZonesTab() { )} {zones.map((zone) => ( - {form?.mode === "edit" && form.zone.id === zone.id ? ( + {form?.mode === "edit" && form.entity.id === zone.id ? ( ) : ( @@ -176,7 +150,7 @@ export default function ZonesTab() {
setForm(null)} + onCancel={closeForm} /> diff --git a/web/src/features/pause/PauseWidget.tsx b/web/src/features/pause/PauseWidget.tsx index 9f00dd7..76b448d 100644 --- a/web/src/features/pause/PauseWidget.tsx +++ b/web/src/features/pause/PauseWidget.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { pauseMutation, pauseQuery } from "@/lib/queries"; import InlineError from "@/lib/InlineError"; +import { buttonClass, insetFocusRing } from "@/ui/classes"; const DURATIONS = [ { label: "60 seconds", seconds: 60 }, @@ -34,8 +35,7 @@ function useNowSeconds(active: boolean): number { return now; } -const BUTTON_CLASS = - "rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600"; +const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`; export default function PauseWidget() { const queryClient = useQueryClient(); @@ -52,7 +52,7 @@ export default function PauseWidget() { if (data === undefined) { return ( - ); @@ -69,7 +69,7 @@ export default function PauseWidget() { type="button" onClick={() => mutation.mutate({ paused: false })} disabled={mutation.isPending} - className={BUTTON_CLASS} + className={triggerButtonClass} > Resume @@ -92,7 +92,7 @@ export default function PauseWidget() { aria-controls="pause-menu" onClick={() => setMenuOpen((open) => !open)} disabled={mutation.isPending} - className={BUTTON_CLASS} + className={triggerButtonClass} > Pause @@ -111,7 +111,7 @@ export default function PauseWidget() { seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds }, ); }} - className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800" + className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`} > {label} diff --git a/web/src/features/queries/QueryLogPage.tsx b/web/src/features/queries/QueryLogPage.tsx index 9f1e80b..37d3fc6 100644 --- a/web/src/features/queries/QueryLogPage.tsx +++ b/web/src/features/queries/QueryLogPage.tsx @@ -5,11 +5,10 @@ import { formatMicros, formatTime } from "@/lib/format"; import { queriesInfiniteQuery } from "@/lib/queries"; import type { QueriesFilter, QueryRow } from "@/lib/types"; import { qtypeName } from "./qtype"; +import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes"; -const inputClass = - "mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"; -const buttonClass = - "rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"; +const filterInputClass = `mt-1 w-full ${smallInputClass}`; +const toolbarButtonClass = `${buttonClass} font-medium`; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -134,7 +133,7 @@ export default function QueryLogPage() { type="text" value={domain} onChange={(event) => setDomain(event.target.value)} - className={inputClass} + className={filterInputClass} />
- - {base.isFetching && ( @@ -197,7 +200,7 @@ export default function QueryLogPage() {

) : ( <> -
+
@@ -219,7 +222,7 @@ export default function QueryLogPage() { type="button" onClick={loadMore} disabled={base.isFetchingNextPage || base.isPlaceholderData} - className={buttonClass} + className={toolbarButtonClass} > {base.isFetchingNextPage ? "Loading…" : "Load more"} diff --git a/web/src/features/rules/RulesPage.tsx b/web/src/features/rules/RulesPage.tsx index 0b7eb14..021a41d 100644 --- a/web/src/features/rules/RulesPage.tsx +++ b/web/src/features/rules/RulesPage.tsx @@ -4,11 +4,7 @@ import { formatTime } from "@/lib/format"; import InlineError from "@/lib/InlineError"; import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries"; import type { Rule, RuleAction, RuleKind } from "@/lib/types"; - -const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700"; -const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"; -const INPUT_CLASS = - "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"; +import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; export default function RulesPage() { const queryClient = useQueryClient(); @@ -44,16 +40,16 @@ export default function RulesPage() { {rules.length === 0 ? (

No allow or block rules yet. Create one below.

) : ( -
+
- - - - - - + + + + + @@ -61,9 +57,9 @@ export default function RulesPage() { {rules.map((rule) => ( - - - + + - - - + +
PatternKindActionGroupCreated + PatternKindActionGroupCreated Actions
{rule.pattern}{rule.kind} + {rule.pattern}{rule.kind} {rule.group}{formatTime(rule.created_at)} + {rule.group}{formatTime(rule.created_at)} @@ -107,7 +103,7 @@ export default function RulesPage() { value={pattern} onChange={(event) => setPattern(event.target.value)} placeholder="ads.example.com or *.example.com" - className={INPUT_CLASS} + className={inputClass} />
@@ -119,7 +115,7 @@ export default function RulesPage() { id="rule-kind" value={kind} onChange={(event) => setKind(event.target.value as RuleKind)} - className={INPUT_CLASS} + className={inputClass} > @@ -133,7 +129,7 @@ export default function RulesPage() { id="rule-action" value={action} onChange={(event) => setAction(event.target.value as RuleAction)} - className={INPUT_CLASS} + className={inputClass} > @@ -147,7 +143,7 @@ export default function RulesPage() { id="rule-group" value={groupId} onChange={(event) => setGroupId(Number(event.target.value))} - className={INPUT_CLASS} + className={inputClass} > {groups.map((group) => (
- diff --git a/web/src/features/settings/RestartBanner.tsx b/web/src/features/settings/RestartBanner.tsx index bb68ebf..6194d31 100644 --- a/web/src/features/settings/RestartBanner.tsx +++ b/web/src/features/settings/RestartBanner.tsx @@ -1,4 +1,5 @@ import { dismissRestartBanner, useRestartBanner } from "./restartBanner"; +import { focusRing } from "@/ui/classes"; export default function RestartBanner() { const raised = useRestartBanner(); @@ -12,7 +13,7 @@ export default function RestartBanner() { diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx index 29a4a93..04ec708 100644 --- a/web/src/features/settings/SettingsPage.tsx +++ b/web/src/features/settings/SettingsPage.tsx @@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsPatch } from "@/lib/types"; import { raiseRestartBanner } from "./restartBanner"; +import { focusRing } from "@/ui/classes"; /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ export function patchRequiresRestart(patch: SettingsPatch): boolean { @@ -120,8 +121,7 @@ const SECTIONS: readonly SectionDef[] = [ ]; const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; -const INPUT_CLASS = - "rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"; +const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`; function FieldRow({ section, @@ -143,7 +143,7 @@ function FieldRow({ type="checkbox" checked={value as boolean} onChange={(e) => onChange(e.target.checked)} - className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" + className={focusRing} />