docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
This commit is contained in:
+79
-430
@@ -1,123 +1,40 @@
|
||||
# Milestone 7: serving pipeline composition (PLAN Phase 7)
|
||||
|
||||
Goal: `nxdns run` serves DNS end to end — rate limit → parse → group → local records →
|
||||
forward zones → filtering → safe-search → cache → upstream → CNAME uncloaking → cache store →
|
||||
async query log — with client auto-materialization, pause/resume, and a composition root that
|
||||
starts every background loop and shuts down cleanly on SIGINT/SIGTERM.
|
||||
Goal: `nxdns run` serves DNS end to end — rate limit → parse → group → local records → forward zones → filtering → safe-search → cache → upstream → CNAME uncloaking → cache store → async query log — with client auto-materialization, pause/resume, and a composition root that starts every background loop and shuts down cleanly on SIGINT/SIGTERM.
|
||||
|
||||
Ground truth: PLAN.md §4 (pipeline diagram, lines 151-173), §6 (DNS behavior), §7.2
|
||||
(auto-materialization), §8 (cache key), §10 (rate limit), §11.4/§11.6 (logging, disk), Phase 7
|
||||
text at PLAN.md:606-608. Verified Zig facts: specs/research/zig-0.16-api-notes.md plus the
|
||||
scratchpad note m7-explore-zig.md (signals, Io.Group, cancelable net ops) — the load-bearing
|
||||
facts are restated inline below where a session needs them.
|
||||
Ground truth: PLAN.md §4 (pipeline diagram, lines 151-173), §6 (DNS behavior), §7.2 (auto-materialization), §8 (cache key), §10 (rate limit), §11.4/§11.6 (logging, disk), Phase 7 text at PLAN.md:606-608. Verified Zig facts: specs/research/zig-0.16-api-notes.md plus the scratchpad note m7-explore-zig.md (signals, Io.Group, cancelable net ops) — the load-bearing facts are restated inline below where a session needs them.
|
||||
|
||||
## Rulings (engineering calls resolving PLAN ambiguities — binding)
|
||||
|
||||
1. **Scope**: Phase 7 owns `nxdns run` end to end: `src/app.zig` (composition root),
|
||||
`src/server/shutdown.zig` (signal → event), the `cli.runRun` body, and both listener edits.
|
||||
2. **Signals**: no signalfd/epoll/timerfd. `std.posix.sigaction` handler for INT and TERM that
|
||||
calls `std.Io.Event.set` on a file-scope event (`set` is async-signal-safe on the Threaded
|
||||
Linux backend: raw futex wake, no locks — Threaded.zig:1105,17235).
|
||||
3. **Locking**: `DnsCache` and `RateLimiter` each get one `std.Io.Mutex` owned by the handler.
|
||||
Household scale makes contention irrelevant; sharding is over-engineering.
|
||||
4. **Null snapshot** (`Manager.acquire` returns null before the first successful reload): fail
|
||||
open — skip group/filter/safe-search, use the default group semantics, count
|
||||
`unfiltered_queries`. DNS availability beats filtering for a household. The app attempts one
|
||||
synchronous `reload` before serving; a failure warns and serving starts anyway.
|
||||
5. **Cache scope**: one global cache, no group in the key (PLAN §8). Safe because blocked
|
||||
responses are never cached. Safe-search responses are not cached either (13 domains, rare;
|
||||
caching them under the rewritten name would force answer re-encoding on hit). Paused-mode
|
||||
upstream answers cache normally (they are ordinary upstream responses). Forward-zone
|
||||
responses cache normally (PLAN §6.5) under the same global key.
|
||||
6. **Local vs forward zone**: local records win (checked first; PLAN §6.4 puts them before
|
||||
everything but rate limit/parse).
|
||||
7. **Forward zones bypass** filtering, safe-search, and uncloaking (PLAN §6.5). They are logged
|
||||
with `upstream` = the resolver's canonical text.
|
||||
8. **Rate limit position**: after the 12-byte header read (the REFUSED reply needs the ID),
|
||||
before full parse. Refused queries are counted (`Stats.refused`), never query-logged.
|
||||
Localhost is not exempt (PLAN §10's localhost relaxation is the Phase 8 API limiter only).
|
||||
9. **Non-IN qclass**: skip filtering/cache/safe-search/uncloak, go straight upstream, log
|
||||
normally. Rare and harmless; special-casing CHAOS is scope creep.
|
||||
10. **Safe-search mechanics** (per safesearch.zig:56 doc): the outgoing query's QNAME becomes
|
||||
the target; the reply to the client keeps the original question, starts with a synthesized
|
||||
CNAME original→target, then copies only the A/AAAA answer records from the upstream
|
||||
response verbatim (their RDATA is raw bytes, so no compression-pointer re-encoding; other
|
||||
rtypes are skipped). Applies only on the upstream path — never to local records or forward
|
||||
zones.
|
||||
11. **CNAME uncloaking** (PLAN §6.3): walk the answer-section CNAME chain of the upstream
|
||||
response — no re-resolution. Depth 8 = at most 8 CNAME links followed. Each target is
|
||||
normalized and evaluated for the same group; a blocked target synthesizes a blocked
|
||||
response for the ORIGINAL qname/qtype. `block_reason` = `"cname:" ++ @tagName(reason)`
|
||||
(fits the 32-byte Entry field). Uncloaked-blocked responses are not cached. Skipped when
|
||||
paused, for forward zones, and for non-IN.
|
||||
12. **Local CNAME records** are returned as-is (writeAnswers emits the CNAME and stops,
|
||||
milestone-5 As-built). The client re-queries the target, which then flows through the full
|
||||
pipeline — that is how §6.4's "targets resolve through the normal pipeline" is satisfied.
|
||||
13. **ECS** (PLAN §6.1): `strip` (default) removes a client-sent ECS option from the outgoing
|
||||
query by rebuilding it (header + question + OPT sans option 8); other options are
|
||||
preserved. `forward` sends the query unchanged and puts the raw ECS option payload
|
||||
(≤ `dns_cache.max_ecs_len` = 40 bytes) into the cache key. If a rewritten query cannot fit
|
||||
the 512-byte build buffer (freak OPT payloads), the original is forwarded unchanged,
|
||||
`Stats.ecs_strip_failed` counts it, and the query is excluded from the cache in both
|
||||
directions (the subnet reached the resolver; the strip-mode key says nothing about it).
|
||||
14. **Client auto-materialization** (PLAN §7.2): never a hot-path DB write. The handler records
|
||||
the client in a mutex-guarded fixed-capacity pending table; a background loop flushes every
|
||||
60 s with its OWN config.db connection: `INSERT ... hand_edited=0 ON CONFLICT(ip) DO UPDATE
|
||||
last_seen`. At most one write per client per flush. Table full → drop silently (counted).
|
||||
15. **Snapshot staleness**: a materialized client reaches the matcher snapshot only on the next
|
||||
manager reload. Correct behavior does not depend on it: `groupForClient` already falls back
|
||||
to prefix/default, and materialized rows exist for UI visibility (Phase 8 reads the DB
|
||||
directly). No reload is triggered per client.
|
||||
16. **Client pruning** (PLAN §7.2 + milestone-6 ruling 1): the same background loop prunes
|
||||
daily: `DELETE FROM clients WHERE hand_edited=0 AND last_seen < now - retention_days`
|
||||
(`logging.retention_days`, same knob as the query log — documented). `retention.zig` stays
|
||||
frozen and querylog-only. `last_seen` is the only signal (§3.6 forbids cross-DB joins).
|
||||
17. **Disk gate**: the client flush/prune loop skips its pass when `writesAllowed()` is false;
|
||||
the logger holds its batches. As built: milestone-6.md:410 assigned the manager's
|
||||
consumption to Phase 7 and no seam existed, so S6 added `Manager.monitor:
|
||||
?*disk_monitor.Monitor` (set by the composition root after `init`, before `runScheduler`)
|
||||
plus a `refreshes_gated` counter read through `refreshesGated()`. Only scheduled passes are
|
||||
gated: `runScheduler`'s periodic pass and the source-refresh half of the startup pass.
|
||||
`reload` and `refreshAll` stay ungated (operator actions). The startup pass's
|
||||
`reloadLocked` runs ahead of the gate: publishing an already-compiled snapshot is a read,
|
||||
and a full disk must not cost the household its filtering as well as its downloads. A
|
||||
gated pass logs one warn line and counts.
|
||||
18. **Pause/resume** (PLAN §13.1 mechanism): suspends FILTERING only (evaluate + safe-search +
|
||||
uncloak are skipped); everything else — local records, forward zones, cache, upstream,
|
||||
logging — runs unchanged. Global, not per-group. In-memory only; a restart resumes
|
||||
filtering (safe default). State: one `std.atomic.Value(i64)` — 0 = active, -1 = paused
|
||||
indefinitely, else = paused until that unix second (auto-resume by compare, no timer task).
|
||||
Phase 8's endpoints will call `pauseFor`/`unpause` on the shared pointer.
|
||||
19. **Paused queries** log `blocked=false, block_reason=""`; `Stats.paused_queries` counts
|
||||
them. Evaluation is skipped entirely (not evaluated-then-ignored).
|
||||
20. **Log field semantics**: `response_time_us` = wall time from `handle` entry to reply ready.
|
||||
`cache_hit`: true/false on the upstream path, null for local/forward/refused-never-logged;
|
||||
`upstream`: "" on cache hit, "local" for local records, resolver text for forward zones,
|
||||
the pool endpoint's canonical text for upstream answers (pool already tracks which entry
|
||||
answered — if unavailable without new plumbing, "" and a spec note; do not rebuild the
|
||||
pool for it: `upstream` may be "pool" as the compromise, S2 decides and records).
|
||||
`block_reason` = `@tagName(matcher.Reason)` truncated to 32, `"cname:"`-prefixed for
|
||||
uncloaking. Malformed/dropped queries are never query-logged (handler stats only).
|
||||
21. **Connections**: config.db — one opened by the app (migrate/bootstrap/readConfig, then
|
||||
handed to the manager for reloads), plus one dedicated to the client loop. querylog.db —
|
||||
one for the log writer, one for retention (retention.zig:79 contract). Four total.
|
||||
22. **Shutdown order**: shutdown event fires → `logger.shutdown(io)` (closes the queue; the
|
||||
writer drains and its task returns; a writer stuck in the disk gate holds its final batch —
|
||||
known, documented) → `group.cancel(io)` (requests cancel and joins every task) → deinit
|
||||
servers/pool/manager/state → close DBs → return. Canceled UDP recv / TCP accept return
|
||||
`error.Canceled` on the Threaded backend (Threaded.zig:12439,12877).
|
||||
23. **No runtime config re-read** in Phase 7. The blocklist scheduler reloads snapshots;
|
||||
everything else is restart-required until Phase 8 mutates the DB and calls reload.
|
||||
24. **No new config fields, no schema change.** Pause is in-memory; pruning reuses
|
||||
`retention_days`; ECS uses the existing `edns.ecs_mode`.
|
||||
25. **Performance** (PLAN §18): no formal benchmark in this milestone; the integration test
|
||||
asserts correctness only. The targets stay in PLAN for a later measurement pass.
|
||||
1. **Scope**: Phase 7 owns `nxdns run` end to end: `src/app.zig` (composition root), `src/server/shutdown.zig` (signal → event), the `cli.runRun` body, and both listener edits.
|
||||
2. **Signals**: no signalfd/epoll/timerfd. `std.posix.sigaction` handler for INT and TERM that calls `std.Io.Event.set` on a file-scope event (`set` is async-signal-safe on the Threaded Linux backend: raw futex wake, no locks — Threaded.zig:1105,17235).
|
||||
3. **Locking**: `DnsCache` and `RateLimiter` each get one `std.Io.Mutex` owned by the handler. Household scale makes contention irrelevant; sharding is over-engineering.
|
||||
4. **Null snapshot** (`Manager.acquire` returns null before the first successful reload): fail open — skip group/filter/safe-search, use the default group semantics, count `unfiltered_queries`. DNS availability beats filtering for a household. The app attempts one synchronous `reload` before serving; a failure warns and serving starts anyway.
|
||||
5. **Cache scope**: one global cache, no group in the key (PLAN §8). Safe because blocked responses are never cached. Safe-search responses are not cached either (13 domains, rare; caching them under the rewritten name would force answer re-encoding on hit). Paused-mode upstream answers cache normally (they are ordinary upstream responses). Forward-zone responses cache normally (PLAN §6.5) under the same global key.
|
||||
6. **Local vs forward zone**: local records win (checked first; PLAN §6.4 puts them before everything but rate limit/parse).
|
||||
7. **Forward zones bypass** filtering, safe-search, and uncloaking (PLAN §6.5). They are logged with `upstream` = the resolver's canonical text.
|
||||
8. **Rate limit position**: after the 12-byte header read (the REFUSED reply needs the ID), before full parse. Refused queries are counted (`Stats.refused`), never query-logged. Localhost is not exempt (PLAN §10's localhost relaxation is the Phase 8 API limiter only).
|
||||
9. **Non-IN qclass**: skip filtering/cache/safe-search/uncloak, go straight upstream, log normally. Rare and harmless; special-casing CHAOS is scope creep.
|
||||
10. **Safe-search mechanics** (per safesearch.zig:56 doc): the outgoing query's QNAME becomes the target; the reply to the client keeps the original question, starts with a synthesized CNAME original→target, then copies only the A/AAAA answer records from the upstream response verbatim (their RDATA is raw bytes, so no compression-pointer re-encoding; other rtypes are skipped). Applies only on the upstream path — never to local records or forward zones.
|
||||
11. **CNAME uncloaking** (PLAN §6.3): walk the answer-section CNAME chain of the upstream response — no re-resolution. Depth 8 = at most 8 CNAME links followed. Each target is normalized and evaluated for the same group; a blocked target synthesizes a blocked response for the ORIGINAL qname/qtype. `block_reason` = `"cname:" ++ @tagName(reason)` (fits the 32-byte Entry field). Uncloaked-blocked responses are not cached. Skipped when paused, for forward zones, and for non-IN.
|
||||
12. **Local CNAME records** are returned as-is (writeAnswers emits the CNAME and stops, milestone-5 As-built). The client re-queries the target, which then flows through the full pipeline — that is how §6.4's "targets resolve through the normal pipeline" is satisfied.
|
||||
13. **ECS** (PLAN §6.1): `strip` (default) removes a client-sent ECS option from the outgoing query by rebuilding it (header + question + OPT sans option 8); other options are preserved. `forward` sends the query unchanged and puts the raw ECS option payload (≤ `dns_cache.max_ecs_len` = 40 bytes) into the cache key. If a rewritten query cannot fit the 512-byte build buffer (freak OPT payloads), the original is forwarded unchanged, `Stats.ecs_strip_failed` counts it, and the query is excluded from the cache in both directions (the subnet reached the resolver; the strip-mode key says nothing about it).
|
||||
14. **Client auto-materialization** (PLAN §7.2): never a hot-path DB write. The handler records the client in a mutex-guarded fixed-capacity pending table; a background loop flushes every 60 s with its OWN config.db connection: `INSERT ... hand_edited=0 ON CONFLICT(ip) DO UPDATE last_seen`. At most one write per client per flush. Table full → drop silently (counted).
|
||||
15. **Snapshot staleness**: a materialized client reaches the matcher snapshot only on the next manager reload. Correct behavior does not depend on it: `groupForClient` already falls back to prefix/default, and materialized rows exist for UI visibility (Phase 8 reads the DB directly). No reload is triggered per client.
|
||||
16. **Client pruning** (PLAN §7.2 + milestone-6 ruling 1): the same background loop prunes daily: `DELETE FROM clients WHERE hand_edited=0 AND last_seen < now - retention_days` (`logging.retention_days`, same knob as the query log — documented). `retention.zig` stays frozen and querylog-only. `last_seen` is the only signal (§3.6 forbids cross-DB joins).
|
||||
17. **Disk gate**: the client flush/prune loop skips its pass when `writesAllowed()` is false; the logger holds its batches. As built: milestone-6.md:410 assigned the manager's consumption to Phase 7 and no seam existed, so S6 added `Manager.monitor: ?*disk_monitor.Monitor` (set by the composition root after `init`, before `runScheduler`) plus a `refreshes_gated` counter read through `refreshesGated()`. Only scheduled passes are gated: `runScheduler`'s periodic pass and the source-refresh half of the startup pass. `reload` and `refreshAll` stay ungated (operator actions). The startup pass's `reloadLocked` runs ahead of the gate: publishing an already-compiled snapshot is a read, and a full disk must not cost the household its filtering as well as its downloads. A gated pass logs one warn line and counts.
|
||||
18. **Pause/resume** (PLAN §13.1 mechanism): suspends FILTERING only (evaluate + safe-search + uncloak are skipped); everything else — local records, forward zones, cache, upstream, logging — runs unchanged. Global, not per-group. In-memory only; a restart resumes filtering (safe default). State: one `std.atomic.Value(i64)` — 0 = active, -1 = paused indefinitely, else = paused until that unix second (auto-resume by compare, no timer task). Phase 8's endpoints will call `pauseFor`/`unpause` on the shared pointer.
|
||||
19. **Paused queries** log `blocked=false, block_reason=""`; `Stats.paused_queries` counts them. Evaluation is skipped entirely (not evaluated-then-ignored).
|
||||
20. **Log field semantics**: `response_time_us` = wall time from `handle` entry to reply ready. `cache_hit`: true/false on the upstream path, null for local/forward/refused-never-logged; `upstream`: "" on cache hit, "local" for local records, resolver text for forward zones, the pool endpoint's canonical text for upstream answers (pool already tracks which entry answered — if unavailable without new plumbing, "" and a spec note; do not rebuild the pool for it: `upstream` may be "pool" as the compromise, S2 decides and records). `block_reason` = `@tagName(matcher.Reason)` truncated to 32, `"cname:"`-prefixed for uncloaking. Malformed/dropped queries are never query-logged (handler stats only).
|
||||
21. **Connections**: config.db — one opened by the app (migrate/bootstrap/readConfig, then handed to the manager for reloads), plus one dedicated to the client loop. querylog.db — one for the log writer, one for retention (retention.zig:79 contract). Four total.
|
||||
22. **Shutdown order**: shutdown event fires → `logger.shutdown(io)` (closes the queue; the writer drains and its task returns; a writer stuck in the disk gate holds its final batch — known, documented) → `group.cancel(io)` (requests cancel and joins every task) → deinit servers/pool/manager/state → close DBs → return. Canceled UDP recv / TCP accept return `error.Canceled` on the Threaded backend (Threaded.zig:12439,12877).
|
||||
23. **No runtime config re-read** in Phase 7. The blocklist scheduler reloads snapshots; everything else is restart-required until Phase 8 mutates the DB and calls reload.
|
||||
24. **No new config fields, no schema change.** Pause is in-memory; pruning reuses `retention_days`; ECS uses the existing `edns.ecs_mode`.
|
||||
25. **Performance** (PLAN §18): no formal benchmark in this milestone; the integration test asserts correctness only. The targets stay in PLAN for a later measurement pass.
|
||||
|
||||
## Sessions
|
||||
|
||||
Dependency graph: S1 + S4 parallel (wave 1) → S2 (needs S1's edns API) → S3 (needs S2's
|
||||
signature in code) → S6 (needs S2, S3, S4) → S7 (needs S6). The orchestrator wires
|
||||
`src/tests.zig` after each wave.
|
||||
Dependency graph: S1 + S4 parallel (wave 1) → S2 (needs S1's edns API) → S3 (needs S2's signature in code) → S6 (needs S2, S3, S4) → S7 (needs S6). The orchestrator wires `src/tests.zig` after each wave.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,13 +59,7 @@ pub fn stripEcs(query: []const u8, pkt: packet.Packet, opt: OptRecord, out: []u8
|
||||
error{ BadOption, Overflow }!StripResult
|
||||
```
|
||||
|
||||
Constraints: pure, allocation-free, no Io. The rebuilt query keeps the original ID, flags and
|
||||
question bytes verbatim (copy `query[0..opt_record_offset_of_question_end]` — concretely: copy
|
||||
everything up to the OPT record's start; the OPT is the sole additional record in a query per
|
||||
the existing handler validation, and `packet.parse` yields its offset). ARCOUNT stays 1 when
|
||||
the OPT is re-emitted (it always is — only the option list shrinks). Use the existing
|
||||
`encodeOpt` for the re-emission. The RDATA walk follows the same bounds checks `findOption`
|
||||
uses.
|
||||
Constraints: pure, allocation-free, no Io. The rebuilt query keeps the original ID, flags and question bytes verbatim (copy `query[0..opt_record_offset_of_question_end]` — concretely: copy everything up to the OPT record's start; the OPT is the sole additional record in a query per the existing handler validation, and `packet.parse` yields its offset). ARCOUNT stays 1 when the OPT is re-emitted (it always is — only the option list shrinks). Use the existing `encodeOpt` for the re-emission. The RDATA walk follows the same bounds checks `findOption` uses.
|
||||
|
||||
### S1.2 Acceptance
|
||||
|
||||
@@ -161,36 +72,13 @@ uses.
|
||||
|
||||
### S1 As built
|
||||
|
||||
Two spec claims were wrong and the implementation corrects them. (1) The handler does NOT
|
||||
guarantee the OPT is the sole additional record (handler.zig:105 rejects only misplaced OPTs;
|
||||
packet.parse rejects only a second OPT) — `stripEcs` therefore copies the bytes after the OPT
|
||||
verbatim and all four section counts survive. Caveat, documented on the function: a compression
|
||||
pointer in a trailing record that targets a byte inside the OPT's option list shifts after the
|
||||
rewrite — such a pointer is already malformed (RFC 1035 §4.1.4) and the consequence is an
|
||||
upstream FORMERR. (2) `packet.parse` does not yield the OPT's start offset; a private
|
||||
`optRecordStart` re-walks the sections and matches the record on its full RDATA span, so a
|
||||
hand-built `OptRecord` cannot aim it at a different record (deriving the start backwards is
|
||||
forgeable: a `0x00xx` transaction ID makes a pointer's low byte decode as a root name).
|
||||
`encodeOpt` takes one contiguous options slice, so `stripEcs` emits it with an empty list,
|
||||
streams the survivors, and patches RDLENGTH at `opt_rdlength_offset` (9); a guard test pins
|
||||
that offset against `encodeOpt`'s real output. All options with code 8 are removed (a malformed
|
||||
query can carry several). Walk failures map to `error.BadOption` (frozen error set; reachable
|
||||
only for hand-assembled Packets, documented). After review: `stripEcs` asserts both that
|
||||
`query` aliases `pkt.bytes` and that `out` is pointer-range disjoint from `query` in both
|
||||
directions; the caller owns keeping the rebuild buffer separate from the received query
|
||||
buffer (S2's `scratch.build` satisfies this). The import is aliased `packet_mod` (seven existing declarations
|
||||
named `packet`). `ecsPayload` was built here, then removed in the review round when the
|
||||
handler's `subnetForKey` single-walk replaced its only caller (see S2 As-built); the
|
||||
forward-mode clamp policy lives there — a payload longer than `dns_cache.max_ecs_len` (40)
|
||||
makes the query uncacheable (truncating the key would merge distinct subnets). 7 S1 tests
|
||||
remain, 25 in file.
|
||||
Two spec claims were wrong and the implementation corrects them. (1) The handler does NOT guarantee the OPT is the sole additional record (handler.zig:105 rejects only misplaced OPTs; packet.parse rejects only a second OPT) — `stripEcs` therefore copies the bytes after the OPT verbatim and all four section counts survive. Caveat, documented on the function: a compression pointer in a trailing record that targets a byte inside the OPT's option list shifts after the rewrite — such a pointer is already malformed (RFC 1035 §4.1.4) and the consequence is an upstream FORMERR. (2) `packet.parse` does not yield the OPT's start offset; a private `optRecordStart` re-walks the sections and matches the record on its full RDATA span, so a hand-built `OptRecord` cannot aim it at a different record (deriving the start backwards is forgeable: a `0x00xx` transaction ID makes a pointer's low byte decode as a root name). `encodeOpt` takes one contiguous options slice, so `stripEcs` emits it with an empty list, streams the survivors, and patches RDLENGTH at `opt_rdlength_offset` (9); a guard test pins that offset against `encodeOpt`'s real output. All options with code 8 are removed (a malformed query can carry several). Walk failures map to `error.BadOption` (frozen error set; reachable only for hand-assembled Packets, documented). After review: `stripEcs` asserts both that `query` aliases `pkt.bytes` and that `out` is pointer-range disjoint from `query` in both directions; the caller owns keeping the rebuild buffer separate from the received query buffer (S2's `scratch.build` satisfies this). The import is aliased `packet_mod` (seven existing declarations named `packet`). `ecsPayload` was built here, then removed in the review round when the handler's `subnetForKey` single-walk replaced its only caller (see S2 As-built); the forward-mode clamp policy lives there — a payload longer than `dns_cache.max_ecs_len` (40) makes the query uncacheable (truncating the key would merge distinct subnets). 7 S1 tests remain, 25 in file.
|
||||
|
||||
---
|
||||
|
||||
## Session S2: pipeline in server/handler.zig (+ pause.zig)
|
||||
|
||||
Owns: `src/server/handler.zig` (rewrite around the existing skeleton), `src/server/pause.zig`
|
||||
(new). The biggest session. Everything below is frozen interface — S3 and S6 build against it.
|
||||
Owns: `src/server/handler.zig` (rewrite around the existing skeleton), `src/server/pause.zig` (new). The biggest session. Everything below is frozen interface — S3 and S6 build against it.
|
||||
|
||||
### S2.1 pause.zig
|
||||
|
||||
@@ -203,9 +91,7 @@ pub const Pause = struct {
|
||||
};
|
||||
```
|
||||
|
||||
Auto-resume is the compare in `isPaused` — no timer. `.monotonic` ordering is sufficient (a
|
||||
lone i64 flag, no dependent data). Tests: the three states, expiry boundary (`now == until`
|
||||
is resumed), pauseFor overwrites a previous pause.
|
||||
Auto-resume is the compare in `isPaused` — no timer. `.monotonic` ordering is sufficient (a lone i64 flag, no dependent data). Tests: the three states, expiry boundary (`now == until` is resumed), pauseFor overwrites a previous pause.
|
||||
|
||||
### S2.2 Handler struct (frozen for S3/S6)
|
||||
|
||||
@@ -229,10 +115,7 @@ pub const Handler = struct {
|
||||
};
|
||||
```
|
||||
|
||||
Every `?*` defaults to null so existing tests and the check command construct a bare handler
|
||||
exactly as today (upstream + blocking + records/zones + timeout are the only required fields;
|
||||
`records`/`zones` may point at the shared `Records.empty`/`Zones.empty` constants — if
|
||||
`Zones.empty` does not exist yet, S2 adds it mirroring `Records.empty`).
|
||||
Every `?*` defaults to null so existing tests and the check command construct a bare handler exactly as today (upstream + blocking + records/zones + timeout are the only required fields; `records`/`zones` may point at the shared `Records.empty`/`Zones.empty` constants — if `Zones.empty` does not exist yet, S2 adds it mirroring `Records.empty`).
|
||||
|
||||
```zig
|
||||
pub const Scratch = struct {
|
||||
@@ -246,67 +129,29 @@ pub fn handle(self: *Handler, io: std.Io, which: Transport, from: address.NetAdd
|
||||
query: []const u8, response_buf: []u8, scratch: *Scratch) Outcome
|
||||
```
|
||||
|
||||
`Outcome` unchanged. Still no error union — every failure is a synthesized rcode or a counted
|
||||
drop. New Stats counters (all `std.atomic.Value(u64)`): `refused`, `blocked`,
|
||||
`uncloak_blocked`, `local_answers`, `forward_zone_answers`, `cache_hits`, `paused_queries`,
|
||||
`unfiltered_queries`, `safesearch_rewrites`, `ecs_strip_failed`, `tracker_full`.
|
||||
`Outcome` unchanged. Still no error union — every failure is a synthesized rcode or a counted drop. New Stats counters (all `std.atomic.Value(u64)`): `refused`, `blocked`, `uncloak_blocked`, `local_answers`, `forward_zone_answers`, `cache_hits`, `paused_queries`, `unfiltered_queries`, `safesearch_rewrites`, `ecs_strip_failed`, `tracker_full`.
|
||||
|
||||
### S2.3 Pipeline order inside handle (binding; rulings 4-13, 18-20 apply)
|
||||
|
||||
1. Read the 12-byte header (existing code). QR/opcode/qdcount checks as today.
|
||||
2. Rate limit: `limiter_mutex.lock(io)`, `check(now, from.key())`, unlock. Refused →
|
||||
synthesize REFUSED (reuse `synthesize`), count, return. `now` from
|
||||
`std.Io.Clock.awake.now(io)` (limiter windows are awake-anchored, milestone-6 As-built).
|
||||
3. Full parse + OPT validation (existing code). Record `start_us` once from
|
||||
`std.Io.Clock.real.now(io)` for both `response_time_us` and cache `now_s`.
|
||||
2. Rate limit: `limiter_mutex.lock(io)`, `check(now, from.key())`, unlock. Refused → synthesize REFUSED (reuse `synthesize`), count, return. `now` from `std.Io.Clock.awake.now(io)` (limiter windows are awake-anchored, milestone-6 As-built).
|
||||
3. Full parse + OPT validation (existing code). Record `start_us` once from `std.Io.Clock.real.now(io)` for both `response_time_us` and cache `now_s`.
|
||||
4. `tracker.track(from)` (nonblocking; full table counts `tracker_full`).
|
||||
5. Snapshot: `if (self.manager) |m| m.acquire(io) else null`; released once on every exit path
|
||||
(single defer). Null snapshot → `unfiltered_queries`, group features off (ruling 4).
|
||||
5. Snapshot: `if (self.manager) |m| m.acquire(io) else null`; released once on every exit path (single defer). Null snapshot → `unfiltered_queries`, group features off (ruling 4).
|
||||
6. Non-IN qclass → step 12 (upstream direct), no cache (ruling 9).
|
||||
7. Normalize qname into `scratch.normalize`.
|
||||
8. Local records: `hasName` → `lookup`; answer via `ResponseBuilder` + `writeAnswers` +
|
||||
`addOptEcho`, authoritative. A name that exists with no records of the qtype → NODATA,
|
||||
authoritative. Log (`upstream="local"`, `cache_hit=null`) and return.
|
||||
9. Forward zones: `zones.match` → construct `ForwardClient` on the stack
|
||||
(`init(zone.resolver, &scratch.frame, self.forward_read_timeout)`), cache get → miss →
|
||||
`exchange` into `response_buf` → cache put (classify), log (`upstream=resolver text`),
|
||||
return. Filtering/safe-search/uncloak bypassed (ruling 7). Exchange failure → SERVFAIL.
|
||||
8. Local records: `hasName` → `lookup`; answer via `ResponseBuilder` + `writeAnswers` + `addOptEcho`, authoritative. A name that exists with no records of the qtype → NODATA, authoritative. Log (`upstream="local"`, `cache_hit=null`) and return.
|
||||
9. Forward zones: `zones.match` → construct `ForwardClient` on the stack (`init(zone.resolver, &scratch.frame, self.forward_read_timeout)`), cache get → miss → `exchange` into `response_buf` → cache put (classify), log (`upstream=resolver text`), return. Filtering/safe-search/uncloak bypassed (ruling 7). Exchange failure → SERVFAIL.
|
||||
10. Pause check (`isPaused(now_s)`) → skip 11 and the uncloak in 14; count `paused_queries`.
|
||||
11. Filtering: `snapshot.evaluate(group, domain)`; blocked → `response.writeBlocked` into
|
||||
`response_buf`, log (`blocked=true`, reason), return. Then safe-search:
|
||||
`snapshot.safeSearch(group)` and `safesearch.rewrite(domain)` → remember the target;
|
||||
outgoing QNAME = target (query rebuilt in `scratch.build`; count `safesearch_rewrites`).
|
||||
12. ECS (ruling 13): `.strip` → `edns.stripEcs` into `scratch.build` (compose with the
|
||||
safe-search rebuild: build once with both the final QNAME and the filtered OPT — one
|
||||
rebuild, not two); `.forward` → `edns.ecsPayload` for the key. Overflow →
|
||||
`ecs_strip_failed`, forward original.
|
||||
13. Cache get (skip for safe-search, ruling 5): key = `buildKey(outgoing qname, qtype, qclass,
|
||||
do_bit, ecs_payload_or_null)` under `cache_mutex`; hit → copy is already in
|
||||
`response_buf`-sized `out` (pass `response_buf`), `packet.setId`, UDP truncation check as
|
||||
today, log (`cache_hit=true`, `upstream=""`), return.
|
||||
14. Upstream: `self.upstream.exchange(io, outgoing, response_buf)` (existing error mapping to
|
||||
SERVFAIL/drop stays). Then uncloak (ruling 11) unless paused/non-IN: iterate
|
||||
`packet.answers`, follow CNAMEs from the outgoing qname via `record.rdataCname`, depth ≤ 8,
|
||||
normalize each target, `evaluate`; blocked → `writeBlocked` for the ORIGINAL question,
|
||||
count `uncloak_blocked`, log (`blocked=true`, `"cname:"` reason), return.
|
||||
15. Safe-search synthesis (ruling 10): new `ResponseBuilder` with the original question,
|
||||
`addAnswer(original, CNAME, target)`, then each A/AAAA answer record of the upstream
|
||||
response verbatim (owner = target name), `addOptEcho`. The upstream bytes are read from
|
||||
`response_buf` while the builder writes into... **the builder needs its own buffer**: build
|
||||
into a second half — concretely `handle` requires `response_buf.len >= 1024` already via
|
||||
assert; S2 adds a `synth: [4096]u8` field to `Scratch` for the safe-search build, then
|
||||
copies the finished reply into `response_buf`. A synthesized reply that cannot fit 4096 →
|
||||
SERVFAIL (safe-search answers are tiny in practice).
|
||||
16. Cache put: upstream path only, not blocked/safe-search (ruling 5): `classify` →
|
||||
`cache_mutex` → `put`. Allocation failure inside put → skip caching (already
|
||||
`Allocator.Error`; count nothing, put's stats cover it).
|
||||
17. UDP truncation check (existing). Log the entry: `Entry.init(.{ .timestamp = now_s, .domain
|
||||
= normalized domain, .client_ip = formatted from, .qtype, .blocked=false, .response_time_us,
|
||||
.cache_hit=false, .upstream = per ruling 20 })`, `logger.log(io, entry)`. Return reply.
|
||||
11. Filtering: `snapshot.evaluate(group, domain)`; blocked → `response.writeBlocked` into `response_buf`, log (`blocked=true`, reason), return. Then safe-search: `snapshot.safeSearch(group)` and `safesearch.rewrite(domain)` → remember the target; outgoing QNAME = target (query rebuilt in `scratch.build`; count `safesearch_rewrites`).
|
||||
12. ECS (ruling 13): `.strip` → `edns.stripEcs` into `scratch.build` (compose with the safe-search rebuild: build once with both the final QNAME and the filtered OPT — one rebuild, not two); `.forward` → `edns.ecsPayload` for the key. Overflow → `ecs_strip_failed`, forward original.
|
||||
13. Cache get (skip for safe-search, ruling 5): key = `buildKey(outgoing qname, qtype, qclass, do_bit, ecs_payload_or_null)` under `cache_mutex`; hit → copy is already in `response_buf`-sized `out` (pass `response_buf`), `packet.setId`, UDP truncation check as today, log (`cache_hit=true`, `upstream=""`), return.
|
||||
14. Upstream: `self.upstream.exchange(io, outgoing, response_buf)` (existing error mapping to SERVFAIL/drop stays). Then uncloak (ruling 11) unless paused/non-IN: iterate `packet.answers`, follow CNAMEs from the outgoing qname via `record.rdataCname`, depth ≤ 8, normalize each target, `evaluate`; blocked → `writeBlocked` for the ORIGINAL question, count `uncloak_blocked`, log (`blocked=true`, `"cname:"` reason), return.
|
||||
15. Safe-search synthesis (ruling 10): new `ResponseBuilder` with the original question, `addAnswer(original, CNAME, target)`, then each A/AAAA answer record of the upstream response verbatim (owner = target name), `addOptEcho`. The upstream bytes are read from `response_buf` while the builder writes into... **the builder needs its own buffer**: build into a second half — concretely `handle` requires `response_buf.len >= 1024` already via assert; S2 adds a `synth: [4096]u8` field to `Scratch` for the safe-search build, then copies the finished reply into `response_buf`. A synthesized reply that cannot fit 4096 → SERVFAIL (safe-search answers are tiny in practice).
|
||||
16. Cache put: upstream path only, not blocked/safe-search (ruling 5): `classify` → `cache_mutex` → `put`. Allocation failure inside put → skip caching (already `Allocator.Error`; count nothing, put's stats cover it).
|
||||
17. UDP truncation check (existing). Log the entry: `Entry.init(.{ .timestamp = now_s, .domain = normalized domain, .client_ip = formatted from, .qtype, .blocked=false, .response_time_us, .cache_hit=false, .upstream = per ruling 20 })`, `logger.log(io, entry)`. Return reply.
|
||||
|
||||
The query-log write happens on every replied path (blocked, local, forward, cache, upstream,
|
||||
paused) — one call site per return is acceptable; a tiny private `logEntry` helper keeps it
|
||||
readable. Refused/malformed/dropped are never logged (rulings 8, 20).
|
||||
The query-log write happens on every replied path (blocked, local, forward, cache, upstream, paused) — one call site per return is acceptable; a tiny private `logEntry` helper keeps it readable. Refused/malformed/dropped are never logged (rulings 8, 20).
|
||||
|
||||
### S2.4 Acceptance
|
||||
|
||||
@@ -326,96 +171,22 @@ readable. Refused/malformed/dropped are never logged (rulings 8, 20).
|
||||
|
||||
### S2 As built
|
||||
|
||||
`Handler` gained one additive field: `negative_ttl_max: u32 = 0` (set from
|
||||
`cfg.cache.negative_ttl_max` — `DnsCache` keeps no copy of its config and `classify` needs the
|
||||
value; **S6 must wire it or negative caching is silently off**). `Scratch` gained `synth:
|
||||
[4096]u8` (spec-blessed) and `uncloak: [types.max_name_len]u8` (target normalization must not
|
||||
clobber `normalize`, which still holds the logged name). `Zones.empty` already existed
|
||||
(forward_zones.zig:34) — the S2.2 parenthetical was stale. Both mutexes and the tracker use
|
||||
`lockUncancelable` — `std.Io.Mutex.lock` returns `Cancelable!void` (Io.zig:1602) and `handle`
|
||||
has no error union. Safe-search + ECS strip compose via two buffers: rebuild into
|
||||
`scratch.build`, strip into `scratch.synth` (stripEcs requires non-overlapping output); `synth`
|
||||
is reused for the reply after the upstream answers. Uncloaking is also skipped after a
|
||||
safe-search rewrite (the answers belong to the provider's target, which the client never asked
|
||||
about). `safesearch_rewrites` counts at the rebuild, not the table lookup. SERVFAIL paths are
|
||||
counted but never query-logged (the Entry schema has no status field; a logged row would read
|
||||
as success — accepted ruling). Forward zones are exempt from the ECS strip (accepted ruling:
|
||||
the RFC 7871 concern is third-party upstreams, not the LAN resolver). Ruling 20's open choice:
|
||||
`upstream = "pool"` for pool answers; forward-zone rows log `cache_hit` false/true (ruling 20's
|
||||
"null" contradicted ruling 5 — null is now local records and blocked answers only). The
|
||||
safe-search CNAME TTL is the minimum TTL of the copied A/AAAA records, zero when none.
|
||||
`handle` keeps the 512-byte floor assert (the spec's "1024" was wrong); the copy out of
|
||||
`synth` is length-checked with SERVFAIL fallback. `tracker_full` mirrors
|
||||
`Tracker.snapshotStats().dropped_full` after each track (track returns nothing; an always-zero
|
||||
counter would lie). `max_cname_depth = 8` public. `queries` still means pool-answered queries.
|
||||
23 new handler tests + 25 adapted + 6 pause tests. After review, two ECS cache holes fixed at
|
||||
the root: (1) a failed ECS strip forwards the client's subnet upstream, so the answer may be
|
||||
subnet-specific while the `.strip`-mode key names no subnet — `outgoingQuery` returns
|
||||
`Outgoing { bytes, cacheable }` and a failed strip sets `cacheable = false`, skipping both the
|
||||
cache get and the put; (2) `.forward` mode forwards the whole OPT but `edns.findOption`
|
||||
reports only the first ECS option, so a query carrying several would key on one subnet while
|
||||
the resolver answered for another — a local `subnetForKey` walks the option list once and
|
||||
returns `.uncacheable` for a repeated option 8, for a payload over `dns_cache.max_ecs_len`,
|
||||
and for a list that will not walk; `cacheKey` then declines to cache. RFC 7871 §6 permits one
|
||||
ECS option, but a query is client-controlled, so a duplicate is treated as hostile. Both fixes
|
||||
are mutation-checked. `edns.ecsPayload` lost its only caller (the key needs the option count
|
||||
as well as the payload; one walk yields both) and is removed from edns.zig as dead surface. NOTE: the plain `zig build test` does not
|
||||
typecheck the listener call sites (serve is never analyzed there) — only `-Dintegration`
|
||||
validates S3's edit.
|
||||
`Handler` gained one additive field: `negative_ttl_max: u32 = 0` (set from `cfg.cache.negative_ttl_max` — `DnsCache` keeps no copy of its config and `classify` needs the value; **S6 must wire it or negative caching is silently off**). `Scratch` gained `synth: [4096]u8` (spec-blessed) and `uncloak: [types.max_name_len]u8` (target normalization must not clobber `normalize`, which still holds the logged name). `Zones.empty` already existed (forward_zones.zig:34) — the S2.2 parenthetical was stale. Both mutexes and the tracker use `lockUncancelable` — `std.Io.Mutex.lock` returns `Cancelable!void` (Io.zig:1602) and `handle` has no error union. Safe-search + ECS strip compose via two buffers: rebuild into `scratch.build`, strip into `scratch.synth` (stripEcs requires non-overlapping output); `synth` is reused for the reply after the upstream answers. Uncloaking is also skipped after a safe-search rewrite (the answers belong to the provider's target, which the client never asked about). `safesearch_rewrites` counts at the rebuild, not the table lookup. SERVFAIL paths are counted but never query-logged (the Entry schema has no status field; a logged row would read as success — accepted ruling). Forward zones are exempt from the ECS strip (accepted ruling: the RFC 7871 concern is third-party upstreams, not the LAN resolver). Ruling 20's open choice: `upstream = "pool"` for pool answers; forward-zone rows log `cache_hit` false/true (ruling 20's "null" contradicted ruling 5 — null is now local records and blocked answers only). The safe-search CNAME TTL is the minimum TTL of the copied A/AAAA records, zero when none. `handle` keeps the 512-byte floor assert (the spec's "1024" was wrong); the copy out of `synth` is length-checked with SERVFAIL fallback. `tracker_full` mirrors `Tracker.snapshotStats().dropped_full` after each track (track returns nothing; an always-zero counter would lie). `max_cname_depth = 8` public. `queries` still means pool-answered queries. 23 new handler tests + 25 adapted + 6 pause tests. After review, two ECS cache holes fixed at the root: (1) a failed ECS strip forwards the client's subnet upstream, so the answer may be subnet-specific while the `.strip`-mode key names no subnet — `outgoingQuery` returns `Outgoing { bytes, cacheable }` and a failed strip sets `cacheable = false`, skipping both the cache get and the put; (2) `.forward` mode forwards the whole OPT but `edns.findOption` reports only the first ECS option, so a query carrying several would key on one subnet while the resolver answered for another — a local `subnetForKey` walks the option list once and returns `.uncacheable` for a repeated option 8, for a payload over `dns_cache.max_ecs_len`, and for a list that will not walk; `cacheKey` then declines to cache. RFC 7871 §6 permits one ECS option, but a query is client-controlled, so a duplicate is treated as hostile. Both fixes are mutation-checked. `edns.ecsPayload` lost its only caller (the key needs the option count as well as the payload; one walk yields both) and is removed from edns.zig as dead surface. NOTE: the plain `zig build test` does not typecheck the listener call sites (serve is never analyzed there) — only `-Dintegration` validates S3's edit.
|
||||
|
||||
---
|
||||
|
||||
## Session S3: listeners pass the client address and scratch
|
||||
|
||||
Owns: `src/server/udp_server.zig`, `src/server/tcp_server.zig`, and the three integration test
|
||||
files whose `Handler{…}` literals the S2 struct change broke:
|
||||
`src/server/udp_server_integration_test.zig`, `src/server/tcp_server_integration_test.zig`,
|
||||
`src/server/resolver_integration_test.zig` (S2 measured nine stale literals missing
|
||||
`blocking`/`forward_read_timeout`/`records`/`zones` at udp:90/128/163/204, tcp:153/183/216/268,
|
||||
resolver:229, plus the handle-arity change once those are fixed).
|
||||
Owns: `src/server/udp_server.zig`, `src/server/tcp_server.zig`, and the three integration test files whose `Handler{…}` literals the S2 struct change broke: `src/server/udp_server_integration_test.zig`, `src/server/tcp_server_integration_test.zig`, `src/server/resolver_integration_test.zig` (S2 measured nine stale literals missing `blocking`/`forward_read_timeout`/`records`/`zones` at udp:90/128/163/204, tcp:153/183/216/268, resolver:229, plus the handle-arity change once those are fixed).
|
||||
|
||||
- `Slot` (udp) gains `scratch: handler.Scratch`; the call becomes
|
||||
`self.handler.handle(io, .udp, address.NetAddress.fromIp(slot.from), slot.query[0..slot.len],
|
||||
&slot.reply, &slot.scratch)`.
|
||||
- `Conn` (tcp) gains `peer: std.Io.net.IpAddress` (captured at claim from
|
||||
`stream.socket.address`) and `scratch: handler.Scratch`; call updated the same way.
|
||||
- No other behavior change. Slot/Conn are pool-allocated already, so the added ~5 KiB per slot
|
||||
changes no lifetime.
|
||||
- Acceptance: fmt/ast clean; both files' existing tests updated and passing; integration tests
|
||||
(`-Dintegration` udp/tcp/resolver) still pass unchanged in behavior.
|
||||
- `Slot` (udp) gains `scratch: handler.Scratch`; the call becomes `self.handler.handle(io, .udp, address.NetAddress.fromIp(slot.from), slot.query[0..slot.len], &slot.reply, &slot.scratch)`.
|
||||
- `Conn` (tcp) gains `peer: std.Io.net.IpAddress` (captured at claim from `stream.socket.address`) and `scratch: handler.Scratch`; call updated the same way.
|
||||
- No other behavior change. Slot/Conn are pool-allocated already, so the added ~5 KiB per slot changes no lifetime.
|
||||
- Acceptance: fmt/ast clean; both files' existing tests updated and passing; integration tests (`-Dintegration` udp/tcp/resolver) still pass unchanged in behavior.
|
||||
|
||||
### S3 As built
|
||||
|
||||
`Conn.peer` is captured in `claim` inside the same mutex-held section that stores the stream —
|
||||
written once per connection, read only by that connection's task; verified from source that
|
||||
`accept` fills `Socket.address` with the peer (net.zig:1442 → Threaded.zig:12439
|
||||
`addressFromPosix`), and a new TCP integration test asserts the captured peer is the real
|
||||
loopback client (an uninitialized field would typecheck and silently corrupt rate
|
||||
limiting/grouping/logging). Importing the `address` module forced renaming the bind/listen
|
||||
address parameters to `bind_address`/`listen_address` (Zig shadowing rule; call sites are
|
||||
positional, nothing else changed). The nine stale integration literals became a per-file
|
||||
`bareHandler` fixture deriving `blocking` from `model.Blocking{}` and `forward_read_timeout`
|
||||
from `model.readTimeout(.{})` (3000 ms, `.awake`), so the fixtures cannot drift from config
|
||||
defaults. Memory-cost comments updated for the scratch: UDP ≈74 KiB/slot (≈4.6 MiB at 64),
|
||||
TCP ≈137 KiB/slot (≈8.8 MiB at 64); the UDP <8 MiB budget assertion still holds.
|
||||
After review: `TcpServer.serve` distinguishes its two exit paths, because the composition root
|
||||
cancels its task group before any listener `deinit` runs (the deinit defers are declared
|
||||
first, so `group.cancel` executes first). `acceptLoop` returns a `Stop` enum: `.closing` when
|
||||
`deinit` stopped it (`error.SocketNotListening`, a `.shutting_down` claim, or the state flag)
|
||||
and `.canceled` when the task is being canceled (`error.Canceled` from `accept` or the retry
|
||||
sleep). On `.closing` the connection group drains under `.blocked` protection as before —
|
||||
`beginShutdown` already unblocked every live stream, so the wait is bounded by the shutdown.
|
||||
On `.canceled` the group is canceled instead: nothing has shut the streams down, `deinit`
|
||||
cannot run until `serve` returns, and RFC 7766 §6.2.1.1 lets a client hold a connection open
|
||||
indefinitely by asking again inside the idle budget — draining would let one client stall
|
||||
process shutdown. The cost is the single reply mid-write. The old await under `.blocked`
|
||||
protection was what made cancel invisible: `Syscall.start` (Threaded.zig:1349) never arms
|
||||
cancellation while protection is blocked. Per-connection cleanup is unaffected (`serveConn`'s
|
||||
`defer finish` runs on the canceled path; `finish` already used `lockUncancelable` + `.blocked`
|
||||
around the close). Pinned by "a canceled serve does not wait for a live connection" (blocked
|
||||
read, 600 s idle budget, cancel must return; reinstating drain-always hangs the suite). The
|
||||
same await shape remains in `UdpServer.serve` deliberately: a UDP task is one bounded upstream
|
||||
exchange, not a client-paced session.
|
||||
`Conn.peer` is captured in `claim` inside the same mutex-held section that stores the stream — written once per connection, read only by that connection's task; verified from source that `accept` fills `Socket.address` with the peer (net.zig:1442 → Threaded.zig:12439 `addressFromPosix`), and a new TCP integration test asserts the captured peer is the real loopback client (an uninitialized field would typecheck and silently corrupt rate limiting/grouping/logging). Importing the `address` module forced renaming the bind/listen address parameters to `bind_address`/`listen_address` (Zig shadowing rule; call sites are positional, nothing else changed). The nine stale integration literals became a per-file `bareHandler` fixture deriving `blocking` from `model.Blocking{}` and `forward_read_timeout` from `model.readTimeout(.{})` (3000 ms, `.awake`), so the fixtures cannot drift from config defaults. Memory-cost comments updated for the scratch: UDP ≈74 KiB/slot (≈4.6 MiB at 64), TCP ≈137 KiB/slot (≈8.8 MiB at 64); the UDP <8 MiB budget assertion still holds. After review: `TcpServer.serve` distinguishes its two exit paths, because the composition root cancels its task group before any listener `deinit` runs (the deinit defers are declared first, so `group.cancel` executes first). `acceptLoop` returns a `Stop` enum: `.closing` when `deinit` stopped it (`error.SocketNotListening`, a `.shutting_down` claim, or the state flag) and `.canceled` when the task is being canceled (`error.Canceled` from `accept` or the retry sleep). On `.closing` the connection group drains under `.blocked` protection as before — `beginShutdown` already unblocked every live stream, so the wait is bounded by the shutdown. On `.canceled` the group is canceled instead: nothing has shut the streams down, `deinit` cannot run until `serve` returns, and RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by asking again inside the idle budget — draining would let one client stall process shutdown. The cost is the single reply mid-write. The old await under `.blocked` protection was what made cancel invisible: `Syscall.start` (Threaded.zig:1349) never arms cancellation while protection is blocked. Per-connection cleanup is unaffected (`serveConn`'s `defer finish` runs on the canceled path; `finish` already used `lockUncancelable` + `.blocked` around the close). Pinned by "a canceled serve does not wait for a live connection" (blocked read, 600 s idle budget, cancel must return; reinstating drain-always hangs the suite). The same await shape remains in `UdpServer.serve` deliberately: a UDP task is one bounded upstream exchange, not a client-paced session.
|
||||
|
||||
---
|
||||
|
||||
@@ -434,9 +205,7 @@ pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void
|
||||
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32
|
||||
```
|
||||
|
||||
`ON CONFLICT(ip) DO UPDATE SET last_seen=excluded.last_seen`. Check the actual clients schema
|
||||
(unique key on ip) in storage/config_schema.zig and use the real column names. The existing
|
||||
`insertClient` (hand_edited=1, import path) stays untouched.
|
||||
`ON CONFLICT(ip) DO UPDATE SET last_seen=excluded.last_seen`. Check the actual clients schema (unique key on ip) in storage/config_schema.zig and use the real column names. The existing `insertClient` (hand_edited=1, import path) stays untouched.
|
||||
|
||||
### S4.2 Tracker
|
||||
|
||||
@@ -455,54 +224,21 @@ pub const Tracker = struct {
|
||||
};
|
||||
```
|
||||
|
||||
`run` is the house loop shape (manager.zig:946): startup no-op, then every `flush_interval_s`
|
||||
on the `.boot` clock: skip the pass when `monitor` says writes are gated (ruling 17); else
|
||||
drain the pending table under the mutex into a local copy (release before I/O), one
|
||||
`upsertSeen` per client with `real` wall-clock seconds; every `prune_every_passes`-th pass also
|
||||
`pruneStale(now - retention_days * 86_400)`. A failed upsert warns once per pass and counts
|
||||
`flush_failures`; entries stay dropped (next query re-tracks). The database handle is the
|
||||
loop's own dedicated config.db connection (ruling 21) — document on `run` like retention does.
|
||||
IP text via `address.NetAddress.format` into a stack buffer.
|
||||
`run` is the house loop shape (manager.zig:946): startup no-op, then every `flush_interval_s` on the `.boot` clock: skip the pass when `monitor` says writes are gated (ruling 17); else drain the pending table under the mutex into a local copy (release before I/O), one `upsertSeen` per client with `real` wall-clock seconds; every `prune_every_passes`-th pass also `pruneStale(now - retention_days * 86_400)`. A failed upsert warns once per pass and counts `flush_failures`; entries stay dropped (next query re-tracks). The database handle is the loop's own dedicated config.db connection (ruling 21) — document on `run` like retention does. IP text via `address.NetAddress.format` into a stack buffer.
|
||||
|
||||
Tests: track dedupes per flush (same client twice → one row, `last_seen` = latest), table
|
||||
full drops and counts, flush inserts hand_edited=0 rows and touches existing hand-edited rows'
|
||||
last_seen only, prune removes only stale hand_edited=0 rows, gated pass writes nothing
|
||||
(in-memory db + a Monitor fixture — Monitor's classify/state accessors allow constructing a
|
||||
gated state; if not constructible without I/O, test the gate branch through a bool seam
|
||||
`writes_allowed` parameter on a private `flushOnce` and have `run` pass the monitor read).
|
||||
Tests: track dedupes per flush (same client twice → one row, `last_seen` = latest), table full drops and counts, flush inserts hand_edited=0 rows and touches existing hand-edited rows' last_seen only, prune removes only stale hand_edited=0 rows, gated pass writes nothing (in-memory db + a Monitor fixture — Monitor's classify/state accessors allow constructing a gated state; if not constructible without I/O, test the gate branch through a bool seam `writes_allowed` parameter on a private `flushOnce` and have `run` pass the monitor read).
|
||||
|
||||
Acceptance: fmt/ast clean; temp-root (with `-lc -lsqlite3`) green; migration untouched.
|
||||
|
||||
### S4 As built
|
||||
|
||||
`init(retention_days: u16)` (matches `model.Logging.retention_days`). The pending table is a
|
||||
fixed 512-entry array with a linear scan, not a hash map — the frozen `init` takes no
|
||||
allocator, and rate_limiter's actual pattern is a pre-reserved hash map, so "same house
|
||||
pattern" did not apply; a linear scan over ≤512 entries of 17-byte keys is fine at household
|
||||
scale. Added: `trackAt(io, addr, now_s)` (timestamp seam; `track` = `trackAt` with real
|
||||
wall-clock), `flushOnce(io, database, writes_allowed: bool)` public (the gate seam `run` feeds
|
||||
from `monitor.writesAllowed()`; S7 case 10 forces passes with it), `snapshotStats(io)` and
|
||||
`pendingClients(io)` — `Stats` stays plain u64 and every field plus `passes` is written under
|
||||
`mutex`, so readers go through the accessors. `last_seen` is stamped at track time (it means
|
||||
"last query", and it is what makes latest-wins dedupe observable); the flush writes each
|
||||
entry's own stamp. A gated pass does not increment `passes` (a skipped pass must not advance
|
||||
the prune schedule). `track` uses `lockUncancelable` — `handle` has no error union, so `track`
|
||||
is not a cancelation point. `upsertSeen` resolves `group_id` via
|
||||
`(SELECT id FROM groups WHERE name = 'default')` — the column is NOT NULL REFERENCES groups
|
||||
(config_schema.zig:26), which ruling 14's sketch omitted; a database with no `default` group
|
||||
fails the whole statement with `error.Constraint` and writes nothing (validate.zig:461 already
|
||||
rejects such configs, so this is a warn-and-continue path in the flush loop, never live). The
|
||||
upsert's DO UPDATE touches `last_seen` only — `name`, `group_id`, `hand_edited`, `first_seen`
|
||||
survive on every existing row, hand-edited or not, one test per column. `pruneStale` uses
|
||||
strict `<` (matches `queries_repo.pruneOlderThan`) and clamps `sqlite3_changes` to u32.
|
||||
16 tests (9 tracker, 7 repo).
|
||||
`init(retention_days: u16)` (matches `model.Logging.retention_days`). The pending table is a fixed 512-entry array with a linear scan, not a hash map — the frozen `init` takes no allocator, and rate_limiter's actual pattern is a pre-reserved hash map, so "same house pattern" did not apply; a linear scan over ≤512 entries of 17-byte keys is fine at household scale. Added: `trackAt(io, addr, now_s)` (timestamp seam; `track` = `trackAt` with real wall-clock), `flushOnce(io, database, writes_allowed: bool)` public (the gate seam `run` feeds from `monitor.writesAllowed()`; S7 case 10 forces passes with it), `snapshotStats(io)` and `pendingClients(io)` — `Stats` stays plain u64 and every field plus `passes` is written under `mutex`, so readers go through the accessors. `last_seen` is stamped at track time (it means "last query", and it is what makes latest-wins dedupe observable); the flush writes each entry's own stamp. A gated pass does not increment `passes` (a skipped pass must not advance the prune schedule). `track` uses `lockUncancelable` — `handle` has no error union, so `track` is not a cancelation point. `upsertSeen` resolves `group_id` via `(SELECT id FROM groups WHERE name = 'default')` — the column is NOT NULL REFERENCES groups (config_schema.zig:26), which ruling 14's sketch omitted; a database with no `default` group fails the whole statement with `error.Constraint` and writes nothing (validate.zig:461 already rejects such configs, so this is a warn-and-continue path in the flush loop, never live). The upsert's DO UPDATE touches `last_seen` only — `name`, `group_id`, `hand_edited`, `first_seen` survive on every existing row, hand-edited or not, one test per column. `pruneStale` uses strict `<` (matches `queries_repo.pruneOlderThan`) and clamps `sqlite3_changes` to u32. 16 tests (9 tracker, 7 repo).
|
||||
|
||||
---
|
||||
|
||||
## Session S6: composition root — app.zig, shutdown.zig, cli wiring
|
||||
|
||||
Owns: `src/app.zig` (new), `src/server/shutdown.zig` (new), `src/cli.zig` (runRun body,
|
||||
DataDir), `src/main.zig` (only if a signature forces it — expected unchanged).
|
||||
Owns: `src/app.zig` (new), `src/server/shutdown.zig` (new), `src/cli.zig` (runRun body, DataDir), `src/main.zig` (only if a signature forces it — expected unchanged).
|
||||
|
||||
### S6.1 shutdown.zig
|
||||
|
||||
@@ -514,53 +250,25 @@ pub fn wait(io: std.Io) std.Io.Cancelable!void // event.wait
|
||||
pub fn trigger(io: std.Io) void // tests and future admin use: event.set
|
||||
```
|
||||
|
||||
Handler fn: `callconv(.c)`, calls set only (async-signal-safe per ruling 2). `flags = 0`, empty
|
||||
mask (matches Threaded's own handlers, Threaded.zig:1653). Do not restore handlers on exit —
|
||||
the process is leaving anyway; `install` is once-per-process (assert).
|
||||
Handler fn: `callconv(.c)`, calls set only (async-signal-safe per ruling 2). `flags = 0`, empty mask (matches Threaded's own handlers, Threaded.zig:1653). Do not restore handlers on exit — the process is leaving anyway; `install` is once-per-process (assert).
|
||||
|
||||
### S6.2 app.zig — `pub fn run(runner: cli.Runner, paths: cli.Paths) u8`
|
||||
|
||||
Startup order (each failure prints to `runner.err` and returns `cli.exit_runtime`):
|
||||
|
||||
1. `DataDir.open` (S6 changes its `openDir` flags to `.{ .iterate = true }` — the monitor
|
||||
needs it; nothing else cares). `openConfigDb`. `migrations.migrate`.
|
||||
`bootstrap.bootstrap(...)` (first run seeds; diags printed like runCheck does).
|
||||
`export.readConfig` into an arena.
|
||||
2. `logging.install(io, cfg.logging, ...)` — the sink goes live (main.zig's std_options
|
||||
already routes).
|
||||
3. Build state, in dependency order, all heap-allocated where pinning is required (Logger,
|
||||
Manager, Pool entries and their DoH/DoT client structs must not move — same pattern as
|
||||
`cli.probeUpstreams` but with owned arrays that outlive the pool):
|
||||
records + zones (`local_repo` listers → `build`), fetcher + manager (`Manager.init`),
|
||||
pool (upstreams_repo → clients array → `Pool.init`), cache (`DnsCache.init`), limiter
|
||||
(`RateLimiter.init` from `cfg.dns`), pause, tracker, logger (`Logger.init` with a
|
||||
heap `queue_buf` of `cfg.logging.query_log_buffer_max` entries), monitor (`Monitor.init`
|
||||
with the data dir), retention (`Retention.init`), querylog connections: add
|
||||
`DataDir.openQuerylogDb(self, io) !db.Db` mirroring openConfigDb (same pragmas + 0600);
|
||||
open TWO (writer, retention) plus ONE extra config.db connection for the tracker
|
||||
(ruling 21).
|
||||
1. `DataDir.open` (S6 changes its `openDir` flags to `.{ .iterate = true }` — the monitor needs it; nothing else cares). `openConfigDb`. `migrations.migrate`. `bootstrap.bootstrap(...)` (first run seeds; diags printed like runCheck does). `export.readConfig` into an arena.
|
||||
2. `logging.install(io, cfg.logging, ...)` — the sink goes live (main.zig's std_options already routes).
|
||||
3. Build state, in dependency order, all heap-allocated where pinning is required (Logger, Manager, Pool entries and their DoH/DoT client structs must not move — same pattern as `cli.probeUpstreams` but with owned arrays that outlive the pool): records + zones (`local_repo` listers → `build`), fetcher + manager (`Manager.init`), pool (upstreams_repo → clients array → `Pool.init`), cache (`DnsCache.init`), limiter (`RateLimiter.init` from `cfg.dns`), pause, tracker, logger (`Logger.init` with a heap `queue_buf` of `cfg.logging.query_log_buffer_max` entries), monitor (`Monitor.init` with the data dir), retention (`Retention.init`), querylog connections: add `DataDir.openQuerylogDb(self, io) !db.Db` mirroring openConfigDb (same pragmas + 0600); open TWO (writer, retention) plus ONE extra config.db connection for the tracker (ruling 21).
|
||||
4. One synchronous `manager.reload(io)`; failure warns and continues (ruling 4).
|
||||
5. Handler construction (S2 struct) + `UdpServer.bind` + `TcpServer.listen` on
|
||||
`cfg.dns.bind_ipv4/bind_ipv6/port` — v4 and v6 both, matching PLAN §7.2 parity; a bind
|
||||
failure is fatal.
|
||||
6. `shutdown.install(io)`. Then one `std.Io.Group`:
|
||||
udp.serve, tcp.serve, logger.runWriter(io, &querylog_writer_db, &monitor),
|
||||
retention.run(io, &querylog_retention_db), monitor.run(io),
|
||||
manager.runScheduler(io), tracker.run(io, &tracker_db, &monitor),
|
||||
and a maintenance loop (private fn in app.zig, house shape, `.boot`, 60 s): cache sweep +
|
||||
limiter sweep under their mutexes... the mutexes live in the Handler; the maintenance loop
|
||||
takes `*Handler` and locks the same mutexes.
|
||||
7. `shutdown.wait(io)` (a Canceled result also proceeds to teardown). Then ruling 22 order:
|
||||
`logger.shutdown(io)` → `group.cancel(io)` → server deinits → pool/manager/state deinits →
|
||||
DB closes → DataDir.close → return `cli.exit_ok`.
|
||||
5. Handler construction (S2 struct) + `UdpServer.bind` + `TcpServer.listen` on `cfg.dns.bind_ipv4/bind_ipv6/port` — v4 and v6 both, matching PLAN §7.2 parity; a bind failure is fatal.
|
||||
6. `shutdown.install(io)`. Then one `std.Io.Group`: udp.serve, tcp.serve, logger.runWriter(io, &querylog_writer_db, &monitor), retention.run(io, &querylog_retention_db), monitor.run(io), manager.runScheduler(io), tracker.run(io, &tracker_db, &monitor), and a maintenance loop (private fn in app.zig, house shape, `.boot`, 60 s): cache sweep + limiter sweep under their mutexes... the mutexes live in the Handler; the maintenance loop takes `*Handler` and locks the same mutexes.
|
||||
7. `shutdown.wait(io)` (a Canceled result also proceeds to teardown). Then ruling 22 order: `logger.shutdown(io)` → `group.cancel(io)` → server deinits → pool/manager/state deinits → DB closes → DataDir.close → return `cli.exit_ok`.
|
||||
|
||||
Log a one-line startup summary (scope `.nxdns`, info): bind addresses, snapshot generation or
|
||||
"unfiltered", upstream count.
|
||||
Log a one-line startup summary (scope `.nxdns`, info): bind addresses, snapshot generation or "unfiltered", upstream count.
|
||||
|
||||
### S6.3 cli.zig
|
||||
|
||||
`runRun` body: `return app.run(r, paths);` — keep the doc comment honest. `DataDir.open` flag
|
||||
change + `openQuerylogDb` as above (both used by app; runCheck untouched).
|
||||
`runRun` body: `return app.run(r, paths);` — keep the doc comment honest. `DataDir.open` flag change + `openQuerylogDb` as above (both used by app; runCheck untouched).
|
||||
|
||||
### S6.4 Acceptance
|
||||
|
||||
@@ -573,88 +281,33 @@ change + `openQuerylogDb` as above (both used by app; runCheck untouched).
|
||||
|
||||
### S6 As built
|
||||
|
||||
Startup order as specified, with these corrections. **Binding**: IPv6 binds first; an
|
||||
`AddressInUse` from the IPv4 bind that follows a wildcard IPv6 bind is dual-stack coverage
|
||||
(info line), not a failure — on Linux `net.ipv6.bindv6only=0` makes the v6 wildcard own the v4
|
||||
port, and `BindOptions.ip6_only` cannot prevent it because Threaded.zig:12274 sets IPV6_V6ONLY
|
||||
to 0 when the option is true (inverted vs its doc); a foreign IPv4 holder would have failed
|
||||
the v6 wildcard bind too, so the inference is sound. `NetAddress.fromIp` unmaps `::ffff:`
|
||||
clients, so v4 clients keep their real identity. A kernel without IPv6
|
||||
(`AddressFamilyUnsupported` and friends) warns and serves IPv4; every other bind failure is
|
||||
fatal. **Exit codes**: configuration faults (`NoUsableUpstreams`, `BadBindAddress`,
|
||||
`BadRateLimit` — zero rate fields are rejected before RateLimiter.init asserts) exit
|
||||
`cli.exit_check` with a "run 'nxdns check'" hint; everything else exits `cli.exit_runtime`.
|
||||
**Querylog open**: `openQuerylogDb` wraps `querylog_schema.open` (creation/repair lives there;
|
||||
chmod of the file plus both WAL sidecars happens after, missing sidecars ignored);
|
||||
`reopenQuerylogDb` opens the extra connections without repeating the O(size) quick_check.
|
||||
**Two `std.http.Client`s**: blocklist downloads must not queue DoH queries behind a
|
||||
multi-megabyte stream. Teardown is defer-driven; the invariants held are: group.cancel joins
|
||||
first, config.db closes after manager.deinit, DataDir closes last. `negative_ttl_max` and
|
||||
`forward_read_timeout` are wired (the S2 trap). `shutdown.zig` has `reset()` for tests and 2
|
||||
in-file tests (orchestrator wired it into tests.zig — app.zig analysis via cli does not
|
||||
collect tests). app.zig itself has no unit test; S7 case 11 is the runtime proof. Smoke test
|
||||
passed: boot, migration 0->2, dual-stack answers on 15353/15354, SERVFAIL from the dead
|
||||
upstream, SIGTERM exit 0 in 0.007 s, DBs 0600, zero err lines. The manager disk gate was wired in a follow-up S6 edit — see ruling 17's as-built text; the
|
||||
gate test drives `Monitor.state_raw` directly (no I/O) and pins no-monitor/ok/warn pass,
|
||||
critical gates-and-counts, recovery resumes. After review: the composition root takes one
|
||||
synchronous `monitor.sample(io)` after `Monitor.init`, before the `Io.Group` spawns anything —
|
||||
`Monitor` initializes to `.ok` and `run` samples inside its own task, so without it the
|
||||
writer/tracker/scheduler would consult a gate still saying "writes allowed" during the boot
|
||||
window on a critically full disk. A failed first sample warns and leaves `.ok` (an unreadable
|
||||
filesystem is not evidence of a full disk — the monitor's own documented policy). `run`'s own
|
||||
first sample repeats it (one extra statvfs + scan at boot; `run`'s sample-then-sleep contract
|
||||
stays untouched). The ordering is not unit-testable (serve binds sockets and opens four
|
||||
databases); S7's boot case plus the statement order is the evidence.
|
||||
Startup order as specified, with these corrections. **Binding**: IPv6 binds first; an `AddressInUse` from the IPv4 bind that follows a wildcard IPv6 bind is dual-stack coverage (info line), not a failure — on Linux `net.ipv6.bindv6only=0` makes the v6 wildcard own the v4 port, and `BindOptions.ip6_only` cannot prevent it because Threaded.zig:12274 sets IPV6_V6ONLY to 0 when the option is true (inverted vs its doc); a foreign IPv4 holder would have failed the v6 wildcard bind too, so the inference is sound. `NetAddress.fromIp` unmaps `::ffff:` clients, so v4 clients keep their real identity. A kernel without IPv6 (`AddressFamilyUnsupported` and friends) warns and serves IPv4; every other bind failure is fatal. **Exit codes**: configuration faults (`NoUsableUpstreams`, `BadBindAddress`, `BadRateLimit` — zero rate fields are rejected before RateLimiter.init asserts) exit `cli.exit_check` with a "run 'nxdns check'" hint; everything else exits `cli.exit_runtime`. **Querylog open**: `openQuerylogDb` wraps `querylog_schema.open` (creation/repair lives there; chmod of the file plus both WAL sidecars happens after, missing sidecars ignored); `reopenQuerylogDb` opens the extra connections without repeating the O(size) quick_check. **Two `std.http.Client`s**: blocklist downloads must not queue DoH queries behind a multi-megabyte stream. Teardown is defer-driven; the invariants held are: group.cancel joins first, config.db closes after manager.deinit, DataDir closes last. `negative_ttl_max` and `forward_read_timeout` are wired (the S2 trap). `shutdown.zig` has `reset()` for tests and 2 in-file tests (orchestrator wired it into tests.zig — app.zig analysis via cli does not collect tests). app.zig itself has no unit test; S7 case 11 is the runtime proof. Smoke test passed: boot, migration 0->2, dual-stack answers on 15353/15354, SERVFAIL from the dead upstream, SIGTERM exit 0 in 0.007 s, DBs 0600, zero err lines. The manager disk gate was wired in a follow-up S6 edit — see ruling 17's as-built text; the gate test drives `Monitor.state_raw` directly (no I/O) and pins no-monitor/ok/warn pass, critical gates-and-counts, recovery resumes. After review: the composition root takes one synchronous `monitor.sample(io)` after `Monitor.init`, before the `Io.Group` spawns anything — `Monitor` initializes to `.ok` and `run` samples inside its own task, so without it the writer/tracker/scheduler would consult a gate still saying "writes allowed" during the boot window on a critically full disk. A failed first sample warns and leaves `.ok` (an unreadable filesystem is not evidence of a full disk — the monitor's own documented policy). `run`'s own first sample repeats it (one extra statvfs + scan at boot; `run`'s sample-then-sleep contract stays untouched). The ordering is not unit-testable (serve binds sockets and opens four databases); S7's boot case plus the statement order is the evidence.
|
||||
|
||||
---
|
||||
|
||||
## Session S7: phase 7 integration tests
|
||||
|
||||
Owns: `src/server/phase7_integration_test.zig` (new; gated on `build_options.integration` like
|
||||
phase6). Fixtures may reuse the S8/M6 helper patterns (in-memory dbs, fake upstream sockets).
|
||||
Owns: `src/server/phase7_integration_test.zig` (new; gated on `build_options.integration` like phase6). Fixtures may reuse the S8/M6 helper patterns (in-memory dbs, fake upstream sockets).
|
||||
|
||||
Cases (each end-to-end through a real `Handler` with real UDP sockets on 127.0.0.1 ephemeral
|
||||
ports; a fake upstream = a UDP socket task answering canned responses):
|
||||
Cases (each end-to-end through a real `Handler` with real UDP sockets on 127.0.0.1 ephemeral ports; a fake upstream = a UDP socket task answering canned responses):
|
||||
|
||||
1. Blocked domain answers 0.0.0.0 with blocking TTL; log entry captured with reason.
|
||||
2. Allow-rule-over-blocklist resolves via upstream.
|
||||
3. Local record answers authoritatively without touching the fake upstream.
|
||||
4. Forward zone: matching suffix goes to the zone resolver socket (not the pool fake), a
|
||||
blocklisted name inside the zone still resolves (bypass), response cached (second query
|
||||
does not hit the zone socket).
|
||||
4. Forward zone: matching suffix goes to the zone resolver socket (not the pool fake), a blocklisted name inside the zone still resolves (bypass), response cached (second query does not hit the zone socket).
|
||||
5. Cache: miss→hit, TTL aged, transaction ID fresh, `cache_hit` logged on the hit.
|
||||
6. Uncloak: fake upstream returns CNAME chain to a blocked target → zero-IP answer with
|
||||
`cname:` reason.
|
||||
7. Safe-search group: query for a safesearch.zig table domain returns CNAME + the fake
|
||||
upstream's A for the target; question is the original name.
|
||||
6. Uncloak: fake upstream returns CNAME chain to a blocked target → zero-IP answer with `cname:` reason.
|
||||
7. Safe-search group: query for a safesearch.zig table domain returns CNAME + the fake upstream's A for the target; question is the original name.
|
||||
8. Rate limit: limit=2 window=60 → third query REFUSED.
|
||||
9. Pause: pauseFor(indefinite) lets a blocked domain resolve; unpause blocks it again.
|
||||
10. Tracker: after queries + one forced flush (`flushOnce` seam or short interval), the
|
||||
clients table holds the source IP with hand_edited=0.
|
||||
11. Full app boot: `app.run` in a task against a temp data dir (bootstrap from a minimal
|
||||
config.zon on an ephemeral port), one real resolve through it, `shutdown.trigger`, task
|
||||
returns 0. This is the lifecycle proof.
|
||||
10. Tracker: after queries + one forced flush (`flushOnce` seam or short interval), the clients table holds the source IP with hand_edited=0.
|
||||
11. Full app boot: `app.run` in a task against a temp data dir (bootstrap from a minimal config.zon on an ephemeral port), one real resolve through it, `shutdown.trigger`, task returns 0. This is the lifecycle proof.
|
||||
|
||||
Acceptance: `zig build test -Dintegration` green, zero std.log.err emitted (binding logging
|
||||
policy), cases deterministic (no external network — the pool's upstream is the fake).
|
||||
Acceptance: `zig build test -Dintegration` green, zero std.log.err emitted (binding logging policy), cases deterministic (no external network — the pool's upstream is the fake).
|
||||
|
||||
### S7 As built
|
||||
|
||||
Case 11 runs against a `std.testing.tmpDir` rather than a fixed scratch path (a committed
|
||||
test cannot hold a machine-specific absolute path; the phase6 fixture already resolves
|
||||
cwd-relative paths through both `std.Io.Dir` and SQLite). Port 15455, IPv4+IPv6 binds, the
|
||||
dead `https://127.0.0.1:9/dns-query` upstream, one seeded local record; `app.serve` installs
|
||||
and deinstalls its own log sink, so `installForTest` is unnecessary and the case asserts the
|
||||
error writer stayed empty; `shutdown.reset()` brackets the case. Case 5 proves TTL ageing
|
||||
with an entry planted ten seconds in the past under the handler's own key — the
|
||||
miss-hit-fresh-ID half is a real round trip; no test can advance the clock. Case 4's zone
|
||||
resolver is a UDP socket task answering with `ResponseBuilder` against the received query;
|
||||
the second query proves the cache by leaving the resolver's datagram count at 1. The fake
|
||||
pool upstream builds each answer from the question it receives (safe search and ECS rewrite
|
||||
the outgoing question, so canned bytes would not bind). A shared `Loop` fixture binds the
|
||||
listener and client before the serve task starts, so no task holds a pointer into a value
|
||||
that later moves. 11 tests, all gated on `build_options.integration`; the integration suite
|
||||
emits zero err-level lines.
|
||||
Case 11 runs against a `std.testing.tmpDir` rather than a fixed scratch path (a committed test cannot hold a machine-specific absolute path; the phase6 fixture already resolves cwd-relative paths through both `std.Io.Dir` and SQLite). Port 15455, IPv4+IPv6 binds, the dead `https://127.0.0.1:9/dns-query` upstream, one seeded local record; `app.serve` installs and deinstalls its own log sink, so `installForTest` is unnecessary and the case asserts the error writer stayed empty; `shutdown.reset()` brackets the case. Case 5 proves TTL ageing with an entry planted ten seconds in the past under the handler's own key — the miss-hit-fresh-ID half is a real round trip; no test can advance the clock. Case 4's zone resolver is a UDP socket task answering with `ResponseBuilder` against the received query; the second query proves the cache by leaving the resolver's datagram count at 1. The fake pool upstream builds each answer from the question it receives (safe search and ECS rewrite the outgoing question, so canned bytes would not bind). A shared `Loop` fixture binds the listener and client before the serve task starts, so no task holds a pointer into a value that later moves. 11 tests, all gated on `build_options.integration`; the integration suite emits zero err-level lines.
|
||||
|
||||
---
|
||||
|
||||
@@ -675,11 +328,7 @@ emits zero err-level lines.
|
||||
|
||||
## File ownership
|
||||
|
||||
S1: dns/edns.zig. S2: server/handler.zig, server/pause.zig. S3: server/udp_server.zig,
|
||||
server/tcp_server.zig. S4: server/clients.zig, storage/repositories/clients_repo.zig.
|
||||
S6: app.zig, server/shutdown.zig, cli.zig, main.zig(if forced). S7: its test file.
|
||||
Orchestrator: src/tests.zig, specs. No parallel sessions share a file (S1∥S4; the rest are
|
||||
sequential).
|
||||
S1: dns/edns.zig. S2: server/handler.zig, server/pause.zig. S3: server/udp_server.zig, server/tcp_server.zig. S4: server/clients.zig, storage/repositories/clients_repo.zig. S6: app.zig, server/shutdown.zig, cli.zig, main.zig(if forced). S7: its test file. Orchestrator: src/tests.zig, specs. No parallel sessions share a file (S1∥S4; the rest are sequential).
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user