Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a261dc2aa3
|
||
|
|
90b85ef954
|
||
|
|
ad26aca198
|
||
|
|
08d756cc87
|
||
|
|
44ecc2c4ae
|
||
|
|
ce143d1d87
|
||
|
|
f7f4c8be09
|
||
|
|
fe71efe335
|
||
|
|
a8fd9fee48
|
||
|
|
f1a85d3dab
|
||
|
|
72dbcbe24f
|
@@ -101,6 +101,11 @@ jobs:
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq -y --no-install-recommends qemu-user
|
||||
|
||||
# qemu 11.1.0 has a TCG regression: its translation-block optimization
|
||||
# takes the panic branch of Zig's UBSan pointer-overflow check on a
|
||||
# valid in-bounds pointer (11.0.3 and earlier are clean; bisected via
|
||||
# the Arch archive). This image's qemu 8.2 is not affected — do not
|
||||
# upgrade the emulator past 11.0.x until qemu fixes it.
|
||||
- name: Run test suite under qemu (plain suite, no -Dintegration)
|
||||
run: zig build test-aarch64 -fqemu
|
||||
|
||||
|
||||
@@ -32,6 +32,23 @@ Any *other* failure text is real. Trust the summary line: `zig build test` exiti
|
||||
|
||||
One trap: running a cached test binary by hand with `--listen=-` aborts with `internal test runner failure: EndOfStream`. That is not a teardown bug; the IPC runner is talking to a closed stdin because no build runner is on the other end. Run the binary with no arguments to get the plain stdio report.
|
||||
|
||||
## Debug-mode miscompile: a `bool` live across an atomic read-modify-write
|
||||
|
||||
In Debug the x86_64 self-hosted backend is the default, and zig 0.16.0's atomic read-modify-write lowering there does not invalidate a `bool` the register allocator is still tracking in EFLAGS. The `bool` silently becomes the flags the `lock xadd` left behind. Release modes go through LLVM and are unaffected, so this can only ever break `zig build test`, never a shipped binary.
|
||||
|
||||
The shape to avoid is a comparison whose result stays live across `fetchAdd`/`fetchSub`/`@atomicRmw` and is then branched on:
|
||||
|
||||
```zig
|
||||
const idle = old.refs == 0; // sete 0x50(%rsp) -- correct
|
||||
_ = self.published.fetchAdd(1, .monotonic); // lock xadd %rdi,(%rsi)
|
||||
// sete 0x51(%rsp) -- bogus, reads EFLAGS from the xadd
|
||||
return if (idle) old else null; // branches on 0x51, not 0x50
|
||||
```
|
||||
|
||||
That function returns `null` for every input. `fetchAdd` is the only trigger: a plain `+= 1`, an atomic `load`, and an atomic `store` in the same slot all compile correctly, and inserting any call (including `std.debug.print`) between the comparison and the branch forces a spill that hides it. Build the same file with `-fllvm` or `-OReleaseSafe` to confirm a suspected instance.
|
||||
|
||||
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
|
||||
|
||||
## Regenerating the contract samples
|
||||
|
||||
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
|
||||
|
||||
@@ -4,6 +4,25 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
|
||||
|
||||
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
|
||||
|
||||
## [0.0.11] - 2026-08-24
|
||||
|
||||
The Overview charts move to visx and grow up: one hover treatment across all four, honest labels, and maintained d3 math under the app's own rendering.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The Overview charts are drawn with visx.** The hand-written chart layout code is replaced by visx 4.0.0 primitives — maintained d3 math for the scales, ticks, stacking and arcs — while the rendering, colours and themes stay the app's own. The charts read as before, with four behaviour improvements: all four charts now share the same hover treatment (the per-client chart and both donuts gain the tooltip and dimming the query timeline already had, so pointing at a ring segment names it, its count and its share), an open tooltip follows a data refresh instead of showing stale counts, and it retires cleanly when the time window rolls. The admin bundle grows by about 68 KB and stays under its size budget.
|
||||
- **The query timeline's third series is called "Allowed".** What the chart called "Other" is every query that was neither blocked nor served from cache — answered upstream, from a local record or a forward zone — so it is now named for what it is rather than for the subtraction that produces it. It stays on the chart even in a window where nothing was allowed, alongside Blocked and Cached: all three name a kind of answer a query can get, and a period where every query was blocked or cached is worth seeing.
|
||||
- **The client chart drops "Other" in a window where it counted nothing.** That series aggregates the clients outside the top eight, so when it counts nothing there is nothing being aggregated, and a legend entry, a tooltip row and a table column that exist only to say "zero" are noise. The named clients stay even at zero, because a client that went quiet is a fact about the window.
|
||||
|
||||
## [0.0.10] - 2026-08-24
|
||||
|
||||
Configuration goes live: when the database owns the configuration, saving a setting reconfigures the running process instead of asking for a restart. The restart-required set shrinks to the listen sockets and the admin interface switch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Almost every settings change now applies while the server runs.** When the database owns the configuration, saving a setting takes effect immediately — the blocking response, upstream timeouts, cache size, rate limits, session lifetime, log level and destination, blocklist update schedule, privacy flags, disk thresholds and the query-log buffer all reconfigure the running process, exactly as Pi-hole and AdGuard Home do. Nothing is written to the database unless the running server already accepted it, so the API can never report a value the process refused. The restart-required set shrinks from every scalar key to the twelve that genuinely need one: listen addresses and ports, and turning the admin interface itself on or off. The admin pages drop their restart notices for everything else, and an upstream edit — the loudest offender — now applies to the next query. A configuration file still works the way it always has: edit the file, restart the process.
|
||||
- **The release cut refuses to ship an undisclosed query-log schema change.** `zig build cut` now compares the `querylog.db` schema fingerprint of the previous release tag against this tree's, and when they differ it requires the changelog section for the version being cut to state that the upgrade discards the stored query history. 0.0.9 changed the schema and its announcement did not mention it; the file is never migrated, so that upgrade silently threw every logged query away.
|
||||
|
||||
## [0.0.9] - 2026-08-22
|
||||
|
||||
Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.
|
||||
|
||||
Generated
+508
-3
@@ -11,6 +11,12 @@
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"@visx/axis": "4.0.0",
|
||||
"@visx/grid": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/tooltip": "4.0.0",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
@@ -1619,6 +1625,84 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
|
||||
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
|
||||
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
|
||||
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
|
||||
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -1633,6 +1717,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
@@ -1647,7 +1737,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1657,7 +1747,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2003,6 +2093,211 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/axis": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/axis/-/axis-4.0.0.tgz",
|
||||
"integrity": "sha512-cSPNO9Aic2UX49AmIeJJ9TQrrAkvUHpHOGqimUAXhbg07VI3HoHDKcXZdrjvQz+g0LhubrUkjj8Gwju2WZ3dtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/point": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/text": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/bounds": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-4.0.0.tgz",
|
||||
"integrity": "sha512-3wAfN5fg2bxg/5f3MlKWzGDTKU2hdKkxq8bVWXpPZQV0UiVc0myuU1qa34c7tN3hg5SDe6MJi/bae2eTQDgEiQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/curve": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.0.tgz",
|
||||
"integrity": "sha512-bXrOSd2BzVCxR7kBE7gPTSU4plxzYN75w7erdHYabwn3vzP+xFk1o0gg3NOHEPJzt5tXqf0Ecm5YqHACOnipTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/vendor": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/grid": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.0.tgz",
|
||||
"integrity": "sha512-BUnznqYo8jyn3zXRWZiF0zwg1VQl6w5kYqZ+XMY+cPXtAHZZbwGXBChMpPefH5Lj1+P1b4Pf3QihKG8zjL9PCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/curve": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/point": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/group": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.0.tgz",
|
||||
"integrity": "sha512-HGAq8BCqn5x0t94CJOLJeKtWss9LFpF3HY69HbuO1PlR+B9c4aVT9xbjfudmU8wGQZLT6JNt+PJvxbO2aJ+1aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.0.tgz",
|
||||
"integrity": "sha512-hwJ9UlIjg3iV4iiJ3RN5SNTXama+zGaNe57sXSrSvPiqhkDXypsYBdDSlAnHmKbMzHP0zFmgB1UJciB/QUyigw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@visx/scale": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.0.tgz",
|
||||
"integrity": "sha512-Q3VaSLKlsrxxSl9CwS2D3Mvz4/4kIp/6NMKRY8Im0Au1hrQh2egU806p0+JwN0ccuGGDe03jyAxX4BSRAH8Plg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/vendor": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/shape": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.0.tgz",
|
||||
"integrity": "sha512-X0FP3OFjQhc5/6Vj2aY7cK2JwKYI0H0hQM7cdNRWy/ZMMlrh5KpEdqx0HVo0KoTeTlkUvO+kydMLnAuuCRFIFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/curve": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/vendor": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/text": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/text/-/text-4.0.0.tgz",
|
||||
"integrity": "sha512-39f2goSaKy5Mqn89lRDGSJ1IMr49wplkbIFHsFI8cPnAPbguiJxYZ+ulizd7Vcsq2z/hytuGv3Lmk165zUiUYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"reduce-css-calc": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/tooltip": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-4.0.0.tgz",
|
||||
"integrity": "sha512-+6J2h5qPvniT0pQtxQrLYGErPRVHr3pRfcMkNqTxD/HgofDGsL6vEBkjMR535MF1hkWYQ9XzOiWP6ERN4TF+mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/bounds": "4.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"react-use-measure": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/vendor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0.tgz",
|
||||
"integrity": "sha512-LoBNzWjTXzBfu095BQxB14IwZQHHOLs8ZMzM6t2FL4ZGORaACgLcmXRRLhFo0VmRR8L7iNTM4HHc1j6yFvzsAA==",
|
||||
"license": "MIT and ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "3.0.3",
|
||||
"@types/d3-color": "3.1.0",
|
||||
"@types/d3-delaunay": "6.0.1",
|
||||
"@types/d3-format": "3.0.1",
|
||||
"@types/d3-geo": "3.1.0",
|
||||
"@types/d3-interpolate": "3.0.1",
|
||||
"@types/d3-path": "3.1.1",
|
||||
"@types/d3-scale": "4.0.2",
|
||||
"@types/d3-shape": "3.1.7",
|
||||
"@types/d3-time": "3.0.0",
|
||||
"@types/d3-time-format": "2.1.0",
|
||||
"d3-array": "3.2.1",
|
||||
"d3-color": "3.1.0",
|
||||
"d3-delaunay": "6.0.2",
|
||||
"d3-format": "3.1.0",
|
||||
"d3-geo": "3.1.0",
|
||||
"d3-interpolate": "3.0.1",
|
||||
"d3-path": "3.1.0",
|
||||
"d3-scale": "4.0.2",
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-time": "3.1.0",
|
||||
"d3-time-format": "4.1.0",
|
||||
"internmap": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||
@@ -2211,6 +2506,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
|
||||
"integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.12",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
|
||||
@@ -2299,6 +2600,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2351,9 +2658,136 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
|
||||
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -2393,6 +2827,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2533,6 +2976,15 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -2946,6 +3398,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-expression-evaluator": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
|
||||
"integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -3254,6 +3712,47 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13",
|
||||
"react-dom": ">=16.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-css-calc": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
|
||||
"integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^0.4.2",
|
||||
"math-expression-evaluator": "^1.2.14",
|
||||
"reduce-function-call": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-function-call": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
|
||||
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-function-call/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -3264,6 +3763,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"@visx/axis": "4.0.0",
|
||||
"@visx/grid": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/tooltip": "4.0.0",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
|
||||
/**
|
||||
* Resolution in database mode: the upstream pool, the local records and the
|
||||
@@ -37,7 +37,7 @@ test("the upstream pool is the default tab and lists every field", async () => {
|
||||
expect((screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
|
||||
expect(screen.getByText(/takes effect at the next restart/)).toBeTruthy();
|
||||
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("adding an upstream posts every field", async () => {
|
||||
@@ -56,24 +56,18 @@ test("adding an upstream posts every field", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("an upstream write re-reads the config status, and the shell states the pending restart", async () => {
|
||||
// The client never decides a restart is owed: the server sets the flag, and
|
||||
// the mutation's invalidation is only what makes the page ask again.
|
||||
let restartPending = false;
|
||||
await openResolution(undefined, {
|
||||
responses: { "GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }) },
|
||||
onWrite: () => {
|
||||
restartPending = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
test("an upstream write applies live, so the tab says nothing about a restart", async () => {
|
||||
// The server rebuilds the pool on the write and echoes `restart_required:
|
||||
// false`, so `restart_pending` stays down and silence is the whole report.
|
||||
await openResolution(undefined, { responses: { "GET /api/config/status": () => DATABASE } });
|
||||
await screen.findByRole("heading", { name: "Add upstream" });
|
||||
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
||||
|
||||
await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
|
||||
await waitFor(() => expect(writes()).toHaveLength(1));
|
||||
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
||||
expect(contentArea().textContent).not.toMatch(/restart/i);
|
||||
});
|
||||
|
||||
test("toggling enabled resends the whole row", async () => {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { DATABASE, MANAGED_FILE, baseSettings, renderPage, stubApi } from "./testFixtures";
|
||||
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";
|
||||
|
||||
/**
|
||||
* System in database mode: the settings form, its diff contract, and the
|
||||
* certificate reload that is a runtime action under both authorities.
|
||||
*/
|
||||
|
||||
// `logging.level` is enum-backed, so the list covers both field renderings: an
|
||||
// input whose label carries the mark, and a `Select` that cannot.
|
||||
const RESTART_KEYS = ["dns.port", "web.port", "logging.level"];
|
||||
// What the server actually reports: the listener binds and `web.enabled`.
|
||||
// Every other key applies live, so it carries no mark at all.
|
||||
const RESTART_KEYS = RESTART_REQUIRED_KEYS;
|
||||
|
||||
let stored: Settings;
|
||||
let putBodies: SettingsPatch[];
|
||||
@@ -32,10 +32,10 @@ function applyPatch(patch: SettingsPatch): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors settings.zig: a patch touching only `web.password` applies live. */
|
||||
function needsRestart(patch: SettingsPatch): boolean {
|
||||
/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
|
||||
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
|
||||
return Object.entries(patch).some(([section, fields]) =>
|
||||
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
|
||||
Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function openSystem() {
|
||||
async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
|
||||
stubApi(DATABASE, {
|
||||
responses: {
|
||||
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
|
||||
"GET /api/settings": () => ({ settings: stored, restart_required: RESTART_KEYS }),
|
||||
"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
|
||||
},
|
||||
onWrite: (call) => {
|
||||
if (call.url !== "/api/settings") return null;
|
||||
@@ -62,8 +62,8 @@ async function openSystem() {
|
||||
putBodies.push(patch);
|
||||
if (putResponse !== null) return putResponse();
|
||||
applyPatch(patch);
|
||||
if (needsRestart(patch)) restartPending = true;
|
||||
return json({ settings: stored, restart_required: RESTART_KEYS });
|
||||
if (needsRestart(patch, restartKeys)) restartPending = true;
|
||||
return json({ settings: stored, restart_required: restartKeys });
|
||||
},
|
||||
});
|
||||
const router = await renderPage("/configuration/system", "System");
|
||||
@@ -103,15 +103,50 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
|
||||
test("a restart-required key is marked as one, from the envelope's list", async () => {
|
||||
await openSystem();
|
||||
|
||||
expect(within(screen.getByRole("group", { name: "DNS" })).getByText("needs restart")).toBeTruthy();
|
||||
// `rate_limit` is not on the list, so it carries no mark.
|
||||
// The DNS binds and the port are the section's whole share of the list;
|
||||
// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
expect(within(cache).queryByText("needs restart")).toBeNull();
|
||||
});
|
||||
|
||||
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
||||
test("every key the server applies live is drawn without restart messaging", async () => {
|
||||
await openSystem();
|
||||
|
||||
// Silence is the report for a live key: no mark on the field, and editing
|
||||
// one owes nothing afterwards either.
|
||||
for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
|
||||
const section = screen.getByRole("group", { name: title });
|
||||
expect(within(section).queryByText("needs restart")).toBeNull();
|
||||
}
|
||||
|
||||
const logging = screen.getByRole("group", { name: "Logging" });
|
||||
fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("a port edit still owes a restart, and the shell says so", async () => {
|
||||
await openSystem();
|
||||
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
await screen.findByText(/Saved changes are not running yet/);
|
||||
});
|
||||
|
||||
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
||||
// No shipped restart-required key is enum-backed, but the list is the
|
||||
// server's to change, so the `Select` rendering is pinned against one.
|
||||
await openSystem(["logging.level"]);
|
||||
|
||||
const logging = screen.getByRole("group", { name: "Logging" });
|
||||
// `logging.level` is a Select and `logging.output` is not on the list, so
|
||||
// exactly one mark belongs to this section.
|
||||
@@ -176,7 +211,7 @@ test("password flow: note shown, confirm required, PUT sends web.password, no re
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("a mixed patch makes the server owe a restart, and the shell says so", async () => {
|
||||
test("a patch of live keys alone leaves the server owing nothing", async () => {
|
||||
await openSystem();
|
||||
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
@@ -187,7 +222,8 @@ test("a mixed patch makes the server owe a restart, and the shell says so", asyn
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
|
||||
await screen.findByText(/Saved changes are not running yet/);
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import QueryPanel from "./QueryPanel";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const INTRO = "The pool builds its clients at startup, so an edit here takes effect at the next restart.";
|
||||
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
|
||||
|
||||
const styles = stylex.create({
|
||||
url: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { sample_get_settings } from "@/lib/contractSamples.gen";
|
||||
import type { ConfigStatus, Settings } from "@/lib/types";
|
||||
|
||||
export const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
@@ -33,6 +34,13 @@ export const MANAGED_FILE: ConfigStatus = {
|
||||
restart_pending: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The keys `/api/settings` still reports as restart-required, taken from the
|
||||
* committed contract sample so a server-side change to the set fails the tests
|
||||
* that pin it rather than passing against a stale copy.
|
||||
*/
|
||||
export const RESTART_REQUIRED_KEYS: readonly string[] = sample_get_settings.restart_required;
|
||||
|
||||
export function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
@@ -197,7 +205,7 @@ function defaultResponses(status: ConfigStatus): Record<string, unknown> {
|
||||
"GET /api/upstreams": { upstreams: UPSTREAMS },
|
||||
"GET /api/local-records": { local_records: LOCAL_RECORDS },
|
||||
"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
|
||||
"GET /api/settings": { settings: baseSettings(), restart_required: ["dns.port", "web.port"] },
|
||||
"GET /api/settings": { settings: baseSettings(), restart_required: RESTART_REQUIRED_KEYS },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { fireEvent, render as renderBare, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsClients } from "@/lib/types";
|
||||
import ClientChart from "./ClientChart";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
const BUCKET = 1800;
|
||||
|
||||
function clients(named: { client: string; buckets: number[] }[], other: number[]): StatsClients {
|
||||
return {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: SINCE + other.length * BUCKET,
|
||||
bucket_seconds: BUCKET,
|
||||
coverage: { complete: true, available_since: SINCE },
|
||||
clients: named,
|
||||
other,
|
||||
};
|
||||
}
|
||||
|
||||
const TWO_BUCKETS = clients(
|
||||
[
|
||||
{ client: "192.0.2.30", buckets: [10, 1] },
|
||||
{ client: "192.0.2.31", buckets: [20, 1] },
|
||||
],
|
||||
[5, 1],
|
||||
);
|
||||
|
||||
/**
|
||||
* The chart looks a client's registered name up, so it needs a query client. No
|
||||
* client is registered in these fixtures, which is what leaves the addresses on
|
||||
* screen as the labels.
|
||||
*/
|
||||
function render(data: StatsClients) {
|
||||
const client = createQueryClient();
|
||||
const tree = (next: StatsClients) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ClientChart data={next} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = renderBare(tree(data));
|
||||
return { ...result, rerender: (next: StatsClients) => result.rerender(tree(next)) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ clients: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function overlayRects(container: HTMLElement): SVGRectElement[] {
|
||||
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
|
||||
}
|
||||
|
||||
test("the data table is the SVG's accessible equivalent", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
|
||||
const table = screen.getByRole("table", { name: "Queries per client per time bucket" });
|
||||
expect(within(table).getAllByRole("row").length).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* The x-axis is this response's own `since` plus one bucket width per column.
|
||||
* The timeseries endpoint aligns its buckets with these, which is what lets the
|
||||
* two charts stack above one another without either knowing about the other.
|
||||
*/
|
||||
test("the columns are timestamped from this response's own window", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
|
||||
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Every series here is a disjoint part of the whole, so the scale has to come
|
||||
* from the tallest column's own sum. Taking it from the largest single value
|
||||
* instead would run the tallest column off the top of the plot: the axis has to
|
||||
* reach 35 here, not 20.
|
||||
*/
|
||||
test("the value scale covers the tallest column's total, not its largest series", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [20] }], [15]));
|
||||
|
||||
const labels = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) => label.textContent);
|
||||
expect(labels[labels.length - 1]).toBe("35");
|
||||
});
|
||||
|
||||
test("the series are drawn in the colour of the client's address, and Other in its own", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
|
||||
});
|
||||
|
||||
test("a window with no queries says so instead of drawing an empty grid", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [0, 0] }], [0, 0]));
|
||||
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("no bucket at all says the same thing", () => {
|
||||
render(clients([], []));
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("hover text is the tooltip alone, never a bare SVG title", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
expect(container.querySelectorAll("title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("each bucket's hit target spans the full plot height", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
const rects = overlayRects(container);
|
||||
expect(rects).toHaveLength(2);
|
||||
for (const rect of rects) {
|
||||
expect(rect.getAttribute("y")).toBe("8");
|
||||
expect(rect.getAttribute("height")).toBe("210");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A band scale spends a gap after the last column as well as between them, so a
|
||||
* full-step hit target on the last bucket would reach into the right margin.
|
||||
*/
|
||||
test("the last hit target stops at the plot's right edge", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
const last = overlayRects(container).at(-1) as SVGRectElement;
|
||||
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
|
||||
// 640 fallback width, less the 44px left and 8px right margins.
|
||||
expect(right).toBeCloseTo(44 + 588, 6);
|
||||
});
|
||||
|
||||
/**
|
||||
* The window refreshes every half minute under an open tooltip. The tooltip
|
||||
* holds a bucket index and reads the numbers out of the render it is drawing, so
|
||||
* a refresh that keeps the same buckets updates it rather than leaving last
|
||||
* minute's counts on screen.
|
||||
*/
|
||||
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
|
||||
const { container, rerender } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["35", "10", "20", "5"]);
|
||||
|
||||
rerender(
|
||||
clients(
|
||||
[
|
||||
{ client: "192.0.2.30", buckets: [11, 1] },
|
||||
{ client: "192.0.2.31", buckets: [22, 1] },
|
||||
],
|
||||
[6, 1],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["39", "11", "22", "6"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
|
||||
* was over is gone, so index 0 now names a different span.
|
||||
*/
|
||||
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
|
||||
const { container, rerender } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
|
||||
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
|
||||
});
|
||||
|
||||
/**
|
||||
* `withBoundingRects` measures its node once, on mount, so the buckets must not
|
||||
* share one. jsdom reports every rect as zero, so the mount is what this can
|
||||
* observe, not the measurement itself.
|
||||
*/
|
||||
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
const rects = overlayRects(container);
|
||||
|
||||
fireEvent.mouseOver(rects[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
fireEvent.mouseOver(rects[1]);
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
|
||||
"Queries",
|
||||
"192.0.2.30",
|
||||
"192.0.2.31",
|
||||
"Other",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
|
||||
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* "Other" is everything outside the top eight clients. In a window where it
|
||||
* counted nothing there is no eighth client to aggregate, and the entry would
|
||||
* appear in the legend, the stack, the tooltip and the table saying only that it
|
||||
* is empty. The named clients stay at zero: a client that went quiet is a fact.
|
||||
*/
|
||||
test("a window where Other counted nothing drops it from every surface", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
|
||||
});
|
||||
|
||||
test("one query outside the named clients is enough to keep Other", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
]);
|
||||
});
|
||||
@@ -2,56 +2,42 @@
|
||||
* Client activity over the same window as the query-volume chart: one stacked
|
||||
* series per named client, plus everything outside the top eight as "Other".
|
||||
*
|
||||
* The x-axis is the timeseries endpoint's own bucket alignment, so the two
|
||||
* charts stack directly above one another and a spike in one is at the same
|
||||
* horizontal position in the other. Colour keys on the client string, so a
|
||||
* client that changes rank between polls keeps its colour.
|
||||
* The x-axis is derived from this response's own `since` and `bucket_seconds`,
|
||||
* which the API aligns with the timeseries endpoint's buckets, so the two charts
|
||||
* stack directly above one another and a spike in one is at the same horizontal
|
||||
* position in the other. Colour keys on the client string, so a client that
|
||||
* changes rank between polls keeps its colour.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsClients } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutStacked } from "./chartLayout";
|
||||
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
position: "relative",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
@@ -80,31 +66,6 @@ const styles = stylex.create({
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -115,10 +76,11 @@ interface Series {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a
|
||||
* client, and always present: the response always carries the series, and a
|
||||
* legend that dropped it on a quiet period would make the reader think the
|
||||
* chart's clients were all of them.
|
||||
* "Other" last, so it sits at the top of every column rather than under a client,
|
||||
* and dropped entirely when it counted nothing across the window: an aggregation
|
||||
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
|
||||
* and a table column all saying zero. The named clients stay at zero, because a
|
||||
* client that went quiet is something the reader wants to see.
|
||||
*/
|
||||
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client) => ({
|
||||
@@ -131,119 +93,119 @@ function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
}
|
||||
|
||||
/** One column: the timestamp plus one entry per series, keyed by the series key. */
|
||||
type Column = { ts: number } & Record<string, number>;
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const [containerRef, width] = useMeasuredWidth();
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.other.length}:${width}`);
|
||||
const names = useClientNames();
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
|
||||
const series = seriesOf(data, names);
|
||||
const bucketCount = data.other.length;
|
||||
const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => {
|
||||
const column: Column = { ts: data.since + i * data.bucket_seconds };
|
||||
for (const one of series) column[one.key] = one.buckets[i] ?? 0;
|
||||
return column;
|
||||
});
|
||||
|
||||
if (bucketCount === 0) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
}
|
||||
|
||||
const columns = Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: data.since + i * data.bucket_seconds,
|
||||
values: series.map((one) => one.buckets[i] ?? 0),
|
||||
}));
|
||||
if (columns.every((column) => column.values.every((value) => value === 0))) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// Every series here is a disjoint part of the whole rather than a highlighted
|
||||
// subset of a separately reported total, so the tallest column's own sum is
|
||||
// the scale.
|
||||
const yScale = valueScale(Math.max(...totals), [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
const colorOf = new Map(series.map((one) => [one.key, one.color]));
|
||||
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
return {
|
||||
title: formatTime(columns[index].ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(totals[index]) },
|
||||
...series.map((one) => ({
|
||||
key: one.key,
|
||||
label: one.label,
|
||||
color: one.color,
|
||||
value: String(columns[index][one.key]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
<BarStack<Column, string>
|
||||
data={columns}
|
||||
keys={series.map((one) => one.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => colorOf.get(key) ?? seriesColor(key)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.columns.map((column) => (
|
||||
<g key={column.ts}>
|
||||
{column.segments.map((rect, index) =>
|
||||
rect.height <= 0 ? null : (
|
||||
<rect
|
||||
key={series[index].key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series[index].color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<rect
|
||||
x={column.slot.x}
|
||||
y={column.slot.y}
|
||||
width={column.slot.width}
|
||||
height={column.slot.height}
|
||||
fill="transparent"
|
||||
>
|
||||
<title>
|
||||
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
|
||||
</title>
|
||||
</rect>
|
||||
</g>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
||||
@@ -269,14 +231,14 @@ export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
{column.values.map((value, index) => (
|
||||
<td key={series[index].key}>{value}</td>
|
||||
{series.map((one) => (
|
||||
<td key={one.key}>{column[one.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ChartRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import Donut, { type DonutSlice } from "./Donut";
|
||||
|
||||
function slice(key: string, value: number, color = "#000000"): DonutSlice {
|
||||
return { key, label: key.toUpperCase(), value, color };
|
||||
}
|
||||
|
||||
function ring(container: HTMLElement): SVGPathElement[] {
|
||||
return Array.from(container.querySelectorAll("svg path"));
|
||||
}
|
||||
|
||||
/** The x radius of every `A` command in a path, in the order they are drawn. */
|
||||
function arcRadii(d: string): number[] {
|
||||
return Array.from(d.matchAll(/A(-?[\d.]+),/g)).map((match) => Number(match[1]));
|
||||
}
|
||||
|
||||
function draw(slices: DonutSlice[]) {
|
||||
return render(<Donut slices={slices} caption="Queries by DNS type" unit="Queries" />);
|
||||
}
|
||||
|
||||
test("a breakdown of nothing says so rather than dividing by zero", () => {
|
||||
const { container } = draw([slice("a", 0), slice("b", 0)]);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty breakdown says the same thing", () => {
|
||||
draw([]);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
/** A legend entry reading 0% is noise, and a zero slice has no arc to draw. */
|
||||
test("zero-valued entries are dropped from the ring, the legend and the table", () => {
|
||||
const { container } = draw([slice("a", 3), slice("z", 0), slice("b", 1)]);
|
||||
|
||||
expect(ring(container)).toHaveLength(2);
|
||||
expect(screen.queryByText("Z")).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("rowheader")
|
||||
.map((row) => row.textContent),
|
||||
).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The caller has already ranked the slices, so the ring must not re-sort them:
|
||||
* the first arc opens at twelve o'clock and each one starts where the last
|
||||
* ended, running clockwise.
|
||||
*
|
||||
* The fixture runs 1 then 3 on purpose. d3's default pie sort is by value
|
||||
* descending, so a fixture that happened to be given in descending order would
|
||||
* pass with the sort left on and prove nothing.
|
||||
*/
|
||||
test("the ring starts at twelve o'clock and runs clockwise in the order given", () => {
|
||||
const { container } = draw([slice("a", 1, "#112233"), slice("b", 3, "#445566")]);
|
||||
|
||||
const paths = ring(container);
|
||||
// The small slice is drawn first because it was given first.
|
||||
expect(paths[0].getAttribute("fill")).toBe("#112233");
|
||||
expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
|
||||
// A quarter turn clockwise from the top is three o'clock, where the second
|
||||
// slice picks up.
|
||||
expect(paths[1].getAttribute("fill")).toBe("#445566");
|
||||
expect(paths[1].getAttribute("d")?.startsWith("M90,0")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* A whole-circle arc from a point back to itself draws nothing at all, so the
|
||||
* one-entry case has to come out as a closed ring: two half arcs out and two
|
||||
* back.
|
||||
*/
|
||||
test("a single entry is a closed ring, not a zero-length arc", () => {
|
||||
const { container } = draw([slice("only", 7)]);
|
||||
|
||||
const paths = ring(container);
|
||||
expect(paths).toHaveLength(1);
|
||||
const d = paths[0].getAttribute("d") ?? "";
|
||||
expect(d.match(/A/g)).toHaveLength(4);
|
||||
// Two half arcs out at the outer radius and two back at the inner one: a
|
||||
// 180px ring 36px thick.
|
||||
expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
|
||||
});
|
||||
|
||||
test("shares are of the drawn total, in the legend and in the hidden table alike", () => {
|
||||
draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
expect(screen.getAllByText("75.0%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("25.0%")).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour is the caller's, never recomputed from the key here: the page owns
|
||||
* the identity-to-colour mapping and two donuts of one page must agree with it.
|
||||
*/
|
||||
test("each arc is filled with the colour its slice carries", () => {
|
||||
const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);
|
||||
|
||||
expect(ring(container).map((path) => path.getAttribute("fill"))).toEqual(["#112233", "#445566"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Colour is a pure function of identity and so cannot rule out two slices of one
|
||||
* panel sharing a hue. The stroke is what stops neighbours from merging into one
|
||||
* shape.
|
||||
*/
|
||||
test("every arc is outlined in the panel's own surface colour", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
for (const path of ring(container)) {
|
||||
expect(path.getAttribute("stroke-width")).toBe("1");
|
||||
expect(path.getAttribute("stroke")).toMatch(/^var\(--/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the ring is decoration; the legend and the hidden table are the accessible surface", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(svg?.getAttribute("focusable")).toBe("false");
|
||||
expect(screen.getByRole("table", { name: "Queries by DNS type" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The ring gets the bar charts' hover treatment: the shared tooltip names the
|
||||
* slice and repeats what the legend says about it, and the slices it is not
|
||||
* naming dim out of the way. The ring stays `aria-hidden` — the tooltip is a
|
||||
* pointer affordance, and the legend beside it is still the readable copy.
|
||||
*/
|
||||
test("pointing at a slice names it and dims the rest", () => {
|
||||
const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);
|
||||
const paths = ring(container);
|
||||
|
||||
fireEvent.mouseOver(paths[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe("A");
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
// The same share the legend and the hidden table already print for this slice.
|
||||
expect(screen.getAllByText("75.0%")).toHaveLength(3);
|
||||
|
||||
expect(paths.map((path) => path.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
// The tooltip points at the middle of the arc, which the component computes
|
||||
// from the slice values rather than from the drawn path. Slice A is three
|
||||
// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
|
||||
// radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
|
||||
// the tooltip stands off by. Nothing else here would catch that arithmetic
|
||||
// drifting away from the ring the Pie actually draws.
|
||||
const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltipBox.style.transform).toBe("translate(149px, 149px)");
|
||||
});
|
||||
|
||||
test("leaving the ring takes the tooltip and the dimming with it", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
expect(ring(container).map((path) => path.getAttribute("opacity"))).toEqual(["1", "1"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same slice can change width between refreshes, so a mount measured at the
|
||||
* old width would be placed at the wrong one. This is the donut's half of the
|
||||
* remount the bar charts do.
|
||||
*/
|
||||
test("a refresh keeps the hovered slice current and remeasures it", () => {
|
||||
const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
|
||||
rerender(<Donut slices={[slice("a", 3000), slice("b", 1000)]} caption="Queries by DNS type" unit="Queries" />);
|
||||
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
expect(Array.from(second?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual([
|
||||
"3,000",
|
||||
"75.0%",
|
||||
]);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
/** A slice that is gone cannot be described, so the hover goes with it. */
|
||||
test("a refresh that changes the slices takes the tooltip down", () => {
|
||||
const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender(<Donut slices={[slice("c", 3), slice("b", 1)]} caption="Queries by DNS type" unit="Queries" />);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
});
|
||||
@@ -13,12 +13,26 @@
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { Pie } from "@visx/shape";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
|
||||
|
||||
export interface DonutSlice {
|
||||
/** Semantic identity: the React key, the colour key and the legend's identity. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
|
||||
secondary?: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const SIZE = 180;
|
||||
const THICKNESS = 36;
|
||||
const OUTER_RADIUS = SIZE / 2;
|
||||
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
@@ -54,8 +68,12 @@ const styles = stylex.create({
|
||||
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
|
||||
gap: "1.25rem",
|
||||
},
|
||||
/** The tooltip is placed against the ring's own box, so slice coordinates can
|
||||
* be used unchanged rather than measured against the whole panel. */
|
||||
ring: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
},
|
||||
/**
|
||||
* Capped and left-anchored. Without the cap the row justifies across whatever
|
||||
@@ -115,6 +133,21 @@ function sharePercent(share: number): string {
|
||||
return `${(share * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a slice's tooltip points: the middle of its arc, in the ring box's own
|
||||
* coordinates. This restates the `Pie` configuration below — clockwise from
|
||||
* twelve o'clock, caller order, no pad angle — and the two must agree.
|
||||
*/
|
||||
function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left: number; top: number } {
|
||||
const before = drawn.slice(0, index).reduce((sum, slice) => sum + slice.value, 0);
|
||||
const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
|
||||
const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
|
||||
return {
|
||||
left: OUTER_RADIUS + Math.sin(middle) * radius,
|
||||
top: OUTER_RADIUS - Math.cos(middle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Donut({
|
||||
slices,
|
||||
caption,
|
||||
@@ -126,56 +159,108 @@ export default function Donut({
|
||||
/** The column header for the counted thing, e.g. "Queries". */
|
||||
unit: string;
|
||||
}) {
|
||||
const layout = layoutDonut(slices, SIZE, THICKNESS);
|
||||
// Zero-valued entries have no arc to draw and a legend entry reading 0 is
|
||||
// noise, so they are dropped before anything is measured or drawn.
|
||||
const drawn = slices.filter((slice) => slice.value > 0);
|
||||
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
||||
// A hover names a slice by position, so it survives only while the ring is
|
||||
// still made of the same slices in the same order.
|
||||
const hovered = useActiveIndex(drawn.map((slice) => slice.key).join(","));
|
||||
|
||||
if (layout.total === 0) {
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
const slice = drawn[index];
|
||||
return {
|
||||
title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
|
||||
rows: [
|
||||
{ key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
|
||||
{ key: "share", label: "Share", value: sharePercent(slice.value / total) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.ring)}>
|
||||
{/* The ring stays out of the accessibility tree even though it is now
|
||||
a pointer target: the tooltip repeats what the legend beside it
|
||||
already says in text, so nothing here is the only copy. */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
{...stylex.props(styles.ring)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.arcs.map((arc) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a pure
|
||||
// function of identity, so two neighbouring slices can come out the
|
||||
// same; outlined in the panel's own colour they still read as two
|
||||
// shapes rather than merging into one. Attributes rather than a
|
||||
// class, as the client chart's segments are, so the separation is
|
||||
// visible to a test and not only to a stylesheet.
|
||||
{/* Arc paths are generated around the origin, and `Pie`'s own
|
||||
`top`/`left` group is skipped when it is given a render prop, so
|
||||
the ring is centred here instead. */}
|
||||
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
|
||||
<Pie
|
||||
data={drawn}
|
||||
pieValue={(slice) => slice.value}
|
||||
// The caller has already ranked the slices, so d3's own sort is off
|
||||
// in both of its forms and the ring runs clockwise from twelve
|
||||
// o'clock in the order given. @visx/shape 4.0.0 already disables
|
||||
// it by default — but that default exists because d3-shape v3
|
||||
// changed sortValues to descending under it, so saying it here is
|
||||
// what keeps a future bump from silently re-ranking the ring.
|
||||
pieSort={null}
|
||||
pieSortValues={null}
|
||||
outerRadius={OUTER_RADIUS}
|
||||
innerRadius={INNER_RADIUS}
|
||||
>
|
||||
{({ arcs, path }) =>
|
||||
arcs.map((arc, index) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a
|
||||
// pure function of identity, so two neighbouring slices can come
|
||||
// out the same; outlined in the panel's own colour they still
|
||||
// read as two shapes rather than merging into one. Attributes
|
||||
// rather than a class, as the client chart's segments are, so
|
||||
// the separation is visible to a test and not only to a
|
||||
// stylesheet.
|
||||
<path
|
||||
key={arc.slice.key}
|
||||
d={arc.d}
|
||||
fill={arc.slice.color}
|
||||
fillRule="evenodd"
|
||||
key={arc.data.key}
|
||||
d={path(arc) ?? ""}
|
||||
fill={arc.data.color}
|
||||
stroke={colors.surfaceRaised}
|
||||
strokeWidth={1}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
onMouseEnter={() => hovered.show(index)}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</Pie>
|
||||
</Group>
|
||||
</svg>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{layout.arcs.map((arc) => (
|
||||
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
{...sliceAnchor(drawn, hovered.index, total)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{drawn.map((slice) => (
|
||||
<li key={slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(slice.color))} />
|
||||
<span {...stylex.props(styles.label)}>
|
||||
{arc.slice.label}
|
||||
{arc.slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
|
||||
{slice.label}
|
||||
{slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(arc.slice.value)}
|
||||
{numberFormat.format(slice.value)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>
|
||||
{sharePercent(slice.value / total)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -190,15 +275,15 @@ export default function Donut({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.arcs.map((arc) => (
|
||||
<tr key={arc.slice.key}>
|
||||
{drawn.map((slice) => (
|
||||
<tr key={slice.key}>
|
||||
<th scope="row">
|
||||
{arc.slice.secondary === undefined
|
||||
? arc.slice.label
|
||||
: `${arc.slice.label} (${arc.slice.secondary})`}
|
||||
{slice.secondary === undefined
|
||||
? slice.label
|
||||
: `${slice.label} (${slice.secondary})`}
|
||||
</th>
|
||||
<td>{arc.slice.value}</td>
|
||||
<td>{sharePercent(arc.share)}</td>
|
||||
<td>{slice.value}</td>
|
||||
<td>{sharePercent(slice.value / total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { clientKey, seriesColor } from "./seriesColors";
|
||||
import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } from "@/lib/types";
|
||||
|
||||
@@ -203,6 +203,18 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
|
||||
}
|
||||
});
|
||||
|
||||
test("the page builds a donut slice's colour from the entry's identity", async () => {
|
||||
// `Donut` renders the colour it is handed and never recomputes one, so the
|
||||
// mapping from identity to hue is the page's job and is pinned here.
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
|
||||
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
|
||||
});
|
||||
|
||||
test("a slow endpoint does not hold the page back: the panels that answered render beside it", async () => {
|
||||
// Through the real route, which is the point: the loader starts the five
|
||||
// requests and awaits none of them. If it awaited, the router would hold the
|
||||
@@ -259,9 +271,10 @@ test("naming a client does not recolour its series", async () => {
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
|
||||
});
|
||||
|
||||
test("the client chart names Other even in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Dropping it from the legend there
|
||||
// would tell the reader the two named clients were every client.
|
||||
test("the client chart drops Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. An aggregation bucket that
|
||||
// aggregated nothing is a legend entry and a table column that say only that
|
||||
// they are empty; the named clients stay, because a quiet client is a fact.
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Client activity over time" });
|
||||
|
||||
@@ -269,8 +282,8 @@ test("the client chart names Other even in a period where it counted nothing", a
|
||||
expect(chart).toBeTruthy();
|
||||
// Twice each: the legend swatch and the column header of the table a screen
|
||||
// reader gets instead of the graphic.
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2);
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import ClientChart from "./ClientChart";
|
||||
import Donut from "./Donut";
|
||||
import StatTiles from "./StatTiles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
import type { DonutSlice } from "./donutLayout";
|
||||
import type { DonutSlice } from "./Donut";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Bucket, StatsTimeseries } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
|
||||
function timeseries(bucketCount: number): StatsTimeseries {
|
||||
function timeseries(buckets: Bucket[]): StatsTimeseries {
|
||||
return {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: SINCE + bucketCount * 1800,
|
||||
until: SINCE + buckets.length * 1800,
|
||||
bucket_seconds: 1800,
|
||||
coverage: { complete: true, available_since: SINCE },
|
||||
buckets: Array.from({ length: bucketCount }, (_, i) => ({
|
||||
buckets,
|
||||
};
|
||||
}
|
||||
|
||||
function counting(bucketCount: number): StatsTimeseries {
|
||||
return timeseries(
|
||||
Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: SINCE + i * 1800,
|
||||
queries: i + 1,
|
||||
blocked: 1,
|
||||
cached: 1,
|
||||
})),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
/** The element wearing the shared hidden style, found by its compiled classes. */
|
||||
@@ -29,8 +36,13 @@ function hiddenElement(container: HTMLElement): Element | null {
|
||||
return container.querySelector(classes.map((name) => `.${name}`).join(""));
|
||||
}
|
||||
|
||||
/** The transparent per-bucket hit targets, in bucket order. */
|
||||
function overlayRects(container: HTMLElement): SVGRectElement[] {
|
||||
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
|
||||
}
|
||||
|
||||
test("the data table is the SVG's accessible equivalent", () => {
|
||||
render(<TimeseriesChart data={timeseries(3)} />);
|
||||
render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
const table = screen.getByRole("table", { name: "Queries per time bucket" });
|
||||
@@ -44,9 +56,339 @@ test("the data table is the SVG's accessible equivalent", () => {
|
||||
* push the document's scroll height a screen past the app shell.
|
||||
*/
|
||||
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
|
||||
const { container } = render(<TimeseriesChart data={timeseries(48)} />);
|
||||
const { container } = render(<TimeseriesChart data={counting(48)} />);
|
||||
|
||||
const hidden = hiddenElement(container);
|
||||
expect(hidden?.tagName).toBe("DIV");
|
||||
expect(hidden?.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* "Allowed" is what the reported total leaves over, and the three counts come
|
||||
* from separate columns that a partial write can leave inconsistent. A negative
|
||||
* remainder would draw a segment upside down.
|
||||
*/
|
||||
test("allowed is the remainder of the reported total, clamped at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
|
||||
);
|
||||
|
||||
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
|
||||
expect(
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["10", "8", "5", "0"]);
|
||||
// Blocked and cached are drawn; the empty "allowed" segment is not.
|
||||
expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
|
||||
|
||||
// The scale comes from the reported total, not from the stack's own sum.
|
||||
// Scaling to the sum would reach 13 here and leave the bar four fifths of the
|
||||
// way up a plot whose own numbers say it is full.
|
||||
const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
|
||||
expect(ticks[ticks.length - 1]).toBe("10");
|
||||
});
|
||||
|
||||
test("a window with no queries says so instead of drawing an empty grid", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 0, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("no bucket at all says the same thing", () => {
|
||||
render(<TimeseriesChart data={timeseries([])} />);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The three category colours are fixed constants of this chart rather than
|
||||
* anything derived from `seriesColors`, which would paint "other" grey.
|
||||
*/
|
||||
test("the segments are drawn in this chart's own category colours", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
|
||||
);
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The graphic carries no `<title>`: it duplicated the tooltip, the browser
|
||||
* showed it after its own delay and out of the app's styling, and the hidden
|
||||
* table is already the accessible equivalent.
|
||||
*/
|
||||
test("hover text is the tooltip alone, never a bare SVG title", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
expect(container.querySelectorAll("title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
/** The hit target is the whole column slot, including the space above a short stack. */
|
||||
test("each bucket's hit target spans the full plot height", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const rects = overlayRects(container);
|
||||
expect(rects).toHaveLength(3);
|
||||
for (const rect of rects) {
|
||||
expect(rect.getAttribute("y")).toBe("8");
|
||||
expect(rect.getAttribute("height")).toBe("210");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A band scale spends a gap after the last column as well as between them, so a
|
||||
* full-step hit target on the last bucket would reach into the right margin and
|
||||
* catch pointers that are past the plot entirely.
|
||||
*/
|
||||
test("the last hit target stops at the plot's right edge", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const last = overlayRects(container).at(-1) as SVGRectElement;
|
||||
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
|
||||
// 640 fallback width, less the 44px left and 8px right margins.
|
||||
expect(right).toBeCloseTo(44 + 588, 6);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
|
||||
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
|
||||
expect(values).toEqual(["100", "40", "10", "50"]);
|
||||
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
|
||||
expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
// One swatch per series, in the colour the segment is drawn in.
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches[0]).toContain("#ef4444");
|
||||
expect(swatches[1]).toContain("#059669");
|
||||
expect(swatches[2]).toContain("#3b82f6");
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The tooltip sits 8px down from the chart's top edge and 8px to the side of the
|
||||
* slot it names — the placement the hand-rolled tooltip had, restored over
|
||||
* visx's own 10px defaults.
|
||||
*/
|
||||
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(2)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
|
||||
// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
|
||||
});
|
||||
|
||||
/**
|
||||
* `withBoundingRects` measures its node once, on mount. Sharing one mount across
|
||||
* buckets would place a wide bucket's tooltip with a narrow bucket's measured
|
||||
* width, which flips or clips it at the right-hand edge of the plot. Remounting
|
||||
* per bucket is what forces a fresh measurement; jsdom reports every rect as
|
||||
* zero, so the mount is what this can observe, not the measurement itself.
|
||||
*/
|
||||
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(2)} />);
|
||||
const rects = overlayRects(container);
|
||||
|
||||
fireEvent.mouseOver(rects[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
fireEvent.mouseOver(rects[1]);
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
/**
|
||||
* The window refreshes every half minute under an open tooltip. The tooltip
|
||||
* holds a bucket index and reads the numbers out of the render it is drawing, so
|
||||
* a refresh that keeps the same buckets updates it rather than leaving last
|
||||
* minute's counts on screen.
|
||||
*/
|
||||
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
|
||||
const before = timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
]);
|
||||
const { container, rerender } = render(<TimeseriesChart data={before} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["100", "40", "10", "50"]);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 120, blocked: 60, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
|
||||
(dd) => dd.textContent,
|
||||
);
|
||||
expect(values).toEqual(["120", "60", "10", "50"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
|
||||
* was over is gone, so index 0 now names a different span. The tooltip goes away
|
||||
* rather than describing a bucket that is no longer drawn, and nothing stays
|
||||
* dimmed behind it. Rolling back to the earlier window must not bring it back
|
||||
* either: the selection is deleted when the window moves, not held aside.
|
||||
*/
|
||||
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
|
||||
const { container, rerender } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={{
|
||||
...timeseries([
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
{ ts: SINCE + 3600, queries: 70, blocked: 7, cached: 7 },
|
||||
]),
|
||||
since: SINCE + 1800,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same bucket can change width between refreshes — a count crossing a digit
|
||||
* boundary, a client name arriving — and a mount measured at the old width is
|
||||
* placed at the wrong one. A content change therefore remounts the tooltip, the
|
||||
* same way moving between buckets does.
|
||||
*/
|
||||
test("a bucket whose numbers change is remounted, so it is measured again", () => {
|
||||
const { container, rerender } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 9, blocked: 4, cached: 1 }])} />,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
|
||||
rerender(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 1000, blocked: 4, cached: 1 }])} />);
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The third series is every query neither blocked nor served from cache. It was
|
||||
* called "Other", which named the arithmetic rather than the thing.
|
||||
*/
|
||||
test("the remainder series is called Allowed everywhere it surfaces", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
|
||||
);
|
||||
|
||||
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
expect(screen.queryByText("Other")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* A window where everything was blocked or served from cache has no allowed
|
||||
* queries, and that is worth reading rather than hiding: an absent series would
|
||||
* say the same thing as a series nobody looked at. The three categories are all
|
||||
* real answers a query can get, so none of them is dropped for counting zero.
|
||||
* The client chart's "Other" is dropped at zero, but that one aggregates clients
|
||||
* beyond the top eight rather than naming a kind of answer.
|
||||
*/
|
||||
test("a window with nothing allowed keeps the series at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
|
||||
"Queries",
|
||||
"Blocked",
|
||||
"Cached",
|
||||
"Allowed",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
|
||||
});
|
||||
|
||||
@@ -1,107 +1,49 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import type { Bucket, StatsTimeseries } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||
import {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
|
||||
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
||||
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
|
||||
// The third key stays `other` — it is the colour key and the response field, and
|
||||
// renaming it would repaint the series. Only what the reader sees is "Allowed".
|
||||
const SERIES = [
|
||||
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
||||
{ key: "cached", label: "Cached", color: "#059669" },
|
||||
{ key: "other", label: "Other", color: "#3b82f6" },
|
||||
{ key: "other", label: "Allowed", color: "#3b82f6" },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
type Series = (typeof SERIES)[number];
|
||||
type SeriesKey = Series["key"];
|
||||
|
||||
const SERIES_COLOR: Record<SeriesKey, string> = {
|
||||
blocked: SERIES[0].color,
|
||||
cached: SERIES[1].color,
|
||||
other: SERIES[2].color,
|
||||
};
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
chartRoot: {
|
||||
position: "relative",
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: "0.5rem",
|
||||
zIndex: 10,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
|
||||
tooltipLeft: (left: number) => ({ left, right: null }),
|
||||
tooltipRight: (right: number) => ({ left: null, right }),
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
tooltipList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
tooltipRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
},
|
||||
tooltipTerm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
swatchSmall: {
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
},
|
||||
swatchLarge: {
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
@@ -117,177 +59,131 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
interface Column {
|
||||
ts: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
function columnsOf(buckets: Bucket[]): Column[] {
|
||||
return buckets.map((bucket) => ({
|
||||
ts: bucket.ts,
|
||||
queries: bucket.queries,
|
||||
blocked: bucket.blocked,
|
||||
cached: bucket.cached,
|
||||
other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
|
||||
}));
|
||||
}
|
||||
|
||||
function barSummary(bar: BarLayout): string {
|
||||
return `${formatTime(bar.bucket.ts)}: ${bar.bucket.queries} queries, ${bar.bucket.blocked} blocked, ${bar.bucket.cached} cached`;
|
||||
}
|
||||
|
||||
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||
const leftHalf = centerX < chartWidth / 2;
|
||||
const side = leftHalf
|
||||
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
|
||||
: styles.tooltipRight(chartWidth - centerX + 8);
|
||||
return (
|
||||
<div {...stylex.props(styles.tooltip, side)}>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
<div {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
|
||||
</div>
|
||||
{SERIES.map((series) => (
|
||||
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
|
||||
/>
|
||||
{series.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>
|
||||
{series.key === "other" ? bar.other : bar.bucket[series.key]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
function tooltipOf(column: Column): TooltipContent {
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(column.queries) },
|
||||
...SERIES.map((series) => ({
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
value: String(column[series.key]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
const [containerRef, width] = useMeasuredWidth();
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
|
||||
|
||||
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
}
|
||||
|
||||
const layout = layoutTimeseries(data.buckets, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||
const columns = columnsOf(data.buckets);
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// The scale is the reported total rather than the stack's own sum: blocked
|
||||
// and cached are parts of `queries`, which a clamped `other` can undercount.
|
||||
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} queries per bucket`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
<BarStack<Column, SeriesKey>
|
||||
data={columns}
|
||||
keys={SERIES.map((series) => series.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => SERIES_COLOR[key]}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||
{SERIES.map((series) => {
|
||||
const rect = bar.segments[series.key];
|
||||
if (rect.height <= 0) return null;
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
return (
|
||||
<rect
|
||||
key={series.key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series.color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<rect
|
||||
key={bar.bucket.ts}
|
||||
x={bar.slot.x}
|
||||
y={bar.slot.y}
|
||||
width={bar.slot.width}
|
||||
height={bar.slot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
>
|
||||
<title>{barSummary(bar)}</title>
|
||||
</rect>
|
||||
))}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{SERIES.map((series) => (
|
||||
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
|
||||
/>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
@@ -299,24 +195,26 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Cached</th>
|
||||
<th scope="col">Other</th>
|
||||
{SERIES.map((series) => (
|
||||
<th key={series.key} scope="col">
|
||||
{series.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.bars.map((bar) => (
|
||||
<tr key={bar.bucket.ts}>
|
||||
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||
<td>{bar.bucket.queries}</td>
|
||||
<td>{bar.bucket.blocked}</td>
|
||||
<td>{bar.bucket.cached}</td>
|
||||
<td>{bar.other}</td>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<td>{column.queries}</td>
|
||||
{SERIES.map((series) => (
|
||||
<td key={series.key}>{column[series.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ChartRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
bandPaddingInner,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
} from "./chartKit";
|
||||
|
||||
describe("useMeasuredWidth", () => {
|
||||
/**
|
||||
* jsdom lays nothing out, so `clientWidth` is 0 there and a chart scaled to it
|
||||
* would draw a zero-width plot. Every chart test depends on this fallback.
|
||||
*/
|
||||
test("an unmeasurable container falls back to 640", () => {
|
||||
function Probe() {
|
||||
const [ref, width] = useMeasuredWidth();
|
||||
return <div ref={ref} data-testid="probe" data-width={width} />;
|
||||
}
|
||||
const { getByTestId } = render(<Probe />);
|
||||
expect(getByTestId("probe").getAttribute("data-width")).toBe("640");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueScale", () => {
|
||||
test("the data maximum is nicened outward and the ticks land on round numbers", () => {
|
||||
const scale = valueScale(1780, [240, 0]);
|
||||
expect(scale.domain()).toEqual([0, 1800]);
|
||||
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
|
||||
expect(valueTicks(scale)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandPaddingInner", () => {
|
||||
/**
|
||||
* The gap is a constant number of pixels, so the fraction it takes of one slot
|
||||
* has to fall as the slots get wider. 24 hourly columns in a 1388px plot is
|
||||
* the 24h window on a full-width panel.
|
||||
*/
|
||||
test("the fraction is two pixels per column of the plot's width", () => {
|
||||
expect(bandPaddingInner(24, 1388)).toBeCloseTo((2 * 24) / 1388, 12);
|
||||
expect(bandPaddingInner(24, 1388)).toBeCloseTo(0.034582, 6);
|
||||
});
|
||||
|
||||
/**
|
||||
* Past the cap the gap would be most of the slot and the columns would vanish
|
||||
* into slivers, so it stops at half the slot and the bars stay visible.
|
||||
*/
|
||||
test("the gap never takes more than half a slot", () => {
|
||||
expect(bandPaddingInner(1000, 100)).toBe(0.5);
|
||||
});
|
||||
|
||||
test("no columns and no width mean no gap to compute", () => {
|
||||
expect(bandPaddingInner(0, 640)).toBe(0);
|
||||
expect(bandPaddingInner(24, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("labelTickValues", () => {
|
||||
/** A week of hourly buckets in a panel the width of a laptop window. */
|
||||
test("labels thin out to one every 21 buckets at 748px of plot", () => {
|
||||
const timestamps = Array.from({ length: 168 }, (_, i) => i * 3600);
|
||||
expect(labelTickValues(timestamps, 748)).toEqual([0, 75600, 151200, 226800, 302400, 378000, 453600, 529200]);
|
||||
});
|
||||
|
||||
test("every bucket is labelled when they all fit", () => {
|
||||
expect(labelTickValues([0, 3600, 7200], 748)).toEqual([0, 3600, 7200]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The frame on its own, at the width the charts draw at in jsdom. */
|
||||
function renderFrame() {
|
||||
const plot = plotArea(640);
|
||||
const timestamps = [0, 3600, 7200];
|
||||
const yScale = valueScale(100, [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
const { container } = render(
|
||||
<svg width={640} height={CHART_HEIGHT}>
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={bandScale(timestamps, plot)}
|
||||
xTickValues={timestamps}
|
||||
bucketSeconds={3600}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
return { container, plot, yTicks };
|
||||
}
|
||||
|
||||
describe("ChartFrame", () => {
|
||||
test("the value axis is bare: no axis line, no tick marks, labels 6px left of the plot", () => {
|
||||
const { container, plot, yTicks } = renderFrame();
|
||||
|
||||
const axis = container.querySelector(".visx-axis-left") as SVGGElement;
|
||||
expect(axis.getAttribute("transform")).toBe(`translate(${plot.x}, 0)`);
|
||||
expect(axis.querySelector("line")).toBeNull();
|
||||
|
||||
const labels = Array.from(axis.querySelectorAll("text"));
|
||||
expect(labels.map((label) => label.textContent)).toEqual(yTicks.map(String));
|
||||
for (const label of labels) {
|
||||
expect(label.getAttribute("x")).toBe("0");
|
||||
expect(label.getAttribute("dx")).toBe("-6px");
|
||||
expect(label.getAttribute("text-anchor")).toBe("end");
|
||||
// The label sits on the tick, centred: visx's own 0.25em nudge is
|
||||
// cancelled so it does not double up with the middle baseline.
|
||||
expect(label.getAttribute("dy")).toBe("0");
|
||||
expect(label.getAttribute("dominant-baseline")).toBe("middle");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The time axis keeps its baseline and drops its tick marks, and the labels
|
||||
* are placed by the group transform plus `dy` rather than by the font-size
|
||||
* guess visx would otherwise make.
|
||||
*/
|
||||
test("the time axis keeps only its baseline, and its labels sit 16px below it", () => {
|
||||
const { container, plot } = renderFrame();
|
||||
|
||||
const axis = container.querySelector(".visx-axis-bottom") as SVGGElement;
|
||||
expect(axis.getAttribute("transform")).toBe(`translate(0, ${plot.bottom})`);
|
||||
|
||||
const lines = Array.from(axis.querySelectorAll("line"));
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].getAttribute("class")).toContain("visx-axis-line");
|
||||
|
||||
for (const label of Array.from(axis.querySelectorAll("text"))) {
|
||||
expect(label.getAttribute("y")).toBe("0");
|
||||
expect(label.getAttribute("dy")).toBe("16px");
|
||||
expect(label.getAttribute("text-anchor")).toBe("middle");
|
||||
}
|
||||
});
|
||||
|
||||
test("every grid line has a labelled tick on it and no tick floats without a line", () => {
|
||||
const { container } = renderFrame();
|
||||
|
||||
const gridY = Array.from(container.querySelectorAll(".visx-rows line")).map((line) => line.getAttribute("y1"));
|
||||
const labelY = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) =>
|
||||
label.getAttribute("y"),
|
||||
);
|
||||
expect(gridY.length).toBeGreaterThan(0);
|
||||
expect(gridY).toEqual(labelY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* The plumbing the two bar charts on Overview share: the width measurement, the
|
||||
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
|
||||
* target and the tooltip.
|
||||
*
|
||||
* Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
|
||||
* marks, an axis line on both axes and Arial 10px in #222, none of which this
|
||||
* app wants, so every wrapper here turns those off and dresses the result in
|
||||
* StyleX tokens instead.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
|
||||
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
/** How many pixels one x-axis label needs to itself before the labels thin out. */
|
||||
const MIN_X_LABEL_PX = 90;
|
||||
|
||||
/** The gap between two touching columns, in pixels of the drawn plot. */
|
||||
const BAR_GAP_PX = 2;
|
||||
|
||||
const Y_TICK_COUNT = 5;
|
||||
|
||||
export interface Plot {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** The y of the baseline: the bottom edge of the plot. */
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function plotArea(width: number): Plot {
|
||||
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
|
||||
return {
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The container's width, measured on mount and on every resize. Deliberately
|
||||
* `useEffect` rather than `useLayoutEffect`: the first paint draws at the
|
||||
* fallback width and the measured width lands a frame later, which is the
|
||||
* timing the charts have always had.
|
||||
*/
|
||||
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width > 0 ? width : FALLBACK_WIDTH];
|
||||
}
|
||||
|
||||
/**
|
||||
* The value axis. `nice: true` rounds the domain outward, so the `max` passed
|
||||
* here is the data's own maximum and not a pre-rounded one.
|
||||
*/
|
||||
export function valueScale(max: number, range: [number, number]) {
|
||||
return scaleLinear<number>({ domain: [0, max], range, nice: true });
|
||||
}
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
export function valueTicks(scale: ValueScale): number[] {
|
||||
return scale.ticks(Y_TICK_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide the gap between two columns is, as a fraction of one column's slot.
|
||||
* A constant would make the gap grow with the column: 30 daily columns and 60
|
||||
* minute columns are drawn at the same pixel gap, so the fraction has to be
|
||||
* computed from how many columns share the plot.
|
||||
*/
|
||||
export function bandPaddingInner(bucketCount: number, plotWidth: number): number {
|
||||
if (bucketCount === 0 || plotWidth <= 0) return 0;
|
||||
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
|
||||
}
|
||||
|
||||
/** The time axis: one band per bucket, in the order the buckets were given. */
|
||||
export function bandScale(values: number[], plot: Plot) {
|
||||
return scaleBand<number>({
|
||||
domain: values,
|
||||
range: [plot.x, plot.x + plot.width],
|
||||
paddingInner: bandPaddingInner(values.length, plot.width),
|
||||
});
|
||||
}
|
||||
|
||||
export type BandScale = ReturnType<typeof bandScale>;
|
||||
|
||||
/**
|
||||
* The subset of bucket timestamps that get an x-axis label. A 30-day window is
|
||||
* 30 columns and a 1-hour window is 60, so at narrow widths the labels have to
|
||||
* thin out rather than overprint each other.
|
||||
*/
|
||||
export function labelTickValues(values: number[], plotWidth: number): number[] {
|
||||
if (values.length === 0 || plotWidth <= 0) return values.slice(0, 1);
|
||||
const step = Math.max(1, Math.ceil((values.length * MIN_X_LABEL_PX) / plotWidth));
|
||||
return values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
export function formatBucketTime(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
position: "relative",
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
tooltipList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
tooltipRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
},
|
||||
tooltipTerm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
/** Both charts' "nothing here" state, which is also where the ref to measure lives. */
|
||||
export function EmptyChart({ containerRef }: { containerRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartRoot({
|
||||
containerRef,
|
||||
children,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* visx's `Ticks` derives a label's `y` from a guessed font size, which is wrong
|
||||
* here because the size comes from a stylesheet it cannot read. The label is
|
||||
* therefore placed by the axis group's own transform plus the `dy` the caller
|
||||
* passes, and the guessed `y` is dropped.
|
||||
*/
|
||||
function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={0}
|
||||
dx={dx}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline={dominantBaseline}
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** As `TickLabel`, but keeping the scaled tick position the vertical axis puts in `y`. */
|
||||
function ValueTickLabel({ x, y, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dx={dx}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline={dominantBaseline}
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid and the two axes. The grid lines and the value labels are computed
|
||||
* from one tick array, so every drawn line has a number beside it and no number
|
||||
* floats without one.
|
||||
*/
|
||||
export function ChartFrame({
|
||||
plot,
|
||||
yScale,
|
||||
yTicks,
|
||||
xScale,
|
||||
xTickValues,
|
||||
bucketSeconds,
|
||||
}: {
|
||||
plot: Plot;
|
||||
yScale: ValueScale;
|
||||
yTicks: number[];
|
||||
xScale: BandScale;
|
||||
xTickValues: number[];
|
||||
bucketSeconds: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<GridRows scale={yScale} tickValues={yTicks} left={plot.x} width={plot.width} stroke={colors.border} />
|
||||
<AxisLeft
|
||||
scale={yScale}
|
||||
left={plot.x}
|
||||
tickValues={yTicks}
|
||||
numTicks={Y_TICK_COUNT}
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => compact.format(Number(value))}
|
||||
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
|
||||
// with the middle baseline this app centres its value labels on.
|
||||
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
|
||||
tickComponent={ValueTickLabel}
|
||||
/>
|
||||
<AxisBottom
|
||||
scale={xScale}
|
||||
top={plot.bottom}
|
||||
tickValues={xTickValues}
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
stroke={colors.borderStrong}
|
||||
tickFormat={(value) => formatBucketTime(Number(value), bucketSeconds)}
|
||||
tickLabelProps={{ dy: "16px", textAnchor: "middle" }}
|
||||
tickComponent={TickLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a stacked column. The separator is drawn only once the column
|
||||
* is wide enough for two neighbouring segments to read as two shapes; below
|
||||
* that it would be most of the bar.
|
||||
*/
|
||||
export function StackSegment({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
}) {
|
||||
if (height <= 0) return null;
|
||||
return (
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={fill}
|
||||
strokeWidth={width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transparent hit targets: one per bucket, spanning the whole plot height so
|
||||
* that pointing at the empty space above a short column still selects it.
|
||||
*
|
||||
* A slot is a whole band step, gap included, so that no pixel between two
|
||||
* columns belongs to neither. The last slot is clipped to the plot's right edge:
|
||||
* the band scale spends the trailing gap on nothing, and a full-step rect there
|
||||
* would reach into the right margin.
|
||||
*/
|
||||
export function BucketOverlay({
|
||||
plot,
|
||||
values,
|
||||
xScale,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
values: number[];
|
||||
xScale: BandScale;
|
||||
onEnter: (index: number) => void;
|
||||
}) {
|
||||
const step = xScale.step();
|
||||
const right = plot.x + plot.width;
|
||||
return (
|
||||
<>
|
||||
{values.map((value, index) => {
|
||||
const x = xScale(value) ?? plot.x;
|
||||
return (
|
||||
<rect
|
||||
key={value}
|
||||
x={x}
|
||||
y={plot.y}
|
||||
width={Math.max(0, Math.min(step, right - x))}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
|
||||
return (xScale(value) ?? plot.x) + xScale.step() / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which item the pointer is on, and nothing else — a bucket in the bar charts, a
|
||||
* slice in the donuts.
|
||||
*
|
||||
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
|
||||
* position in state decouples them from the data. The window refreshes every
|
||||
* half minute and the container can be resized under an open tooltip, and either
|
||||
* one leaves a stored copy describing an item that has moved or changed. Only
|
||||
* the index is kept, so the caller reads the values and the coordinates out of
|
||||
* the render it is currently drawing.
|
||||
*
|
||||
* `windowKey` is what makes a changing set safe: when the items themselves
|
||||
* change, index `i` no longer names the one the pointer was over, so the
|
||||
* selection made against the old key is dropped and the tooltip goes away.
|
||||
*/
|
||||
export function useActiveIndex(windowKey: string) {
|
||||
const [selection, setSelection] = useState<{ index: number; key: string } | null>(null);
|
||||
// A selection belongs to the window it was made in. Deleting it as soon as the
|
||||
// key moves on is what stops a returning key — a resize back to a previous
|
||||
// width, a poll that restores a bucket count — from reviving a hover the
|
||||
// pointer never made.
|
||||
if (selection !== null && selection.key !== windowKey) setSelection(null);
|
||||
return {
|
||||
index: selection !== null && selection.key === windowKey ? selection.index : null,
|
||||
show: (index: number) => setSelection({ index, key: windowKey }),
|
||||
clear: () => setSelection(null),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TooltipRow {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Omitted for a row that names no drawn shape, such as a bucket's total. */
|
||||
color?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** What a tooltip says, whichever chart opened it: a heading and its rows. */
|
||||
export interface TooltipContent {
|
||||
title: string;
|
||||
rows: TooltipRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `TooltipWithBounds` positions itself with an inline transform and drops it
|
||||
* when `unstyled` is set, so the default look is replaced by handing it an empty
|
||||
* style object rather than by turning styling off.
|
||||
*/
|
||||
const NO_INLINE_STYLE = {};
|
||||
|
||||
/** The gap the tooltip keeps from its anchor point. */
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
|
||||
export function ChartTooltip({
|
||||
content,
|
||||
index,
|
||||
left,
|
||||
top = 0,
|
||||
}: {
|
||||
content: TooltipContent;
|
||||
/** Which item the tooltip names; part of what forces a fresh measurement. */
|
||||
index: number;
|
||||
left: number;
|
||||
top?: number;
|
||||
}) {
|
||||
// `withBoundingRects` measures once, in `componentDidMount`, and never again,
|
||||
// so every content change needs its own mount to be measured at its own size.
|
||||
// The index alone is not enough: a refresh can widen the same item's text past
|
||||
// a digit boundary, or resolve a client's name, and the stale width would place
|
||||
// it wrongly at the right edge.
|
||||
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
|
||||
return (
|
||||
<TooltipWithBounds
|
||||
key={measureKey}
|
||||
left={left}
|
||||
top={top}
|
||||
offsetLeft={TOOLTIP_OFFSET}
|
||||
offsetTop={TOOLTIP_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltip).className}
|
||||
>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
{content.rows.map((row) => (
|
||||
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
{row.color !== undefined && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
|
||||
/>
|
||||
)}
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</TooltipWithBounds>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { MARGIN, isEmptyTimeseries, layoutTimeseries, niceTicks } from "./chartLayout";
|
||||
|
||||
function bucket(ts: number, queries: number, blocked = 0, cached = 0): Bucket {
|
||||
return { ts, queries, blocked, cached };
|
||||
}
|
||||
|
||||
describe("niceTicks", () => {
|
||||
test("zero max yields a single zero tick", () => {
|
||||
expect(niceTicks(0)).toEqual([0]);
|
||||
});
|
||||
|
||||
test("picks a 1/2/5 step and extends past max", () => {
|
||||
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8]);
|
||||
expect(niceTicks(100)).toEqual([0, 50, 100]);
|
||||
expect(niceTicks(1234)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmptyTimeseries", () => {
|
||||
test("true for no buckets and for all-zero buckets", () => {
|
||||
expect(isEmptyTimeseries([])).toBe(true);
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 0)])).toBe(true);
|
||||
});
|
||||
|
||||
test("false when any bucket has queries", () => {
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 3)])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutTimeseries", () => {
|
||||
test("segment heights are proportional and stack to the queries total", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 100, 40, 10), bucket(60, 50, 0, 0)], 480, 240);
|
||||
const plotHeight = 240 - MARGIN.top - MARGIN.bottom;
|
||||
const baseline = MARGIN.top + plotHeight;
|
||||
const [first, second] = layout.bars;
|
||||
|
||||
expect(layout.scaleMax).toBe(100);
|
||||
expect(first.other).toBe(50);
|
||||
expect(first.segments.blocked.height).toBeCloseTo(plotHeight * 0.4);
|
||||
expect(first.segments.cached.height).toBeCloseTo(plotHeight * 0.1);
|
||||
expect(first.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
expect(first.segments.blocked.y + first.segments.blocked.height).toBeCloseTo(baseline);
|
||||
expect(first.segments.cached.y + first.segments.cached.height).toBeCloseTo(first.segments.blocked.y);
|
||||
expect(first.segments.other.y + first.segments.other.height).toBeCloseTo(first.segments.cached.y);
|
||||
expect(first.segments.other.y).toBeCloseTo(MARGIN.top);
|
||||
expect(second.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
});
|
||||
|
||||
test("clamps other at zero when blocked + cached exceed queries", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 10, 8, 5)], 480, 240);
|
||||
expect(layout.bars[0].other).toBe(0);
|
||||
expect(layout.bars[0].segments.other.height).toBe(0);
|
||||
});
|
||||
|
||||
test("zero data still lays out zero-height bars on a unit scale", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 0), bucket(60, 0)], 480, 240);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
expect(layout.bars).toHaveLength(2);
|
||||
for (const bar of layout.bars) {
|
||||
expect(bar.segments.blocked.height).toBe(0);
|
||||
expect(bar.segments.cached.height).toBe(0);
|
||||
expect(bar.segments.other.height).toBe(0);
|
||||
}
|
||||
expect(layout.yTicks).toEqual([{ value: 0, y: MARGIN.top + (240 - MARGIN.top - MARGIN.bottom) }]);
|
||||
});
|
||||
|
||||
test("single bucket fills the plot width minus the gap", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 5, 1, 1)], 480, 240);
|
||||
const plotWidth = 480 - MARGIN.left - MARGIN.right;
|
||||
const bar = layout.bars[0];
|
||||
expect(bar.slot.width).toBeCloseTo(plotWidth);
|
||||
expect(bar.segments.blocked.width).toBeCloseTo(plotWidth - 2);
|
||||
expect(bar.segments.blocked.x).toBeCloseTo(MARGIN.left + 1);
|
||||
expect(layout.xTicks).toEqual([{ ts: 0, x: MARGIN.left + plotWidth / 2 }]);
|
||||
});
|
||||
|
||||
test("x ticks thin out when buckets outnumber the label budget", () => {
|
||||
const buckets = Array.from({ length: 168 }, (_, i) => bucket(i * 3600, i));
|
||||
const layout = layoutTimeseries(buckets, 800, 240);
|
||||
expect(layout.xTicks.length).toBeLessThan(buckets.length / 10);
|
||||
expect(layout.xTicks[0].ts).toBe(0);
|
||||
const xs = layout.xTicks.map((tick) => tick.x);
|
||||
expect([...xs].sort((a, b) => a - b)).toEqual(xs);
|
||||
});
|
||||
|
||||
test("empty bucket list yields no bars and no x ticks", () => {
|
||||
const layout = layoutTimeseries([], 480, 240);
|
||||
expect(layout.bars).toEqual([]);
|
||||
expect(layout.xTicks).toEqual([]);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BarLayout {
|
||||
bucket: Bucket;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
slot: Rect;
|
||||
segments: {
|
||||
blocked: Rect;
|
||||
cached: Rect;
|
||||
other: Rect;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
plot: Rect;
|
||||
scaleMax: number;
|
||||
bars: BarLayout[];
|
||||
yTicks: { value: number; y: number }[];
|
||||
xTicks: { ts: number; x: number }[];
|
||||
}
|
||||
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
const BAR_GAP = 2;
|
||||
const MIN_X_LABEL_PX = 90;
|
||||
|
||||
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
|
||||
export function niceTicks(max: number, targetCount = 4): number[] {
|
||||
if (max <= 0) return [0];
|
||||
const rawStep = max / targetCount;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const normalized = rawStep / magnitude;
|
||||
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; ; value += step) {
|
||||
ticks.push(value);
|
||||
if (value >= max) break;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
||||
return buckets.every((bucket) => bucket.queries === 0);
|
||||
}
|
||||
|
||||
function plotRect(width: number, height: number): Rect {
|
||||
return {
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns share one x-axis label. A 30-day window is 30 columns and a
|
||||
* 1-hour window is 60, so at narrow widths the labels have to thin out rather
|
||||
* than overprint each other.
|
||||
*/
|
||||
function labelStepFor(count: number, plotWidth: number): number {
|
||||
if (count === 0 || plotWidth <= 0) return 1;
|
||||
return Math.max(1, Math.ceil((count * MIN_X_LABEL_PX) / plotWidth));
|
||||
}
|
||||
|
||||
/** One stacked column: the series values in the order the caller stacks them. */
|
||||
export interface StackedColumn {
|
||||
ts: number;
|
||||
total: number;
|
||||
slot: Rect;
|
||||
segments: Rect[];
|
||||
}
|
||||
|
||||
export interface StackedLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
plot: Rect;
|
||||
scaleMax: number;
|
||||
columns: StackedColumn[];
|
||||
yTicks: { value: number; y: number }[];
|
||||
xTicks: { ts: number; x: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same geometry as the query-volume chart, for an arbitrary number of
|
||||
* series. The scale comes from the tallest column's own total, because every
|
||||
* series here is a disjoint part of the whole rather than a highlighted subset
|
||||
* of a separately reported total.
|
||||
*/
|
||||
export function layoutStacked(
|
||||
columns: { ts: number; values: number[] }[],
|
||||
width: number,
|
||||
height: number,
|
||||
): StackedLayout {
|
||||
const plot = plotRect(width, height);
|
||||
const totals = columns.map((column) => column.values.reduce((sum, value) => sum + value, 0));
|
||||
const tickValues = niceTicks(Math.max(0, ...totals));
|
||||
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||
const baseline = plot.y + plot.height;
|
||||
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||
|
||||
const slotWidth = columns.length > 0 ? plot.width / columns.length : 0;
|
||||
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||
|
||||
const laidOut: StackedColumn[] = columns.map((column, i) => {
|
||||
const slotX = plot.x + i * slotWidth;
|
||||
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||
let top = baseline;
|
||||
const segments = column.values.map((value) => {
|
||||
const segmentHeight = toHeight(value);
|
||||
top -= segmentHeight;
|
||||
return { x: barX, y: top, width: barWidth, height: segmentHeight };
|
||||
});
|
||||
return {
|
||||
ts: column.ts,
|
||||
total: totals[i],
|
||||
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||
segments,
|
||||
};
|
||||
});
|
||||
|
||||
const step = labelStepFor(columns.length, plot.width);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
plot,
|
||||
scaleMax,
|
||||
columns: laidOut,
|
||||
yTicks: tickValues.map((value) => ({ value, y: baseline - toHeight(value) })),
|
||||
xTicks: laidOut
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map((column) => ({ ts: column.ts, x: column.slot.x + column.slot.width / 2 })),
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
||||
const plot = plotRect(width, height);
|
||||
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
|
||||
const tickValues = niceTicks(maxQueries);
|
||||
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||
const baseline = plot.y + plot.height;
|
||||
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||
|
||||
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
|
||||
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||
|
||||
const bars: BarLayout[] = buckets.map((bucket, i) => {
|
||||
const slotX = plot.x + i * slotWidth;
|
||||
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
|
||||
const blockedH = toHeight(bucket.blocked);
|
||||
const cachedH = toHeight(bucket.cached);
|
||||
const otherH = toHeight(other);
|
||||
return {
|
||||
bucket,
|
||||
other,
|
||||
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||
segments: {
|
||||
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
|
||||
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
|
||||
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
||||
|
||||
const labelStep = labelStepFor(buckets.length, plot.width);
|
||||
const xTicks = bars
|
||||
.filter((_, i) => i % labelStep === 0)
|
||||
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
|
||||
|
||||
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
|
||||
function slice(key: string, value: number): DonutSlice {
|
||||
return { key, label: key, value, color: "#000000" };
|
||||
}
|
||||
|
||||
test("an empty breakdown has no total and no arcs to draw", () => {
|
||||
expect(layoutDonut([], 100, 20)).toEqual({ size: 100, total: 0, arcs: [] });
|
||||
});
|
||||
|
||||
test("a breakdown of nothing but zeroes is empty, not a division by zero", () => {
|
||||
const layout = layoutDonut([slice("a", 0), slice("b", 0)], 100, 20);
|
||||
expect(layout.total).toBe(0);
|
||||
expect(layout.arcs).toEqual([]);
|
||||
});
|
||||
|
||||
test("zero-valued entries are dropped rather than legended at 0%", () => {
|
||||
const layout = layoutDonut([slice("a", 3), slice("b", 0), slice("c", 1)], 100, 20);
|
||||
expect(layout.arcs.map((arc) => arc.slice.key)).toEqual(["a", "c"]);
|
||||
expect(layout.total).toBe(4);
|
||||
});
|
||||
|
||||
test("shares are of the drawn total and add up to one", () => {
|
||||
const layout = layoutDonut([slice("a", 3), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs.map((arc) => arc.share)).toEqual([0.75, 0.25]);
|
||||
});
|
||||
|
||||
test("slices keep the order they were ranked in, starting at twelve o'clock", () => {
|
||||
const layout = layoutDonut([slice("a", 1), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs[0].d.startsWith("M 50.000 0.000")).toBe(true);
|
||||
// The second slice begins where the first ended, half a turn round.
|
||||
expect(layout.arcs[1].d.startsWith("M 50.000 100.000")).toBe(true);
|
||||
});
|
||||
|
||||
test("a slice over half the ring takes the large-arc flag", () => {
|
||||
const layout = layoutDonut([slice("a", 9), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs[0].d).toContain("A 50 50 0 1 1");
|
||||
expect(layout.arcs[1].d).toContain("A 50 50 0 0 1");
|
||||
});
|
||||
|
||||
test("a single entry is a closed ring, not a zero-length arc that draws nothing", () => {
|
||||
const layout = layoutDonut([slice("only", 7)], 100, 20);
|
||||
expect(layout.arcs).toHaveLength(1);
|
||||
expect(layout.arcs[0].share).toBe(1);
|
||||
// Two half arcs out and two back: a lone `A` from a point to itself is a no-op.
|
||||
expect(layout.arcs[0].d.match(/A /g)).toHaveLength(4);
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that
|
||||
* decides whether a slice closes correctly is testable without a DOM.
|
||||
*/
|
||||
|
||||
export interface DonutSlice {
|
||||
/** Semantic identity: the React key, the colour key and the legend's identity. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
|
||||
secondary?: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface DonutArc {
|
||||
slice: DonutSlice;
|
||||
/** Of the whole, 0 to 1. */
|
||||
share: number;
|
||||
d: string;
|
||||
}
|
||||
|
||||
export interface DonutLayout {
|
||||
size: number;
|
||||
total: number;
|
||||
arcs: DonutArc[];
|
||||
}
|
||||
|
||||
const START_ANGLE = -Math.PI / 2;
|
||||
|
||||
function point(center: number, radius: number, angle: number): string {
|
||||
return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-circle slice cannot be drawn as one arc — start and end coincide, and
|
||||
* the renderer draws nothing at all — so the ring is two half arcs.
|
||||
*/
|
||||
function fullRing(center: number, outer: number, inner: number): string {
|
||||
const top = `${center} ${center - outer}`;
|
||||
const bottom = `${center} ${center + outer}`;
|
||||
const innerTop = `${center} ${center - inner}`;
|
||||
const innerBottom = `${center} ${center + inner}`;
|
||||
return [
|
||||
`M ${top}`,
|
||||
`A ${outer} ${outer} 0 0 1 ${bottom}`,
|
||||
`A ${outer} ${outer} 0 0 1 ${top}`,
|
||||
`M ${innerTop}`,
|
||||
`A ${inner} ${inner} 0 0 0 ${innerBottom}`,
|
||||
`A ${inner} ${inner} 0 0 0 ${innerTop}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices in the order given — the caller has already ranked them — starting at
|
||||
* twelve o'clock and running clockwise. Zero-valued slices are dropped: they
|
||||
* have no arc to draw, and a legend entry reading 0 is noise.
|
||||
*/
|
||||
export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout {
|
||||
const drawn = slices.filter((slice) => slice.value > 0);
|
||||
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
||||
if (total <= 0) return { size, total: 0, arcs: [] };
|
||||
|
||||
const center = size / 2;
|
||||
const outer = center;
|
||||
const inner = Math.max(0, center - thickness);
|
||||
|
||||
if (drawn.length === 1) {
|
||||
return {
|
||||
size,
|
||||
total,
|
||||
arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }],
|
||||
};
|
||||
}
|
||||
|
||||
let angle = START_ANGLE;
|
||||
const arcs = drawn.map((slice) => {
|
||||
const share = slice.value / total;
|
||||
const sweep = share * Math.PI * 2;
|
||||
const end = angle + sweep;
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
const d = [
|
||||
`M ${point(center, outer, angle)}`,
|
||||
`A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`,
|
||||
`L ${point(center, inner, end)}`,
|
||||
`A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
angle = end;
|
||||
return { slice, share, d };
|
||||
});
|
||||
|
||||
return { size, total, arcs };
|
||||
}
|
||||
@@ -441,7 +441,7 @@ export const sample_create_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
restart_required: false,
|
||||
tls_name: "",
|
||||
url: "https://dns2.example/dns-query",
|
||||
};
|
||||
@@ -458,7 +458,7 @@ export const sample_update_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
restart_required: false,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
@@ -633,51 +633,18 @@ export const sample_post_pause: PauseState = {
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.query_log_flush_interval_s",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
@@ -753,51 +720,18 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.query_log_flush_interval_s",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
|
||||
@@ -328,14 +328,11 @@ export const clientPrefixesPutMutation = (qc: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
// The pool builds its clients at startup, so every upstream write leaves the
|
||||
// server owing a restart. The flag it sets lives on /api/config/status, and the
|
||||
// shell notice reads it there — hence the second invalidation.
|
||||
// An upstream write rebuilds the pool in the running process, so the row list
|
||||
// is the only thing it changes: no restart is owed and /api/config/status says
|
||||
// the same thing after the write as before it.
|
||||
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.upstreams }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
|
||||
]);
|
||||
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
|
||||
}
|
||||
|
||||
export const upstreamCreateMutation = (qc: QueryClient) => ({
|
||||
|
||||
@@ -550,7 +550,7 @@ export interface UpstreamEcho {
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
tls_name: string;
|
||||
restart_required: true;
|
||||
restart_required: false;
|
||||
}
|
||||
|
||||
export interface PauseState {
|
||||
@@ -646,8 +646,9 @@ export interface SettingsEnvelope {
|
||||
* authenticated route, never one of the open ones.
|
||||
*
|
||||
* `restart_pending` is true once the server has committed a change only a
|
||||
* restart applies (an upstream write, a settings key). Nothing but process
|
||||
* exit clears it, so a browser reload cannot dismiss it.
|
||||
* restart applies. Every settings key applies live except the listener binds
|
||||
* and `web.enabled`, so those are the only writes that raise it. Nothing but
|
||||
* process exit clears it, so a browser reload cannot dismiss it.
|
||||
*/
|
||||
export interface ConfigStatus {
|
||||
authority: "database" | "managed_file";
|
||||
|
||||
@@ -425,6 +425,8 @@ export function createAppRouter(history?: RouterHistory, queryClient: QueryClien
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
scrollRestoration: true,
|
||||
scrollToTopSelectors: ["#main-content"],
|
||||
defaultPendingComponent: RoutePending,
|
||||
defaultErrorComponent: RouteError,
|
||||
});
|
||||
|
||||
@@ -146,6 +146,15 @@ test("shell renders the overview route with all nav links", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("main carries the ids the router scrolls and restores", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main.id).toBe("main-content");
|
||||
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
|
||||
});
|
||||
|
||||
test("the three configuration pages sit under a labelled group, after the rest", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
@@ -112,6 +112,8 @@ const styles = stylex.create({
|
||||
},
|
||||
shell: {
|
||||
minHeight: "100dvh",
|
||||
height: { default: null, [WIDE]: "100dvh" },
|
||||
overflow: { default: null, [WIDE]: "hidden" },
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
display: { default: "block", [WIDE]: "grid" },
|
||||
@@ -120,6 +122,8 @@ const styles = stylex.create({
|
||||
sidebar: {
|
||||
display: { default: "none", [WIDE]: "flex" },
|
||||
flexDirection: { default: null, [WIDE]: "column" },
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
overflow: { default: null, [WIDE]: "hidden" },
|
||||
borderRightWidth: 1,
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: colors.border,
|
||||
@@ -133,11 +137,14 @@ const styles = stylex.create({
|
||||
},
|
||||
sidebarNav: {
|
||||
flex: 1,
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
overflowY: { default: null, [WIDE]: "auto" },
|
||||
paddingInline: "0.5rem",
|
||||
},
|
||||
column: {
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
minHeight: { default: "100dvh", [WIDE]: 0 },
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
@@ -177,6 +184,9 @@ const styles = stylex.create({
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
overflowY: { default: null, [WIDE]: "auto" },
|
||||
padding: "1rem",
|
||||
},
|
||||
});
|
||||
@@ -335,7 +345,7 @@ export default function AppShell() {
|
||||
<SidebarFooter />
|
||||
</div>
|
||||
)}
|
||||
<main {...stylex.props(styles.main)}>
|
||||
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -60,3 +60,26 @@ if (globalThis.CSS === undefined) {
|
||||
* than per call; a test that genuinely never resolves still fails, only later.
|
||||
*/
|
||||
configure({ asyncUtilTimeout: 5000 });
|
||||
|
||||
/**
|
||||
* jsdom implements no `Element.prototype.scrollTo`, and its `window.scrollTo` is
|
||||
* a stub that logs "Not implemented". The router's scroll restoration calls both
|
||||
* on every navigation, so without these the shell throws into its error boundary
|
||||
* in tests while working in a browser. jsdom has no layout, so a scroll is a
|
||||
* position assignment and nothing more.
|
||||
*/
|
||||
if (typeof Element.prototype.scrollTo !== "function") {
|
||||
Element.prototype.scrollTo = function scrollTo(...args: unknown[]) {
|
||||
const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions;
|
||||
if (typeof options.top === "number") this.scrollTop = options.top;
|
||||
if (typeof options.left === "number") this.scrollLeft = options.left;
|
||||
} as typeof Element.prototype.scrollTo;
|
||||
}
|
||||
|
||||
window.scrollTo = function scrollTo(...args: unknown[]) {
|
||||
const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions;
|
||||
if (typeof options.top === "number")
|
||||
Object.defineProperty(window, "scrollY", { value: options.top, configurable: true });
|
||||
if (typeof options.left === "number")
|
||||
Object.defineProperty(window, "scrollX", { value: options.left, configurable: true });
|
||||
} as typeof window.scrollTo;
|
||||
|
||||
@@ -275,7 +275,21 @@ pub fn build(b: *std.Build) void {
|
||||
// needs the operator's terminal so `git commit -S` can reach pinentry —
|
||||
// none of which a workflow supplies and all of which a Run step passes
|
||||
// through.
|
||||
// The cut's schema gate compares the querylog fingerprint of the previous
|
||||
// release against this tree's. It must read that number from the file the
|
||||
// server uses, never from a copy: a duplicated DDL or a duplicated hash
|
||||
// would let the gate pass a schema change it no longer describes. Only
|
||||
// `fingerprint` and `fingerprintOf` are referenced, both of which are
|
||||
// comptime-computable text hashing, so no SQLite symbol is pulled in and
|
||||
// the host tool needs no library.
|
||||
const querylog_schema_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/storage/querylog_schema.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const cut_tool = hostTool(b, "cut");
|
||||
cut_tool.root_module.addImport("querylog_schema", querylog_schema_mod);
|
||||
const cut_run = b.addRunArtifact(cut_tool);
|
||||
// It pushes commits and tags, so it must never be answered from the run
|
||||
// cache, and it must run at the build root whatever directory `zig build`
|
||||
@@ -298,7 +312,12 @@ pub fn build(b: *std.Build) void {
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(cut_tests).step);
|
||||
cut_tests.root_module.addImport("querylog_schema", querylog_schema_mod);
|
||||
const cut_tests_run = b.addRunArtifact(cut_tests);
|
||||
// The schema-gate round trip reads `src/storage/querylog_schema.zig` off
|
||||
// disk, so the test binary has to run at the build root.
|
||||
cut_tests_run.setCwd(b.path("."));
|
||||
test_step.dependOn(&cut_tests_run.step);
|
||||
|
||||
addDist(b, options, admin_assets, .{
|
||||
.version = version_option,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.9",
|
||||
.version = "0.0.11",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -15,7 +15,7 @@ The route table is `src/web/routes.zig`; the [Operations](#operations) table bel
|
||||
413.
|
||||
- A request whose path matches but whose method does not answers 405 with an `Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api` paths fall through to the embedded SPA (`index.html`), so client-side routing works.
|
||||
- Item routes (`{id}`) match a positive integer id only.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required, and once one of them is written `GET /api/config/status` reports `restart_pending: true`.
|
||||
- Mutations take effect live, upstreams and `/api/settings` included: a write rebuilds or reconfigures the owner it belongs to in-process. The exceptions are the settings keys that create or destroy a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Writing one of those commits the row and reports `restart_pending: true` on `GET /api/config/status`; the settings envelope lists exactly those keys under `restart_required`.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` — and in file mode the `config_write` routes are refused. See [Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
@@ -90,7 +90,7 @@ All four keys are always present; the two nullable ones carry `null` rather than
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`restart_pending` is per-process state and nothing but process exit clears it. It rises when this server writes an upstream or a settings key — the changes the running process cannot apply — and it is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
|
||||
`restart_pending` is per-process state and nothing but process exit clears it. It rises for exactly one kind of change: a settings key that creates or destroys a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Every other write, upstreams included, is applied in-process and leaves the flag alone. It is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read the file?** Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.
|
||||
|
||||
@@ -173,7 +173,7 @@ Static assets are not routes. The router sends unmatched non-`/api` paths to the
|
||||
|
||||
## Settings keys
|
||||
|
||||
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
|
||||
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies — the bind addresses, ports and enabled flags of the four listeners, and nothing else. Every other key is applied by the PUT that changes it. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
|
||||
|
||||
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of [the configuration reference](configuration.md), with the values in their database spelling — notably `logging.level` is `"error"`, not `"err"`. Two keys behave differently over the API than in the file: `web.password` is write-only (accepted on a `PUT`, never returned, hashed before storage), and `web.password_hash` is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
(MIT)
|
||||
|
||||
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Jed Watson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
The nine ISC-licensed d3 modules the admin UI bundles carry the same permission
|
||||
notice under different copyright years. The notices are reproduced together
|
||||
here, one line per package, followed by the ISC text they share.
|
||||
|
||||
d3-array Copyright 2010-2022 Mike Bostock
|
||||
d3-color Copyright 2010-2022 Mike Bostock
|
||||
d3-format Copyright 2010-2021 Mike Bostock
|
||||
d3-interpolate Copyright 2010-2021 Mike Bostock
|
||||
d3-path Copyright 2015-2022 Mike Bostock
|
||||
d3-scale Copyright 2010-2021 Mike Bostock
|
||||
d3-shape Copyright 2010-2022 Mike Bostock
|
||||
d3-time Copyright 2010-2022 Mike Bostock
|
||||
internmap Copyright 2021 Mike Bostock
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
@@ -52,20 +52,67 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
|
||||
@tanstack/react-store 0.9.3 MIT
|
||||
@tanstack/router-core 1.171.15 MIT
|
||||
@tanstack/store 0.9.3 MIT
|
||||
@types/d3-array 3.0.3 MIT
|
||||
@types/d3-color 3.1.0 MIT
|
||||
@types/d3-delaunay 6.0.1 MIT
|
||||
@types/d3-format 3.0.1 MIT
|
||||
@types/d3-geo 3.1.0 MIT
|
||||
@types/d3-interpolate 3.0.1 MIT
|
||||
@types/d3-path 3.1.1 MIT
|
||||
@types/d3-scale 4.0.2 MIT
|
||||
@types/d3-shape 3.1.7 MIT
|
||||
@types/d3-time 3.0.0 MIT
|
||||
@types/d3-time-format 2.1.0 MIT
|
||||
@types/geojson 7946.0.16 MIT
|
||||
@types/react 19.2.17 MIT
|
||||
@types/react-dom 19.2.3 MIT
|
||||
@visx/axis 4.0.0 MIT
|
||||
@visx/bounds 4.0.0 MIT
|
||||
@visx/curve 4.0.0 MIT
|
||||
@visx/grid 4.0.0 MIT
|
||||
@visx/group 4.0.0 MIT
|
||||
@visx/point 4.0.0 MIT
|
||||
@visx/scale 4.0.0 MIT
|
||||
@visx/shape 4.0.0 MIT
|
||||
@visx/text 4.0.0 MIT
|
||||
@visx/tooltip 4.0.0 MIT
|
||||
@visx/vendor 4.0.0 MIT and ISC
|
||||
aria-hidden 1.2.6 MIT
|
||||
balanced-match 0.4.2 MIT
|
||||
classnames 2.5.1 MIT
|
||||
client-only 0.0.1 MIT
|
||||
clsx 2.1.1 MIT
|
||||
cookie-es 3.1.1 MIT
|
||||
css-mediaquery 0.1.2 BSD
|
||||
csstype 3.2.3 MIT
|
||||
d3-array 3.2.1 ISC
|
||||
d3-color 3.1.0 ISC
|
||||
d3-delaunay 6.0.2 ISC
|
||||
d3-format 3.1.0 ISC
|
||||
d3-geo 3.1.0 ISC
|
||||
d3-interpolate 3.0.1 ISC
|
||||
d3-path 3.1.0 ISC
|
||||
d3-scale 4.0.2 ISC
|
||||
d3-shape 3.2.0 ISC
|
||||
d3-time 3.1.0 ISC
|
||||
d3-time-format 4.1.0 ISC
|
||||
delaunator 5.1.0 ISC
|
||||
internmap 2.0.3 ISC
|
||||
invariant 2.2.4 MIT
|
||||
isbot 5.2.1 Unlicense
|
||||
js-tokens 4.0.0 MIT
|
||||
loose-envify 1.4.0 MIT
|
||||
math-expression-evaluator 1.4.0 MIT
|
||||
react 19.2.8 MIT
|
||||
react-aria 3.51.0 Apache-2.0
|
||||
react-aria-components 1.20.0 Apache-2.0
|
||||
react-dom 19.2.8 MIT
|
||||
react-stately 3.49.0 Apache-2.0
|
||||
react-use-measure 2.1.7 MIT
|
||||
reduce-css-calc 1.3.0 MIT
|
||||
reduce-function-call 1.0.3 MIT
|
||||
balanced-match 1.0.2 MIT
|
||||
robust-predicates 3.0.3 Unlicense
|
||||
scheduler 0.27.0 MIT
|
||||
seroval 1.5.6 MIT
|
||||
seroval-plugins 1.5.6 MIT
|
||||
@@ -90,11 +137,34 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
|
||||
@tanstack/react-store
|
||||
@tanstack/router-core
|
||||
@tanstack/store
|
||||
@visx/axis
|
||||
@visx/bounds
|
||||
@visx/grid
|
||||
@visx/group
|
||||
@visx/point
|
||||
@visx/scale
|
||||
@visx/shape
|
||||
@visx/text
|
||||
@visx/tooltip
|
||||
balanced-match
|
||||
classnames
|
||||
clsx
|
||||
d3-array
|
||||
d3-color
|
||||
d3-format
|
||||
d3-interpolate
|
||||
d3-path
|
||||
d3-scale
|
||||
d3-shape
|
||||
d3-time
|
||||
internmap
|
||||
math-expression-evaluator
|
||||
react
|
||||
react-aria
|
||||
react-aria-components
|
||||
react-dom
|
||||
react-stately
|
||||
reduce-css-calc
|
||||
reduce-function-call
|
||||
scheduler
|
||||
use-sync-external-store
|
||||
|
||||
@@ -71,6 +71,25 @@
|
||||
// a `sources` list — no guard would raise it, which is why it is written down
|
||||
// here.
|
||||
//
|
||||
// Twenty-three more joined the not-shipped list with visx, and the sourcemap
|
||||
// build settled which. Fourteen are types only and emit no runtime code at all:
|
||||
// @types/d3-array, @types/d3-color, @types/d3-delaunay, @types/d3-format,
|
||||
// @types/d3-geo, @types/d3-interpolate, @types/d3-path, @types/d3-scale,
|
||||
// @types/d3-shape, @types/d3-time, @types/d3-time-format, @types/geojson,
|
||||
// @types/react and @types/react-dom, with csstype under them. They are in the
|
||||
// runtime closure rather than among the devDependencies only because the @visx
|
||||
// packages declare them as ordinary dependencies. @visx/curve is the curve
|
||||
// factory the line and area shapes take, which these charts do not draw.
|
||||
// @visx/vendor is the one worth stating plainly: it is a re-export shim over the
|
||||
// d3 modules, npm records it as `MIT and ISC` because of what it re-exports, and
|
||||
// the bundler resolves straight through it to the d3 packages themselves — so
|
||||
// its own bytes never reach admin/dist and the d3 entry below is what covers the
|
||||
// code that does. d3-delaunay, d3-geo, d3-time-format, delaunator and
|
||||
// robust-predicates are the map and Voronoi half of that shim, which no chart
|
||||
// here imports; the first four are ISC and robust-predicates is Unlicense.
|
||||
// react-use-measure is the resize hook @visx/responsive uses, and this app keeps
|
||||
// its own, so nothing pulls it in.
|
||||
//
|
||||
// Of the remaining devDependencies, none puts a byte in admin/dist: @vitejs/
|
||||
// plugin-react and typescript only transform our own sources (react-refresh is
|
||||
// dev-server only, and tslib is optional and unused), lightningcss only
|
||||
@@ -155,6 +174,48 @@
|
||||
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
|
||||
.file = "tanstack-store-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "visx (@visx/axis, @visx/bounds, @visx/grid, @visx/group, @visx/point, @visx/scale, @visx/shape, @visx/text, @visx/tooltip)",
|
||||
.version = "@visx/axis 4.0.0, @visx/bounds 4.0.0, @visx/grid 4.0.0, @visx/group 4.0.0, @visx/point 4.0.0, @visx/scale 4.0.0, @visx/shape 4.0.0, @visx/text 4.0.0, @visx/tooltip 4.0.0",
|
||||
.note = "The chart primitives the Overview charts are drawn with — scales, stacked bars, pie arcs, axes, grid and tooltip — bundled into the admin UI JavaScript. Nine of the eleven @visx packages in the closure ship; @visx/curve and @visx/vendor do not (see the note at the head of this file). All nine carry a byte-identical MIT text. In the tarballs and in the image.",
|
||||
.file = "visx-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "d3 (d3-array, d3-color, d3-format, d3-interpolate, d3-path, d3-scale, d3-shape, d3-time, internmap)",
|
||||
.version = "d3-array 3.2.1, d3-color 3.1.0, d3-format 3.1.0, d3-interpolate 3.0.1, d3-path 3.1.0, d3-scale 4.0.2, d3-shape 3.2.0, d3-time 3.1.0, internmap 2.0.3",
|
||||
.note = "The scale, colour, number-format and path arithmetic behind visx, bundled into the admin UI JavaScript. They arrive through @visx/vendor, which re-exports them without contributing bytes of its own, so these nine are what the bundle actually carries. The first ISC dependencies this project has taken: Mokhtar Mial accepted ISC inbound for nxdns on 2026-08-24, which is the decision that let them ship. ISC asks only that the copyright notice and the permission notice travel with the copies; the nine notices differ in their copyright years alone, so the file below reproduces every notice above the single shared permission text. In the tarballs and in the image.",
|
||||
.file = "d3-isc.txt",
|
||||
},
|
||||
.{
|
||||
.component = "classnames",
|
||||
.version = "classnames 2.5.1",
|
||||
.note = "The class-name joiner visx uses to merge its own chart class names with a caller's. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "classnames-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "balanced-match",
|
||||
.version = "balanced-match 0.4.2, balanced-match 1.0.2",
|
||||
.note = "The bracket matcher reduce-function-call calls when it takes a CSS calc() expression apart. Bundled into the admin UI JavaScript. Two versions are installed, one under reduce-function-call and one at the root; both carry this text. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "balanced-match-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "math-expression-evaluator",
|
||||
.version = "math-expression-evaluator 1.4.0",
|
||||
.note = "The arithmetic evaluator reduce-css-calc reduces a calc() expression with, reached from @visx/text's width measurement. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "math-expression-evaluator-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "reduce-css-calc",
|
||||
.version = "reduce-css-calc 1.3.0",
|
||||
.note = "Resolves CSS calc() expressions for @visx/text. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "reduce-css-calc-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "reduce-function-call",
|
||||
.version = "reduce-function-call 1.0.3",
|
||||
.note = "The function-call walker reduce-css-calc is built on. Bundled into the admin UI JavaScript. Separate entry from reduce-css-calc: same MIT text, different copyright line.",
|
||||
.file = "reduce-function-call-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Vite",
|
||||
.version = "vite 8.1.5",
|
||||
|
||||
@@ -55,6 +55,13 @@ pub const texts: []const Text = &.{
|
||||
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
|
||||
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
|
||||
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
|
||||
.{ .name = "visx-mit.txt", .body = @embedFile("visx-mit.txt") },
|
||||
.{ .name = "d3-isc.txt", .body = @embedFile("d3-isc.txt") },
|
||||
.{ .name = "classnames-mit.txt", .body = @embedFile("classnames-mit.txt") },
|
||||
.{ .name = "balanced-match-mit.txt", .body = @embedFile("balanced-match-mit.txt") },
|
||||
.{ .name = "math-expression-evaluator-mit.txt", .body = @embedFile("math-expression-evaluator-mit.txt") },
|
||||
.{ .name = "reduce-css-calc-mit.txt", .body = @embedFile("reduce-css-calc-mit.txt") },
|
||||
.{ .name = "reduce-function-call-mit.txt", .body = @embedFile("reduce-function-call-mit.txt") },
|
||||
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
|
||||
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
|
||||
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Ankit G.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Maxime Thirouin & Joakim Bengtson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Maxime Thirouin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2018 Harrison Shoff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Milestone 34: hot-apply — tiers A and B
|
||||
|
||||
DB-mode config changes apply live, in-process, for every key that does not create a socket. The comptime "every key is restart-required" blanket is replaced by an explicit per-key apply table. Design: rev 3 of the hot-reload analysis; spec hardened through two Codex review rounds (thread 01a02efc). Listener rebind and `web.enabled` lifecycle are milestone 35.
|
||||
|
||||
## The write contract (every session honors it)
|
||||
|
||||
Prepare → commit → publish → retire:
|
||||
1. **Prepare**: under the serialized config-mutation path, build and validate everything the change needs, from the FINAL MERGED config — ONE candidate per affected owner, never one per key. Prepared resources are heap-stable and owned (a candidate outlives the request arena). Nothing is published. Any failure: clean up every prepared resource, error to the client, NO db write.
|
||||
2. **Commit**: the DB transaction, only after every prepare succeeded. Commit failure: clean up all prepared resources.
|
||||
3. **Publish**: infallible, I/O-free operations — handle swaps, pointer swaps, stores under a lock. Closing files, joining tasks, freeing memory belong to retire, not publish.
|
||||
4. **Retire**: old generations/handles are drained, closed, and freed after their readers release.
|
||||
|
||||
Signatures: every operation that touches a `std.Io` primitive (Mutex, RwLock, Condition, concurrent, sleep) takes `io: std.Io`. The spec's named signatures include it; a builder adds it wherever else the primitive demands it.
|
||||
|
||||
## Sessions
|
||||
|
||||
Six sessions, strictly sequential (S1 → … → S6); each session owns the tree while it runs and ends with `zig build test` green (S6 adds the admin suite).
|
||||
|
||||
**Honesty rule**: `restart_pending`/`restart_required` keep firing exactly as today until S5 swaps the mechanism atomically. No earlier session removes a restart signal.
|
||||
|
||||
---
|
||||
|
||||
## Session S1: per-query policy snapshots (tier A)
|
||||
|
||||
### S1.1 DNS policy snapshot
|
||||
`src/server/handler.zig`: the per-query reads — blocking response + ttl, `ecs_mode`, `forward_read_timeout`, `negative_ttl_max` — move into one `Policy` struct behind `std.Io.RwLock` (the filter discipline, manager.zig:438/856). Each query copies the `Policy` ONCE at query start (shared lock only for the copy); `ecs_mode` at :782/:829/:835 reads the copy. Publish: `pub fn setPolicy(h: *Handler, io: std.Io, p: Policy)`.
|
||||
|
||||
### S1.2 Web trusted proxies
|
||||
`src/web/server.zig` ~:531: `trusted_proxies` becomes an owned immutable generation in `WebState`, pointer-replaced under an RwLock (io-threaded), old generation retired after readers release. Prepare copies out of the request arena.
|
||||
|
||||
### S1.3 Logger config split
|
||||
`src/storage/logger.zig`: `hide_domains` + `hide_client_ips` (applied on the PRODUCER path ~:449) are ONE privacy policy: both pack into a single atomic (one u8), stored together by `setPrivacy` and loaded ONCE per entry — a producer can never observe a mixed policy that redacts the domain but exposes the client, or vice versa. `query_log_flush_interval_s` (~:609) is an independent `.monotonic` atomic with `setFlushInterval`.
|
||||
|
||||
### S1.4 Disk thresholds
|
||||
`src/storage/disk_monitor.zig`: `min_free_mb`/`warn_free_mb` are one invariant pair (warn ≥ min): both u32s packed into ONE atomic u64; readers unpack a single load.
|
||||
|
||||
### S1 implementation notes (post-build)
|
||||
- `Context`'s provenance method `policy(...)` renamed `notePolicy` (collision with the new per-query field).
|
||||
- Pure-atomic setters (`setPrivacy`, `setFlushInterval`, `setThresholds`) take no `io` — they touch no Io primitive.
|
||||
- `LiveProxies.install` RETURNS the retired generation; the caller frees it in retire.
|
||||
- The trusted-proxies read hold ends at the client-address verdict, BEFORE router dispatch — a hold spanning dispatch would let a settings PUT deadlock on the shared lock its own request holds.
|
||||
- The flush-interval test covers short→long only; long→short is unobservable without waking a writer already parked on its old deadline (S5's PUT test inherits this bound).
|
||||
- S1 setters have no production caller until S5 wires the PUT flow — test-only until then, by design.
|
||||
|
||||
### S1.5 Acceptance criteria
|
||||
- [ ] One query is internally consistent across a concurrent `setPolicy` (both ecs/blocking reads agree).
|
||||
- [ ] trusted_proxies replaced under concurrent request-path reads; testing allocator clean.
|
||||
- [ ] Privacy flip with FORCED producer interleaving: pre-flip entries unredacted, post-flip redacted.
|
||||
- [ ] Threshold reader vs concurrent pair-stores: every observed pair satisfies warn ≥ min.
|
||||
- [ ] Flush-interval change observed on the writer's next cycle via the existing timing seam.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S2: upstream extraction + generation owner + lock hygiene
|
||||
|
||||
### S2.1 Extract the upstream composition out of app.zig
|
||||
`Upstreams`, `build`, `deinit`, and what they need from `ConfigLoad` (all private in app.zig ~:1158) move to `src/upstream/owner.zig`; app.zig imports it, never the reverse. `build` returns the generation plus a `BuildReport`. `ConfigLoad.note` is NOT just log output — it writes operational-event rows and retains canonical keys for `finalize` (app.zig:367). Contract: at BOOT, app.zig replays the successful `BuildReport` through the exact `ConfigLoad.note` path before `finalize`, so diagnostics are unchanged; at RUNTIME, candidate preparation is side-effect-free (no event rows) until commit, and the report is RECONCILED in RETIRE (after the pointer publish — event rows are SQLite I/O, banned from publish): retire reconciles SCOPED, not via `ConfigLoad.finalize` (finalize calls `Store.resolveExcept`, events.zig:402, which resolves EVERY active configuration.load event outside its kept set — at runtime that would falsely resolve unrelated boot warnings). Each generation OWNS its report keys (stored in the generation), and the retire payload is STABLE: prepare copies the LIVE generation's report keys (under the owner mutex) into the PUT-owned apply state, so reconciliation never depends on the old generation's lifetime and NEVER runs from a query's `release` path — it runs on the PUT task in retire. Retire: emit the new report's events, then individually resolve exactly `copied_previous_keys − new_report_keys`. S5 tests through the real PUT path: introducing a malformed upstream raises the warning; fixing it resolves it; an UNRELATED active configuration warning survives the whole introduce/fix cycle.
|
||||
|
||||
### S2.2 UpstreamOwner
|
||||
Discipline: CertStore's (cert_store.zig:199/:237) — a mutex held briefly for refcounted borrows.
|
||||
- `pub fn acquire(o: *Owner, io: std.Io) *Generation` — lock, ++refs, return.
|
||||
- `pub fn release(o: *Owner, io: std.Io, g: *Generation)` — lock, --refs; a release that drops a RETIRED generation to zero deinits it (Upstreams.deinit needs io).
|
||||
- `pub fn replace(o: *Owner, io: std.Io, prepared: *Generation) ?*Generation` — lock, swap the live pointer, mark old retired; if the old generation's refs are ALREADY zero, return it for the caller to retire immediately (the CertStore refs==0 pattern) — otherwise null and the last release retires it.
|
||||
- `pub fn deinit(o: *Owner, io: std.Io)` — shutdown teardown of the live generation, called by serve() after listeners and metrics readers have stopped.
|
||||
- Prepared generations are heap-allocated and OWN every configuration string (URL text — transport.Endpoint borrows it, transport.zig:51 — plus tls_name, host, path): each generation carries its own arena covering every borrowed row string until retirement. Candidates built from request-arena rows copy into that arena at prepare.
|
||||
- Timeouts are baked into the generation; a timeout change is a replace.
|
||||
- Query path: `Handler` stores `*Owner` (replacing the boot-time type-erased client, handler.zig:91). Per exchange: acquire → generation's `transport.Client` → exchange → copy the resolver identity from `selected` (borrowed from the endpoint, transport.zig:350) into the PER-QUERY `Context.upstream_buf` (handler.zig:453 — never a Handler-owned buffer; Handler is shared across concurrent queries) → release.
|
||||
- Metrics/health: `WebState` stores `*Owner` (replacing `*Pool`, web/server.zig:144); a scrape acquires for its duration.
|
||||
|
||||
### S2.3 Cache and rate-limiter lock hygiene
|
||||
- handler.zig:799 and app.zig:1124: cache pointer loads move inside `cache_mutex`; then `replaceCache` swaps under it (entries lost — accepted).
|
||||
- handler.zig:189: same for the rate limiter; `replaceRateLimiter` under its lock (windows reset — accepted).
|
||||
|
||||
### S2 implementation notes (post-build)
|
||||
- The retire-time scoped reconciler is DEFERRED TO S5 (S2 has no runtime replace caller; uncalled code fails the values). S2 delivers its precondition: generations own copyable report keys. **S5 must implement the reconciler + its tests (including unrelated-warning-survives).**
|
||||
- `Generation` gains a test-only `borrowing`/`borrowingPool` payload so ~90 existing test sites keep their fake clients without heap generations; production always takes the built path.
|
||||
- One skipped-upstream log string reworded to unify with the event detail; the event detail (what the criterion asserts) is byte-identical.
|
||||
- Integration-gated call sites were mechanically touched (comptime-dead without -Dintegration; would have broken that build).
|
||||
|
||||
### S2.4 Acceptance criteria
|
||||
- [ ] Concurrent exchanges vs `replace`: in-flight completes on G1; G1 deinits only after last release (or immediately when refs==0 at replace); new exchanges on G2; allocator clean.
|
||||
- [ ] A replace while NO reader holds G1 retires G1 via the replace return path (refs==0 branch covered).
|
||||
- [ ] Generation string ownership: build a candidate from a transient arena, free the arena, exchange still reads valid url/tls_name (allocator-poisoning test).
|
||||
- [ ] Metrics scrape concurrent with replace: clean.
|
||||
- [ ] Cache and rate-limiter swaps under concurrent use: clean.
|
||||
- [ ] Boot diagnostics unchanged: the events ConfigLoad.note wrote before the extraction are written identically (assert on the events store, not stdout).
|
||||
- [ ] Restart signals untouched this session.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S3: in-place reconfigure, retention, certs, sink, scheduler
|
||||
|
||||
### S3.1 Sessions TTL
|
||||
Slots gain `issued_at` beside `expires_at` (auth.zig:311). `setTtl(io, ttl)` recomputes each live slot: `expires_at = issued_at + ttl` under the existing mutex. Semantics are SERVER-SIDE: shortening takes effect for every session; lengthening is bounded by the browser cookie's original Max-Age (the cookie is not refreshed — sessions do not become sliding; state this in a comment). Login cookie Max-Age (handlers/auth.zig:127) reads the LIVE ttl.
|
||||
|
||||
### S3.2 ApiLimiter
|
||||
ALL config reads move under the existing mutex (`check` reads `localhost_exempt` pre-lock, api_limiter.zig:135). `setLimits(io, now, limits)`: refill each bucket THROUGH `now` at the old rate, clamp tokens to the new capacity, then install the new rate — no retroactive refill at the new rate, and never move a bucket's clock backward (a bucket already newer than `now` is clamped only). SSE per-IP counts untouched.
|
||||
|
||||
### S3.3 Retention days
|
||||
Two consumers (retention.zig:103, clients.zig:228): one shared `.monotonic` atomic u32 owned by app-level state, read per pass by both; `setRetentionDays` stores.
|
||||
|
||||
### S3.4 Certificate paths
|
||||
`doh_server.cert_path/key_path`, `dot_server.cert_path/key_path` on an ENABLED endpoint: CertStore's paths (borrowed boot slices, reread by reload at cert_store.zig:111, read outside `mutex` by reload/pollOnce at :226/:268) become owned, replaceable strings. The whole apply is serialized under the store's existing `reload_mutex` — the SAME mutex reload holds across load+publication: prepare (under reload_mutex) loads the candidate cert+key from the new paths; publish (still under reload_mutex, taking the generation `mutex` only for the swap — lock order: reload_mutex outer, mutex inner, matching reload today) swaps paths and generation together. `pollOnce` currently reads the paths BEFORE taking reload_mutex (cert_store.zig:268): it now takes reload_mutex around its path reads and calls a non-locking `reloadLocked` helper (reload becomes reload_mutex-lock + `reloadLocked`) so acquisition is never recursive. With that, no concurrent path borrow can outlive an apply. Connections pinning the old cert finish on it. `POST /api/certs/reload` rereads the owned paths.
|
||||
On a DISABLED endpoint no CertStore exists (handlers/certs.zig:31 — `doh_certs`/`dot_certs` are null): the change is DB-ONLY, and the key's table entry says so; the paths are validated when milestone 35 implements enable. This is stated in the table note and the API docs.
|
||||
|
||||
### S3.5 Log sink
|
||||
Three disjoint cases, decided from the merged config — no reuse marker, no lock spanning prepare and publish:
|
||||
- **File target changed** (the merged config has output=file AND the path differs from the current file target, or output switches TO file): prepare opens the NEW target (fallible, closing the TOCTOU in logging.zig:228's close-then-reopen) into an owned `PreparedSink` that carries the COMPLETE target state: the open file, its MEASURED length as the new `file_pos`, and `rotate_pending = false` (the handle couples to both fields, logging.zig:196 — inheriting the old position corrupts writes; inheriting a pending rotation rotates the new target spuriously). Publish installs `{file, file_pos, rotate_pending}` + config atomically under the sink's existing lock; the DETACHED old handle closes in retire. Races can only touch the OLD state, replaced wholesale.
|
||||
- **File target removed** (output switches FROM file to stderr/syslog): nothing to prepare; publish installs `{file = null, file_pos = 0, rotate_pending = false}` + config atomically; the detached file closes in retire.
|
||||
- **File target unchanged** (every remaining case: output stays stderr/syslog, or output=file with the same path — level, limits, or a file_path change while output is non-file): publish updates ONLY the config fields under the sink lock and NEVER touches the handle or its position/rotation state, which stay owned by the existing rotation/recovery machinery. A same-path config apply does not repair a broken handle (the existing per-write recovery does). No prepare-time handle inspection exists, so there is nothing to race. (A file_path change while output is stderr still updates the config so a later output=file switch — its own apply — opens the right target.)
|
||||
The disk monitor's measured directory derives from the final merged `output + file_path` on EVERY log_sink apply: output `file` → the file's directory (installed even if only output changed); output stderr/syslog → cleared (monitor stops measuring a log dir). `Monitor.setLogDir(io, owned_path_or_null)`; `sample` borrows `log_dir_path` across directory I/O (disk_monitor.zig:112), so the path is a pinned generation — sample acquires (refcount or lock held for the borrow), setLogDir swaps, old path freed after the borrow releases. Owned path prepared pre-commit.
|
||||
|
||||
### S3.6 Wakeable blocklist scheduler
|
||||
manager.zig:1493 restructured into a wakeable loop parked on a condition:
|
||||
- The startup refresh pass runs exactly as today, including when disabled.
|
||||
- `setSchedule(io, enabled, interval_hours)` stores under the scheduler's mutex with a version counter (no lost wakes between check and park) and signals.
|
||||
- Anchor rule: the anchor is the completion time of the last refresh pass that RAN — success or failure both advance it; a disk-gate skip ALSO advances it (today's semantics: the scheduled slot is skipped, not retried early). Next refresh = anchor + interval; if that is in the past at set/enable time, refresh immediately.
|
||||
- Disabled parks; the task exits only on shutdown, exactly as today.
|
||||
- Production wake primitive: `std.Io.Event.waitTimeout` (Condition has no timed wait in 0.16). The Event is STICKY after `set` — the reset sequence prevents both spinning and lost wakes: under the scheduler's mutex the loop reads the version, RESETS the event, recomputes its deadline, releases the mutex, then waits; `setSchedule` (under the same mutex) bumps the version, then sets the event. A set that lands between the loop's reset and its wait completes the wait immediately; the version recheck decides whether anything changed.
|
||||
- Test seam: an injectable clock/step seam — validated intervals are ≥ 1 h; acceptance tests must not sleep real time.
|
||||
|
||||
### S3 implementation notes (post-build)
|
||||
- The disabled-endpoint cert criterion (DB-only, no store call, no-restart response) is PURELY a PUT concern — **deferred to S5's obligations** beside the S2 reconciler.
|
||||
- `model.retentionSeconds` deleted (dead once both consumers read the shared cell); `RetentionDays.seconds()` is the single conversion point.
|
||||
- A disabled scheduler PARKS; the seam models shutdown (`shutdown_at_first_park` → error.Canceled) — the loop's only exit is shutdown.
|
||||
- `publishPathChange` reads no clock (loaded_at captured at prepare).
|
||||
- `Monitor.deinit(io)` added to free an installed log-dir generation (no-op at boot — arena-borrowed).
|
||||
- `setLimits` takes the full limiter `Config` (the three fields ARE the config; a twin struct would be invented generality).
|
||||
- Sink race criteria proven as the two reachable interleavings (publish shares the stderr lock with rotation/closure — no third ordering exists); the monitor criterion is a real two-task race.
|
||||
|
||||
### S3.7 Acceptance criteria
|
||||
- [ ] TTL: shortening expires an over-age live session immediately; lengthening extends server-side validity; a fresh login's cookie Max-Age reflects the live ttl.
|
||||
- [ ] Limiter: no pre-lock config read remains; refill-through-now at old rate proven with a controlled clock; capacity cut clamps; SSE counts survive.
|
||||
- [ ] Retention: both consumers observe a change on their next pass.
|
||||
- [ ] Certs: bad candidate refused at prepare, store untouched; good change serves the new cert on the next handshake (-Dintegration loopback); a concurrent reload during an apply is serialized (test drives both under the seam).
|
||||
- [ ] Disabled-endpoint cert change: DB row changes, no store call, response marks no restart.
|
||||
- [ ] Sink: publish does no open/close (close observed in retire); bad path refused at prepare; new file receives lines; monitor samples the new dir; no use-after-free under concurrent sample; a same-path config-only apply concurrent with a forced rotation AND with a write-failure closure changes only config fields, never the handle; a target-change apply racing rotation swaps cleanly (old handle closed in retire); switching to a PRE-EXISTING nonempty file starts at its measured length; switching away from a target with `rotate_pending` set does not rotate the new target; a `file → stderr` apply detaches the handle (closed in retire) and clears position/rotation state.
|
||||
- [ ] Scheduler via the seam: shortened interval → next at new cadence; disable parks; re-enable anchors per rule; startup pass runs when disabled; failed pass advances the anchor; gate-skip advances the anchor.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S4: logger queue controller
|
||||
|
||||
### S4.1 Controller scope
|
||||
`logging.query_log_buffer_max`. A stable controller owns the logger generation: buffer, `Logger`, writer future, and the writer's DB/gate dependencies (writer holds prepared statements for its run, logger.zig:514; future owned by serve today, app.zig:897). serve() creates the controller and delegates; shutdown ordering through the controller is EXACTLY today's safe order — quiesce producers → close queue / set draining → the writer drains the CLOSED queue → await the future (logger.zig:632, app.zig:921; never "drain then close": an empty writer blocks in getOne until close).
|
||||
Facade: `QuerySink` (query_sink.zig:18), metrics (web/metrics.zig:207) and health (web/handlers/health.zig:241, via web/server.zig:150) hold the CONTROLLER, not `*Logger`. The controller owns what must be continuous across swaps: counters, gate-episode state, `last_drop_s`, diagnostics references, AND the S1 privacy/flush atomics (setters address the controller and survive resize). `writer_failed` reflects the LIVE writer: a successful resize publish clears it (the new writer prepared cleanly); a retired writer's failures still land in the drop/diagnostic counters.
|
||||
Producer read-side protocol: a producer's `log()` acquires the live generation through the facade with a refcount (or a lock held through transform + enqueue) — a producer can never enqueue into a queue that retire has closed; retire waits for old-generation producer borrows to release before closing the old queue. This is what makes "no resize drop window" true, not an aspiration.
|
||||
|
||||
### S4.2 Resize — all fallibility before commit
|
||||
1. **Prepare** (fallible): validate against the comptime ceiling (37449, startup's message); allocate the new buffer and Logger state; OPEN A NEW querylog DB connection for the new generation — one connection per writer for its whole life (logger.zig:514); two writers must never share `querylog_writer_db`. The connection factory: the canonical writer-connection opener (today `cli.DataDir.reopenQuerylogDb` + `db.applyPragmas`, cli.zig:350) MOVES into `src/storage` (a pub fn beside `querylog_schema.open` taking the data-dir handle + path); cli.zig delegates to it, and the controller receives the factory inputs at construction. The controller owns each generation's connection and closes it after that generation's writer is joined — and SPAWN the new writer PARKED: the spawn (`io.concurrent` can fail, Io.zig:2352) and statement preparation (runWriter can fail, logger.zig:524) happen NOW; the parked writer signals ready and waits on an activation gate. Any failure: tear down, close the new connection; nothing changed. If a PREVIOUS resize's retired generation has not finished retiring, prepare REFUSES with a distinct error ("previous resize still draining") — at most one retired generation exists, which bounds writers, connections, and buffers.
|
||||
2. **Commit** the DB row.
|
||||
3. **Publish** (infallible): swap the facade's live-generation pointer and open the activation gate. No waiting.
|
||||
4. **Retire** (controller-owned reaper): wait for old-producer borrows to release → close the old queue WITHOUT setting the process-shutdown `draining` flag (a distinct retirement mode: with `draining` set, a gate-held writer takes the GatedAtShutdown path and DROPS its batch, logger.zig:642/:726 — retirement instead lets it flush when the gate reopens) → the old writer drains the closed queue → await its future → close its DB connection → free logger + buffer.
|
||||
Reaper ownership: the reaper task is spawned ONCE at controller construction (boot — fallibility there is fine) and lives for the controller's life, processing retirements signaled by publish; publish never spawns. `Future.await` is not thread-safe (Io.zig:1198), so the reaper is the SOLE joiner of retired writers. Process shutdown, in order: quiesce producers → CLOSE the live queue (a writer blocked in getOne wakes only on close, logger.zig:642) → set `draining` on EVERY outstanding generation (live and retired — turning a gate-parked retired writer onto the counted GatedAtShutdown path) → join the LIVE writer → signal and join the REAPER (which finishes joining any retired writer). The f1a85d3 ordering holds within each join.
|
||||
Accounting: every entry accepted into the old queue is written when the gate allows, or counted by the existing gate/shutdown machinery; entries after publish land in the new queue. No resize-specific drop path exists.
|
||||
|
||||
### S4 implementation notes (post-build)
|
||||
- The controller is a new file, `src/storage/logger_controller.zig`, not a second half of `logger.zig` (2190 lines before this session, and generations/retirement/the reaper are a separate concern from `Entry` and the writer loop). Add it to the module layout.
|
||||
- `Logger` keeps its exact public surface, so every f1a85d3 test passes unchanged. Continuity is achieved by SUMMING rather than by sharing cells: `Controller.sample` folds `base` (finals of joined generations) + the live generation + the outstanding retired one, under the controller mutex, and folds a retired generation's finals into `base` before freeing it. `last_drop_s` is a max, the gate episode is the worst of the outstanding generations, `writer_failed` is the live one's.
|
||||
- `Logger.runWriter` is split: it prepares its statements and calls the new `pub fn runPrepared(io, writer, monitor)`. A resize generation prepares separately so a statement failure is a refused settings change. `Logger.retire(io)` is the close-without-`draining` retirement mode.
|
||||
- The BOOT generation deliberately takes the plain `runWriter` path, not the parked one: a boot that cannot prepare must still serve DNS with `writer_failed` set, which is today's behaviour. Only a resize can afford to refuse.
|
||||
- The connection factory is `querylog_schema.reopen(io, dir, path)`; `cli.DataDir.reopenQuerylogDb` delegates. It takes the dir handle to make `open`'s resolution pairing explicit at every call site, and does not dereference it.
|
||||
- The setters are `Controller.setPrivacy(io, p)` / `setFlushInterval(io, s)` and take `io` (they lock), unlike S1's pure-atomic pair. They store to every outstanding generation; each generation still keeps ONE packed privacy word, so a producer's single load stays the mixed-policy guarantee.
|
||||
- `Controller.prepare` returns typed errors; `sizeMessage(err, requested, buf)` renders `config/validate.zig`'s exact wording for S5's client error.
|
||||
- `Controller.retirementPending(io)` is the "previous resize still draining" predicate, exposed for S5 and the tests.
|
||||
- Test sites keep their stack `Logger`s through `logger_controller.Borrowed` — S2's `upstream_owner.Borrowed` pattern.
|
||||
- **S5 obligations unchanged**: the S2 retire-time scoped reconciler and the S3 disabled-endpoint cert criterion. S4 adds none.
|
||||
|
||||
### S4.3 Acceptance criteria
|
||||
- [ ] Resize under forced concurrent producers: no deadlock; exact accounting — old-queue entries all written (or gate-counted), new-queue entries written, produced == written + counted; no resize-specific drops.
|
||||
- [ ] Resize with the disk gate CLOSED: publish completes immediately; old writer retires after the gate reopens; nothing lost beyond what the gate itself counts.
|
||||
- [ ] Prepare failure (spawn or statement prep) leaves the running logger untouched and writes no DB row.
|
||||
- [ ] Invalid size refused with startup's message.
|
||||
- [ ] Counters/gate state/`last_drop_s` continuous across a swap (metrics read before and after agree modulo new writes).
|
||||
- [ ] Privacy interleaving test pauses a producer between its two field decisions across a concurrent `setPrivacy`: a mixed policy (domain redacted, client exposed, or the reverse) is impossible.
|
||||
- [ ] Shutdown while a retirement is gate-blocked: clean join, the retired writer's held batch is counted by GatedAtShutdown, no deadlock, no double-await.
|
||||
- [ ] f1a85d3 deadlock regression tests pass unchanged.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S5: the apply table and the settings write path
|
||||
|
||||
### S5.1 The key table
|
||||
`src/web/handlers/settings.zig`: delete the comptime `restart_required_keys` generation (:68-95) and `touchesRestartRequiredKey` (:187-200). The table maps EVERY settings key to a CONCRETE operation enum — `dns_policy`, `trusted_proxies`, `logger_privacy`, `logger_flush`, `disk_thresholds`, `upstream_generation`, `cache`, `rate_limiter`, `sessions_ttl`, `api_limiter`, `retention`, `certs_doh`, `certs_dot`, `log_sink`, `scheduler`, `logger_queue`, `bind`, `web_lifecycle` — one entry per key, no second unguarded switch; the broad class (live/subsystem/bind/web_lifecycle) is DERIVED from the operation. `bind` (dns/web/doh/dot bind + port + doh/dot enabled) and `web_lifecycle` (`web.enabled`) set `restart_pending` exactly as today; milestone 35 executes them.
|
||||
Comptime test: walk `@typeInfo(model.Config)` as the old generator did; every scalar key appears exactly once (replaces :549).
|
||||
|
||||
### S5.2 The PUT flow
|
||||
validate → group changed keys by OPERATION → prepare one candidate per affected owner from the final merged config → any failure cleans up all prepared candidates, errors, no DB write → commit (failure: clean up) → publish each prepared candidate → retire. `web.password` keeps its existing path. Upstream create/update/delete (handlers/upstreams.zig): build the candidate generation from the HYPOTHETICAL post-mutation row set before the repository write; commit; publish. The direct `restart_pending` sets (:58/:90/:114) are deleted HERE. `restart_required` in upstream responses (upstreams.zig:156) becomes `false`; update the OpenAPI `UpstreamEcho` pin (openapi.yaml:2749), the `/api/settings` description that says every scalar setting requires restart (openapi.yaml:1699), the API reference claim that all upstream/settings mutations do (docs/reference/api.md:18), generated types, and contract samples.
|
||||
|
||||
### S5.3 Config status
|
||||
`GET /api/config/status`: `restart_pending` remains, fed only by `bind`/`web_lifecycle` operations. The settings envelope's `restart_required` list (:538) shrinks to those keys.
|
||||
|
||||
### S5 implementation notes (post-build)
|
||||
- The table and the four phases live in a new file, `src/web/handlers/apply.zig`, not in `settings.zig`: the upstream RESOURCE handlers need the same prepare/publish/retire, so putting it in the settings handler would have made one handler the other's library. `settings.zig` re-exports `restart_required_keys` and nothing else of it. Add it to the module layout.
|
||||
- Operations are derived from the VALUES, not from the keys the patch named (`changedOperations(before, after)`). The admin form submits every field it read, and treating that as eighteen applies would resize the query log on every save — and refuse the second save with `PreviousResizeDraining`. A key rewritten to what it already was changes nothing, applies nothing, and owes no restart.
|
||||
- `app.zig` now heap-allocates the DNS cache and rate limiter and frees them THROUGH the handler (`replaceCache(io, null)` at teardown): after a `cache.size` apply, what the handler holds is not what boot built. Both are built inside one block whose errdefers end with it, so a boot that fails between the two creations frees them and a boot that fails later leaves them to the handler's teardown defers.
|
||||
- The OpenAPI overview and the three upstream mutation descriptions said restart-required. They now say the change is live, which is what the runtime does and what the `restart_required: false` pin already promised. `docs/reference/api.md` was already correct. `contractSamples.gen.ts` is generated from live responses rather than from the document, so no regeneration followed.
|
||||
- `WebState` gains `upstream_build` (the shared HTTP client, certificate bundle and its lock) and `retention_days`. Without the first, an upstream mutation is a database row and nothing else, which is what a handler test's borrowed owner wants.
|
||||
- `Owner.published` is a plain `u64` under the owner mutex, with `publishedCount(io)`, not an atomic: it is the observable the "one candidate per owner" tests read. An atomic counter in the same struct reproducibly perturbed the S2 test "a replace with no reader holding the live generation retires it through the return path" into failing (~1 run in 1); a mutex-guarded field is both the discipline the rest of `Owner` follows and stable. **That S2 test's sensitivity to unrelated timing is worth a look on its own.**
|
||||
- `mutations.Resource` now accepts a `remove` that returns `error{OutOfMemory}!?Failure`, because a delete that builds a candidate allocates. Both shapes are still checked exactly.
|
||||
- The upstream note rendering moved to `upstream_owner.Rendered`, shared by `app.zig`'s boot replay and the runtime reconciler, so a warning raised at boot and the same warning raised by a write are byte-identical.
|
||||
- Reconciliation reads the new report by RE-ACQUIRING the owner rather than keeping the published pointer: a concurrent second write could retire and free that generation the moment its refs hit zero.
|
||||
- The S2 reconciler test drives its rebuilds through `/api/upstreams`, not `/api/settings`: a settings PUT validates the whole stored configuration and refuses a row malformed enough to produce a build finding, so the finding can never be reached from there.
|
||||
- `logging.file_path` must be absolute, so the integration environment resolves its tmp dir with `Dir.realPath`.
|
||||
- `Plan.prepare` abandons the partly built plan itself on a PROPAGATED error (`errdefer self.abandon(io)`), and only returns a `Failure` for the caller to abandon. An `OutOfMemory` out of the log-directory step used to bypass the caller's `abandon`, leaking whatever earlier owners had built and — because `CertStore` holds `reload_mutex` from its prepare to its publish — wedging every later certificate change and reload.
|
||||
- `Controller.prepare` takes the merged `model.Logging`, not an entry count. Seeding the candidate from the LIVE privacy and flush values was wrong for the one PUT that changes the buffer size and a privacy flag together: publish applies the privacy to the generation being retired, so the replacement went live writing what the operator had just asked to hide.
|
||||
- The `api_limiter` reading is taken in prepare (`Plan.limiter_now`) and used in publish. A clock is I/O, and publish is I/O-free. That reading is STALE by publish time whenever a request served in between refilled a bucket past it, so `setLimits` is monotonic per bucket: a bucket already newer than `now` is clamped to the new capacity and keeps its clock. Rewinding it would let the next request buy the same interval a second time, at the new rate.
|
||||
- `metrics.collect` loads `handler.cache` and `handler.limiter` INSIDE the mutexes that guard them, the same way the request path does: `Plan.retire` frees the displaced object as soon as the swap returns, so a pointer read before the lock can be freed under the reader.
|
||||
- `CertStore.publishPathChange` frees the displaced entry and the old paths BEFORE bumping `reloads`. A `bool` live across an atomic read-modify-write is the Debug-backend miscompile AGENTS.md documents.
|
||||
- Two S5.4 criteria are proven one step short of the wording. `certs_doh`/`certs_dot` assert the store reloaded and the owned paths moved, not a TLS handshake against the new certificate; `dns_policy` asserts `policySnapshot`, which is the read a query performs, not a query. Both are the collaborator's own observable, and neither gap is a claim about a path that is untested.
|
||||
|
||||
### S5.4 Acceptance criteria — one real-PUT test per OPERATION
|
||||
Every operation proven through the real route dispatch → prepare → commit → publish path:
|
||||
- [ ] dns_policy (blocking.ttl live on next query — integration), trusted_proxies, logger_privacy, logger_flush, disk_thresholds, cache (size change swaps, next lookup misses), rate_limiter, sessions_ttl, api_limiter, retention, certs_doh AND certs_dot separately (enabled: handshake serves new cert — integration; disabled: DB-only), log_sink (+ monitor re-point on path change AND on output change both directions), scheduler (via seam), logger_queue (accounting per S4), upstream_generation (row mutation: next exchange uses it, response `restart_required: false`; timeout change: generation counter +1).
|
||||
- [ ] web_lifecycle: a `web.enabled` PUT commits the DB row, sets `restart_pending`, and executes NOTHING (milestone 35 executes it).
|
||||
- [ ] A multi-key PUT touching one owner twice builds ONE candidate (generation counter delta == 1).
|
||||
- [ ] An upstream PUT while G1 is held by an in-flight exchange: publish + diagnostics reconciliation complete on the PUT task; G1 retires later via release; events correct throughout.
|
||||
- [ ] A PUT mixing a live key with a failing prepare writes NOTHING and changes nothing.
|
||||
- [ ] A `bind` key PUT still sets `restart_pending`; nothing else can.
|
||||
- [ ] Contract samples for `/api/config/status`, settings envelope, upstream responses regenerate; route-count pin passes.
|
||||
- [ ] `zig build test` and `zig build test -Dintegration` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S6: admin SPA
|
||||
|
||||
- Per-field "needs restart" tags (SettingsForm.tsx:119-123/:209-223/:267) render only for keys the API lists (bind + web_lifecycle). Banner logic untouched. Upstream flows lose restart messaging; regenerated types carry `restart_required: false`. Live fields say nothing — silence is success. Regenerate goldens (`just goldens`).
|
||||
- [ ] From `admin/`: `npm run typecheck && npm test && npm run lint && npm run format:check && npm run build && npm run assert-bundled` green; bundle under ceiling.
|
||||
- [ ] A test: live-key edit renders no restart affordance; a port edit still does.
|
||||
|
||||
### S6 implementation notes (post-build)
|
||||
- `SettingsForm` needed no change at all. It already built its mark set from `envelope.restart_required` and nothing else, so the twelve-key list arrived and the other fields went quiet on their own. The session's real work was the copy and the tests that had pinned the old answer.
|
||||
- The tests now take the key list from `sample_get_settings.restart_required` in the committed contract sample, re-exported as `RESTART_REQUIRED_KEYS` from the configuration fixtures, rather than from a hand-written array. A server-side change to the set now fails the tests that pin it instead of passing against a stale copy.
|
||||
- One test keeps a hand-written list, `["logging.level"]`: no shipped restart-required key is enum-backed, so the `Select` rendering — where the mark reaches a screen reader through `aria-describedby`, not through the label — has no key left to exercise it. The list is the server's to change and the form must render any key it names, so the branch and its test stay, with the override stated in the test.
|
||||
- `invalidateUpstreams` dropped its `/api/config/status` invalidation. An upstream write rebuilds the pool in the running process, so the status answer after the write is the answer before it, and re-reading it only asserted a claim that is no longer true.
|
||||
- `just goldens` was not run: S5 regenerated the samples and this session changed no server response.
|
||||
|
||||
---
|
||||
|
||||
## Module layout (new/changed)
|
||||
|
||||
`src/upstream/owner.zig` (new); `src/storage/logger_controller.zig` (new); `src/storage/querylog_schema.zig`; `src/cli.zig`; `src/server/handler.zig`; `src/server/query_sink.zig`; `src/web/server.zig`; `src/storage/logger.zig`; `src/storage/disk_monitor.zig`; `src/storage/retention.zig`; `src/server/clients.zig`; `src/platform/logging.zig`; `src/filter/manager.zig`; `src/server/cert_store.zig`; `src/web/auth.zig` + `src/web/handlers/auth.zig`; `src/web/api_limiter.zig`; `src/web/handlers/apply.zig` (new); `src/web/handlers/settings.zig`; `src/web/handlers/upstreams.zig`; `src/web/handlers/certs.zig`; `src/web/metrics.zig`; `src/web/handlers/health.zig`; `src/web/openapi.yaml`; `docs/reference/api.md`; `src/app.zig`; `admin/src/features/configuration/*`.
|
||||
|
||||
## File ownership
|
||||
|
||||
Strictly sequential; each session owns the tree while it runs.
|
||||
|
||||
## Acceptance criteria (milestone complete)
|
||||
|
||||
- [ ] Every settings key except `bind`/`web_lifecycle` applies live through its table-declared operation, each proven by a real-PUT test (S5.4).
|
||||
- [ ] `restart_pending` only from `bind`/`web_lifecycle`; no API response or doc claims a restart for anything else.
|
||||
- [ ] No fallible or I/O work in any publish; every prepare/commit failure leaves DB and runtime unchanged.
|
||||
- [ ] Full gates: `zig build test`, `zig build test -Dintegration`, admin suite from `admin/` incl. `assert-bundled`, `zig fmt --check build.zig src tools`, bundle ceiling.
|
||||
- [ ] Querylog schema fingerprint unchanged (cut's schema-gate takes the equal branch).
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No generic hot-swap framework; one named operation per owner.
|
||||
- No listener rebind, no `web.enabled` execution, no `applied: false` surface, no `restart_pending` removal — milestone 35.
|
||||
- No config file watcher; file mode unchanged. No new config keys; no DB schema changes. Sessions do not become sliding.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Milestone 35: visx charts
|
||||
|
||||
Replace the hand-rolled chart layout math in the admin Overview with visx primitives, and unify the chart components' duplicated plumbing. Owner decision 2026-08-24 after a measured bake-off (recharts +377,355 pre-gzip bytes, nivo +332,686, visx +74,382 against ~103,000 bytes of budget room; visx is the only candidate that fits the 800,000-byte gate). The charts must read as the same charts afterward: same palette, same geometry, same accessible surfaces.
|
||||
|
||||
## Sessions
|
||||
|
||||
One session. Admin SPA plus the license records; no Zig source, no API changes.
|
||||
|
||||
---
|
||||
|
||||
## Session 1: port the Overview charts to visx
|
||||
|
||||
### 1.1 Dependencies and records
|
||||
|
||||
Add to `admin/package.json` under `dependencies` (NOT devDependencies), pinned exact (no `^`): `@visx/scale@4.0.0`, `@visx/shape@4.0.0`, `@visx/group@4.0.0`, `@visx/axis@4.0.0`, `@visx/grid@4.0.0`, `@visx/tooltip@4.0.0`. Nothing else — in particular NOT `@visx/responsive` (the app keeps its own resize hook) and NOT `@visx/text`, `@visx/legend`, `@visx/xychart`.
|
||||
|
||||
Three separate record obligations, each updated from its own evidence, not from this spec's package list:
|
||||
|
||||
1. The sourcemap-derived bundled set that `npm run assert-bundled` checks — regenerate/extend it from the actual build output (expect the transitive `@visx/{bounds,curve,point,text,vendor}` to appear).
|
||||
2. The lockfile-derived npm runtime closure that the Zig drift gate checks — update from the lockfile.
|
||||
3. `licenses/inventory.zon` + `licenses/dependency-identity.txt` + license-text files under `licenses/` for every shipped package. The direct `@visx` packages are MIT; `@visx/vendor` is `MIT AND ISC` (it re-exports ISC-licensed d3 modules) — record that exact expression and include the ISC text.
|
||||
|
||||
### 1.2 Shared chart plumbing — new `admin/src/features/overview/chartKit.tsx`
|
||||
|
||||
Owns what is today duplicated or divergent between `TimeseriesChart.tsx` and `ClientChart.tsx`:
|
||||
|
||||
- `useMeasuredWidth()` — extracted once from the two near-identical hooks. Contract, matching both current hooks: reads `clientWidth` in the mount `useEffect` (NOT `useLayoutEffect` — keep the current timing), observes subsequent changes via `ResizeObserver`, disconnects on cleanup, and falls back to 640 when the measured width is 0 (jsdom). Covered by a test for the zero-width fallback.
|
||||
- A y-scale factory: exported function returning `scaleLinear({ domain: [0, max], range, nice: true })`. The `[0, max]` values in 1.3 are INPUT data domains; nicening mutates them. Consumers pass `numTicks={5}` to `AxisLeft` as a hint. Kit test, exact: input domain `[0, 1780]`, range `[240, 0]` → effective domain `[0, 1800]` and `scale.ticks(5)` equal to `[0, 500, 1000, 1500]`.
|
||||
- Axis wrappers over `AxisBottom`/`AxisLeft` applying the app's tick text styles. Current layout constants are preserved exactly unless named here: chart height 240, margins `{top: 8, right: 8, bottom: 22, left: 44}`. Axis chrome matches today's rendering, not visx defaults: `AxisLeft` hides its axis line and tick marks entirely; `AxisBottom` keeps only the existing baseline — no tick marks. Y labels keep the existing compact-number formatting; x labels keep the existing time formatter driven by `bucket_seconds`. X-label thinning preserves the current algorithm verbatim: `step = max(1, ceil(bucketCount * 90 / plotWidth))`, ticks at indices where `i % step === 0` — kit test with this exact fixture: 168 hourly buckets (ts = 0, 3600, ...) at `plotWidth = 748` → `step = 21`, tick indices `[0, 21, 42, 63, 84, 105, 126, 147]`, tick timestamps `[0, 75600, 151200, 226800, 302400, 378000, 453600, 529200]`. Exact axis attributes (in the spec, not implementer-chosen): `AxisLeft hideAxisLine hideTicks` with tick labels 6px left of the plot edge (current position); `AxisBottom hideTicks tickLength={0}` with `tickLabelProps` `dy="16px"` and `textAnchor="middle"` — the sanctioned baseline+14 → baseline+16 change. Kit test asserts the axis group transform plus the label `y`/`dy` attributes that produce baseline + 16px, and the y-label -6px offset.
|
||||
- `GridRows` and `AxisLeft` MUST share tick positions: compute `yScale.ticks(5)` once and pass the same array as `tickValues` to both (each labeled tick owns its grid line, as today). Test: grid-line y positions equal labeled-tick y positions.
|
||||
- `GridRows`, horizontal only, stroked with the app's existing grid color token.
|
||||
- One tooltip: `useTooltip` + `TooltipWithBounds`, app tokens, no transition. Content contract for both bar charts: the bucket's formatted time, the total, and one row per displayed series with its label, swatch color, and value.
|
||||
|
||||
### 1.3 The three charts
|
||||
|
||||
Port in place; file names, props, and data contracts unchanged, with one exception below (`DonutSlice`). Parents and existing tests keep working.
|
||||
|
||||
- `TimeseriesChart.tsx` — `scaleBand` (x) + the kit y-scale + `BarStack` for blocked/cached/other. Y domain: `[0, max(bucket.queries)]`, and `other = max(0, queries - blocked - cached)` per bucket (test the `blocked + cached > queries` clamp). Band gap: the current chart draws a fixed ~2px gap regardless of bucket count; a constant `paddingInner` cannot do that, so compute it per render: `paddingInner = min(0.5, 2 * bucketCount / plotWidth)`. Segment separator: 1px stroke of the `colors.surface` token, applied only when the bar is wider than 3px (current behavior, TimeseriesChart.tsx:249). Keep the empty-state ("No queries in this period.") exactly; coverage messaging is OWNED BY OverviewPage (via useOverviewWindow), not this component — do not add coverage logic here.
|
||||
- `ClientChart.tsx` — same band/linear scales and `BarStack`, per-client series plus Other. Y domain: `[0, max over buckets of (sum of all client values + other)]` (test it). X domain derived independently from its own props — `data.since + index * data.bucket_seconds` for `data.other.length` buckets — which is equivalent to the timeseries' domain from `buckets[].ts` because the API aligns them; do NOT add cross-chart props or page-level scale coordination. Identical margins/ranges to the timeseries. Gains the shared tooltip and dimming (below).
|
||||
- `Donut.tsx` — `@visx/shape`'s `Pie` for arc generation, our own `<path>` emission. Pie config pins the deleted donutLayout contract: filter `value <= 0` slices before the Pie, sorting disabled (preserve caller order), no pad angle, clockwise from twelve o'clock, outer radius 90, inner radius 54, shares computed from the drawn positive total, and the single-positive-slice full ring must render. Keep the 1px `colors.surfaceRaised` arc outline (an existing test pins it). `DonutSlice` moves: `export interface DonutSlice` from `Donut.tsx`, and `OverviewPage.tsx:28` imports it from `./Donut`.
|
||||
|
||||
Interaction contract (both bar charts, identical): a full-plot-height transparent hit target per bucket; hovered bucket shows the shared tooltip; non-active buckets dim to opacity 0.55; tooltip and dimming clear on pointer leave; ALL bare SVG `<title>` hover text is removed from BOTH charts (the timeseries currently has both a tooltip and `<title>` at TimeseriesChart.tsx:269 — the `<title>`s go).
|
||||
|
||||
Accessibility contract (matches current tests, do not "improve" it): the two bar-chart SVGs KEEP `role="img"` and their `aria-label` (asserted in TimeseriesChart.test.tsx:32 and OverviewPage.test.tsx:382), with the visually-hidden tables as the detailed equivalent; only the donut SVG is `aria-hidden` + `focusable="false"` with the visible legend and hidden table as its surface.
|
||||
|
||||
Color contract: data-series colors are exempt from the token rule, and each chart keeps ITS existing source — they are not unified onto one mapping. TimeseriesChart keeps its fixed category constants exactly as today (including blue `#3b82f6` for Other — NOT `seriesColor(OTHER_KEY)`, which is gray). ClientChart uses `seriesColor(clientKey)` / `seriesColor(OTHER_KEY)`. Donut renders `fill={slice.color}` from its prop contract — never recomputed from `slice.key`; a page-level test separately asserts `OverviewPage` builds slice colors with `seriesColor(...)`. Never rank/order-based anywhere. Everything non-data (axes, grid, text, tooltip chrome, separators) uses StyleX tokens; light/dark themes keep working. No animation anywhere.
|
||||
|
||||
### 1.4 Deletions
|
||||
|
||||
`chartLayout.ts`, `chartLayout.test.ts`, `donutLayout.ts`, `donutLayout.test.ts` are deleted. Their SEMANTIC contracts do not die with them — they move (see 1.5). Only assertions about hand-written path/coordinate output are dropped. Any still-needed helper with no visx equivalent moves into `chartKit.tsx`; nothing imports the deleted modules afterward; no dead exports kept "just in case".
|
||||
|
||||
### 1.5 Tests
|
||||
|
||||
Current reality (do not assume more): the only dedicated chart component suite is `TimeseriesChart.test.tsx`; ClientChart and Donut are covered via `OverviewPage.test.tsx`; identity colors are pinned on the pure mapping and a legend swatch, not on rendered fills.
|
||||
|
||||
- Preserve and port: `TimeseriesChart.test.tsx`, the page-level suites (`OverviewPage.test.tsx` incl. the one-coverage-notice test), the color-mapping tests.
|
||||
- New dedicated suites for `ClientChart` and `Donut`.
|
||||
- Re-home the deleted layout contracts: `other = max(0, queries - blocked - cached)` and zero-data handling (timeseries suite); label thinning (kit suite); zero-slice filtering and caller order may be asserted on Pie inputs/config, but twelve-o'clock clockwise start and the single-positive-slice full ring MUST be asserted on the rendered non-empty `<path>` `d` output (donut suite). Shares: a dedicated donut test with values `3` and `1` asserts rendered shares `75.0%` and `25.0%` in both the legend and the hidden table.
|
||||
- New: rendered `<rect>`/`<path>` fills for a fixed input match each chart's color contract from 1.3 (timeseries constants, ClientChart `seriesColor`, donut `slice.color`).
|
||||
- New: both bar charts show the shared tooltip with the 1.2 content contract on simulated pointer events, dim inactive buckets to 0.55, clear on pointer leave; neither chart contains an SVG `<title>`. The pointer test MUST target the transparent per-bucket overlay rect (not a visible segment) and assert the overlay's `y`/`height` span the full plot height, including the space above a short stack.
|
||||
- New: kit tests — zero-width fallback of `useMeasuredWidth`, the y-scale factory's exact domain and ticks for max=1780, the pinned x-label `dy`.
|
||||
|
||||
### 1.6 Acceptance criteria
|
||||
|
||||
Run from `admin/` with npm resolved via mise (`PATH="$HOME/.local/share/mise/shims:$PATH"` or `mise exec -- npm ...`):
|
||||
|
||||
- [ ] `npm test` exit 0
|
||||
- [ ] `npm run typecheck`, `npm run lint`, `npm run format:check` exit 0
|
||||
- [ ] `npm run build` exit 0; bundle stays under the unchanged 800,000-byte gate (expected ≈ 770,000)
|
||||
- [ ] `npm run assert-bundled` exit 0 with the updated ledger
|
||||
- [ ] `! rg 'chartLayout|donutLayout' admin/src` succeeds, and `test ! -e admin/src/features/overview/chartLayout.ts && test ! -e admin/src/features/overview/chartLayout.test.ts && test ! -e admin/src/features/overview/donutLayout.ts && test ! -e admin/src/features/overview/donutLayout.test.ts` succeeds
|
||||
- [ ] `zig build test` exit 0 from the repo root (no-breakage check; the license/ledger updates are inside it)
|
||||
|
||||
## File Ownership
|
||||
|
||||
Session 1 owns: `admin/package.json`, `admin/package-lock.json`, the bundled-set and runtime-closure ledgers, `licenses/inventory.zon`, `licenses/dependency-identity.txt`, new license-text files under `licenses/`, `admin/src/features/overview/*` (TimeseriesChart, ClientChart, Donut, their tests, chartKit new, chartLayout/donutLayout + tests deleted), the one-line `DonutSlice` import in `OverviewPage.tsx`, `specs/milestone-35.md` (implementation notes).
|
||||
|
||||
## Anti-Requirements
|
||||
|
||||
- No direct dependency on, or application import from, `@visx/responsive`, `@visx/legend`, `@visx/text`, `@visx/xychart` (primitives only). `@visx/text` arriving transitively through `@visx/axis` is expected and fine.
|
||||
- No animation, no react-spring, no transition on the tooltip.
|
||||
- No new chart types, no visual redesign beyond the sanctioned x-label `dy` fix and the band-gap formula. The charts should read as the same charts.
|
||||
- No budget raise; no changes to `assert-bundle-size.mjs`.
|
||||
- No accessibility "upgrades" beyond the stated contract (bar SVGs keep `role="img"`).
|
||||
- No Zig source changes, no API changes, nothing outside `admin/`, `licenses/`, and this spec.
|
||||
|
||||
### Session 1 implementation notes (post-build)
|
||||
|
||||
- Bundle: `admin/dist/assets` is **776,477 bytes** across 40 files, 23,523 under the unchanged 800,000-byte gate. `assert-bundle-size.mjs` untouched.
|
||||
- `DonutSlice` now lives in `Donut.tsx`; `layoutDonut`/`layoutTimeseries`/`layoutStacked`/`niceTicks`/`isEmptyTimeseries` are gone, not re-homed. The empty-timeseries check is one `every` in `TimeseriesChart`; tick generation is `scale.ticks(5)`.
|
||||
- `chartKit.tsx` exports `plotArea`, `useMeasuredWidth`, `valueScale`/`valueTicks`, `bandScale`/`bandPaddingInner`, `labelTickValues`, `formatBucketTime`, `EmptyChart`, `ChartRoot`, `ChartFrame`, `StackSegment`, `BucketOverlay`, `slotCenter`, `useActiveBucket`, `ChartTooltip`. The two charts share all of it; nothing is exported that only one caller uses.
|
||||
- **Divergence — the bundled set.** 1.1 predicted `@visx/{bounds,curve,point,text,vendor}` transitively. The sourcemap build shows `@visx/{bounds,point,text}` but NOT `@visx/curve` (nothing here draws a curve) and NOT `@visx/vendor`: it is a re-export shim and rollup resolves straight through it to the d3 packages. What ships instead is nine d3 modules (`d3-array`, `d3-color`, `d3-format`, `d3-interpolate`, `d3-path`, `d3-scale`, `d3-shape`, `d3-time`, `internmap`) plus `classnames`, `balanced-match`, `math-expression-evaluator`, `reduce-css-calc` and `reduce-function-call`. The ledger was regenerated from that evidence, as 1.1 directs.
|
||||
- **Divergence — a Zig source change was unavoidable.** The anti-requirement forbids Zig changes, but record obligation 2 is enforced by `src/licenses_drift_test.zig`, and `zig build test` (1.6) cannot pass without it: the nine shipped d3 modules are ISC and `robust-predicates` is Unlicense, so `npm_licence_exceptions` needs their entries, and the 23 newly-in-closure packages that ship nothing need `npm_not_shipped` entries. Only those two tables changed; no production Zig was touched.
|
||||
- **ISC is new to this project.** `licenses/d3-isc.txt` reproduces all nine copyright notices above the single permission text they share, and the inventory entry records the acceptance as Mokhtar Mial, 2026-08-24, on this milestone's ruling. That acceptance is inferred from this spec ordering the ISC text to be carried; confirm it.
|
||||
- **Known gap in the drift guard.** `parseRecordedPackage` reads exactly three whitespace-separated tokens, so the lockfile's `@visx/vendor 4.0.0 MIT and ISC` is checked as `MIT` and its ISC half passes unreviewed. `@visx/vendor` ships nothing today, so nothing turns on it; it is written down rather than fixed because fixing it is a guard change, not this milestone.
|
||||
- Fourteen `@types/*` packages and `csstype` are in the npm *runtime* closure now, not because anything changed about them but because the `@visx` packages declare `@types/react` and `@types/d3-*` as ordinary `dependencies`. They emit no runtime code and are recorded as not shipped.
|
||||
- Axis geometry: `AxisBottom` also takes `tickLength={0}` (1.2 named it) and `AxisLeft` takes it too, which 1.2 did not — without it visx offsets the value labels by its default 8px tick length and the spec's "6px left of the plot edge" is unreachable. `AxisLeft` further takes `dy: 0` to cancel visx's own `0.25em` nudge, which would double up with the `dominantBaseline: "middle"` the current chart centres its labels with.
|
||||
- Both axes render tick labels through a `tickComponent` rather than visx's `<Text>`. `Ticks` derives a label's `y` from a *guessed* font size (`Math.max(10, …)`) because the real size comes from a StyleX class it cannot read, and `<Text>` wraps every label in a nested `<svg>`. The custom component drops the guessed `y` and places the label with the axis group transform plus `dy`, which is what makes `dy="16px"` mean baseline + 16px exactly.
|
||||
- `Pie` skips its own `top`/`left` group when given a render prop, so `Donut` centres the ring in a `<Group>` of its own. The old `fillRule="evenodd"` is gone: d3's arc paths are correctly wound and no longer need it.
|
||||
- Band gap: `paddingInner = min(0.5, 2 * n / plotWidth)` yields a gap of `step - bandwidth` ≈ 2.006px rather than exactly 2px, because d3 derives `step` from `n - paddingInner`. Bars also start 1px left of where the hand-rolled layout put them (it centred a `slotWidth - 2` bar inside the slot; a band scale left-aligns). Both are sub-pixel-scale and the charts read the same.
|
||||
- Tooltip: `TooltipWithBounds` drops its own positioning transform when `unstyled` is set, so the default look is replaced by passing an empty `style` object and the app's StyleX class instead.
|
||||
- Tests: **507** pass across 49 files (was 458 across 46). New suites `chartKit.test.tsx` (10), `ClientChart.test.tsx` (15), `Donut.test.tsx` (9); `TimeseriesChart.test.tsx` grew from 2 to 16; `OverviewPage.test.tsx` gained the donut-slice-colour test. The counts above 493 came from the two review passes.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0, `npm run typecheck` 0, `npm run lint` 0, `npm run format:check` 0, `npm run build` 0, `npm run assert-bundled` 0; `zig build test` 0 from the repo root.
|
||||
|
||||
### Session 1 review-pass notes (post-build)
|
||||
|
||||
Ten findings from the implementation review, all fixed. Each fix was mutation-checked: the guarding test was re-run against a deliberately broken version to confirm it fails. One finding did not survive that check and is reported as such.
|
||||
|
||||
- **Tooltip lifecycle (behaviour change).** `useTooltip` is gone from both charts. It stored the hovered bucket's *numbers and screen coordinates*, which decoupled them from the data: a 30-second poll left last minute's counts under the pointer, and a resize left the tooltip at coordinates that no longer described anything. Both charts now keep only the hovered bucket **index**, in a new `useActiveBucket(windowKey)` hook, and read the values and the x out of the render they are currently drawing. `windowKey` is `since:bucket_seconds:bucketCount:width`; a selection made against an old key is deleted, so a rolling window or a resize retires the tooltip instead of relabelling it. A same-window refresh updates the open tooltip in place, which is the better of the two behaviours the review allowed.
|
||||
- **Tooltip is keyed by bucket.** `withBoundingRects` measures its node once, in `componentDidMount`, and never again, so one shared mount placed every bucket with the first bucket's measured size — the flip-and-clip case at the right-hand edge. `key={bucket}` gives each bucket its own mount and its own measurement. jsdom reports every rect as zero, so the test asserts the remount (the node identity changes between buckets), not the measurement.
|
||||
- **Tooltip offsets (behaviour change).** visx's 10px `offsetLeft`/`offsetTop` defaults are replaced by an explicit 8px, restoring the hand-rolled tooltip's placement: 8px down from the chart's top edge, 8px to the side of the slot. `top` is now 0 with the offset supplying the 8, rather than `plot.y` coincidentally being 8.
|
||||
- **Overlay clamp (behaviour change).** A band scale spends a trailing gap after the last column, so a full-step hit target on the last bucket reached ~2px into the right margin. The last slot is now clipped to `plot.x + plot.width`. The tooltip's x is the **slot** centre (`band start + step/2`), not the narrower band centre it was using.
|
||||
- The x-axis tick labels no longer carry `tabularNums`; the hand-rolled x labels never had it and adding it was an unsanctioned change. The y-axis labels keep it, as they always had it.
|
||||
- Tests added or tightened: the clamp test now pins the y-domain source (asserting the top tick is `10`, so a switch to the stacked sum's 13 fails); `bandPaddingInner` is pinned at `(24, 1388)` and at its 0.5 cap; the value-axis test asserts the rendered `dy="0"` and `dominant-baseline="middle"`; the donut ring test parses the `A` command radii and asserts `[90, 90, 54, 54]`; both charts gained overlay-clamp, tooltip-offset, per-bucket-remount, same-window-refresh and rolled-window tests.
|
||||
- **One finding could not be closed as stated.** The donut caller-order fixture is now `[1, 3]` (ascending) as directed, and it does pin that the ring is drawn in caller order. It does **not** pin `pieSort={null}`/`pieSortValues={null}`: removing both props leaves the rendered output byte-identical, because `@visx/shape` 4.0.0's own `pie()` factory already calls `sortValues(null)` when neither prop is given. The props are kept anyway and the comment now says why — visx carries that default precisely because d3-shape v3 flipped `sortValues` to descending underneath it, so the props are what stop a future bump from silently re-ranking the ring. No test can distinguish them at this version.
|
||||
|
||||
### Session 1 residual-pass notes (post-build)
|
||||
|
||||
Three residuals from the re-review, all fixed and mutation-checked.
|
||||
|
||||
- **A retired selection is deleted, not masked (behaviour change).** `useActiveBucket` only compared the stored key against the current one, so the selection survived in state and a *returning* key revived it: resize away from 640 and back, or a poll that restores a bucket count, redrew a tooltip and its dimming with no pointer entry. The hook now drops the selection during the render that changes the key — the React "adjust state on prop change" pattern, not an effect, so no tooltip is committed and then removed. The rolled-window test rolls away and back and asserts nothing returns.
|
||||
- **The tooltip remeasures on content change as well as on bucket change.** `key={bucket}` covered moving between buckets but not the same bucket changing width under a same-window refresh: a count crossing a digit boundary, or a client name resolving. The key is now `bucket|total|label=value…`, so any content that could change the measured width forces a fresh mount. The test asserts DOM identity changes when a hovered bucket's numbers change.
|
||||
- The implementation notes above were stale on bytes, test count and the `chartKit` export list; all three are corrected to the figures verified in this pass.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0 (507 tests, 49 files), `npm run typecheck` 0, `npm run lint` 0, `npm run format:check` 0, `npm run build` 0 (776,477 bytes), `npm run assert-bundled` 0 (40 packages, unchanged); `zig build test` 0 from the repo root. No commits.
|
||||
|
||||
### Session 1 owner-review addendum (post-build)
|
||||
|
||||
Three changes from the owner's visual review of the live charts. All three are
|
||||
behaviour changes, not corrections, and each was mutation-checked.
|
||||
|
||||
- **The client chart drops "Other" when it counted nothing.** `ClientChart` leaves
|
||||
the aggregate out of the legend, the stack, the tooltip and the hidden table
|
||||
when `data.other` is all zeroes; the named clients stay at zero. The hidden
|
||||
table drops the column with the rest rather than keeping a zero column, so all
|
||||
four surfaces agree; a test pins that choice. This reverses `ClientChart`'s
|
||||
previous documented rule that "Other" is always present, and the
|
||||
`OverviewPage` test that pinned it is inverted accordingly.
|
||||
**`TimeseriesChart` does not do this** — see the ruling below.
|
||||
- **`TimeseriesChart`'s third series is labelled "Allowed".** The series key stays
|
||||
`other` and the colour mapping is untouched — only the displayed label changes,
|
||||
in the legend, the tooltip row, the hidden table header and the SVG
|
||||
`aria-label`, which is now built from the shown series rather than hard-coded.
|
||||
- **The donuts gain the bar charts' hover treatment.** Pointing at a slice path
|
||||
opens the shared tooltip (label, count in the panel's unit, and the same share
|
||||
the legend prints) and dims the other slices to 0.55; leaving the ring clears
|
||||
both. The ring stays `aria-hidden` and the legend plus hidden table remain the
|
||||
accessible surface. The slice path is the hit target; no overlay was added.
|
||||
|
||||
Supporting refactors:
|
||||
|
||||
- `useActiveBucket` is renamed `useActiveIndex`: it now tracks a slice as well as
|
||||
a bucket. Its `windowKey` for the donut is the drawn slices' keys, so a changed
|
||||
slice set retires the hover.
|
||||
- `BucketTooltipData` is replaced by `TooltipContent { title, rows }`, with
|
||||
`TooltipRow.value` a string and `TooltipRow.color` optional. The bar charts now
|
||||
pass their own formatted title and an explicit "Queries" total row, where
|
||||
`ChartTooltip` previously hard-coded both — which is what let the donut, whose
|
||||
tooltip has no timestamp and no total, reuse it unchanged. `ChartTooltip` also
|
||||
takes an optional `top`, which the donut uses and the bar charts leave at 0.
|
||||
- `Donut.sliceAnchor()` computes a slice's mid-arc point from the slice values.
|
||||
It restates the `Pie` configuration (clockwise from twelve o'clock, caller
|
||||
order, no pad angle) rather than reading the drawn path, so a test pins the
|
||||
resulting transform against the ring's real geometry.
|
||||
|
||||
- Tests: **515** pass across 49 files (was 507). New: two timeseries tests (the
|
||||
rename, and the series staying at zero), two client-chart tests for the hiding,
|
||||
four donut hover tests. Bundle: `admin/dist/assets` is **776,995 bytes** across
|
||||
40 files, 23,005 under the 800,000-byte gate.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0, `npm run typecheck` 0,
|
||||
`npm run lint` 0, `npm run format:check` 0, `npm run build` 0,
|
||||
`npm run assert-bundled` 0; `zig build test` 0 from the repo root. No commits.
|
||||
|
||||
**Ruling (2026-08-24).** The review's item 1 originally applied the hiding to both
|
||||
bar charts. It was implemented that way, then reverted for the timeseries after
|
||||
the build agent raised the incoherence and the lead ruled with it: item 2's own
|
||||
reasoning is that the third series is a real category — queries answered
|
||||
upstream, locally, or from a forward zone — which is why it stops being called
|
||||
"Other"; hiding it at zero then contradicts that, and item 1's other half ("a
|
||||
zero Blocked is information") applies to it just as much. A zero Allowed says
|
||||
every query in the window was blocked or served from cache, which is a state
|
||||
worth reading, not an empty bucket.
|
||||
|
||||
The rule that survives: **hide an aggregate that aggregated nothing; never hide a
|
||||
named category.** `ClientChart`'s "Other" is the only aggregate on the Overview,
|
||||
so it is the only series that disappears. `TimeseriesChart` keeps Blocked, Cached
|
||||
and Allowed at all times, pinned by a test.
|
||||
|
||||
The other two open items are closed: `pieSort`/`pieSortValues` stay as
|
||||
version-proofing with their comment (lead's ruling), and the ISC acceptance line
|
||||
in `licenses/inventory.zon` rests with the owner.
|
||||
@@ -54,3 +54,18 @@ Pure functions unit-tested: semver validation (accept/reject table incl. leading
|
||||
- [ ] `just --list` shows the recipes; `just verify` passes locally.
|
||||
- [ ] `zig build cut -- patch` derives the next version and refuses in preflight on a dirty tree or a missing changelog section, mutating nothing; `zig build cut -- 0.0.9` and `-- banana` refuse naming the three kinds.
|
||||
- [ ] `zig build test` and `-Dintegration` 0 failed; `zig fmt --check` clean.
|
||||
|
||||
## Addendum: the schema gate (post-0.0.9)
|
||||
|
||||
0.0.9 changed the `query_log` DDL and its announcement said nothing about it. `querylog.db` is never migrated: the server stamps `PRAGMA user_version` with a CRC32 of the DDL text, and on a mismatch it renames the file aside and creates an empty one, so the first start after such a release destroys the operator's query history. Nothing in the cut noticed, because nothing in the cut had ever read the schema.
|
||||
|
||||
`schema-gate` is a read-only preflight check beside the others. It compares releases, not commits:
|
||||
|
||||
1. `git ls-remote --tags origin`, and the highest `vMAJOR.MINOR.PATCH` strictly below the version being cut is the previous release. Strictly below, because a rerun may already see the tag it is cutting. What is kept is the OBJECT ID origin published for that tag — the peeled `^{}` commit where there is one — not the tag name: a local tag of the same name can be stale or replaced, and reading its tree would compare against a schema origin never shipped, which passes silently whenever that schema happens to match this one. No such tag PASSES trivially — a first release has nothing to compare against.
|
||||
2. `git show <oid>:src/storage/querylog_schema.zig`, and `extractDdl` recovers the `ddl` constant from that source the way the compiler reads a multiline string: the lines after `pub const ddl: [:0]const u8 =` that begin with `\\`, stripped of indentation and the `\\`, joined with newlines, ending at the `;`. Blank lines and `//` comments may appear before, between and after the `\\` lines and contribute nothing, exactly as the compiler treats them. A test applies the same function to the file on disk and asserts the result fingerprints to `querylog_schema.fingerprint` — that equality is what makes the text scan trustworthy.
|
||||
3. The old DDL goes through `querylog_schema.fingerprintOf`, factored out of the comptime `fingerprint` so the gate and the server share one hash rather than two copies of one expression. The tool imports the schema module (build.zig, `querylog_schema_mod`); only these two decls are referenced, so no SQLite symbol comes with them.
|
||||
4. Equal fingerprints PASS. Different fingerprints require the `## [<v>]` changelog section to contain the literal phrase `resets your query history`; present PASSES, absent is a soft FAIL naming both fingerprints, the phrase and what the change costs.
|
||||
|
||||
Every step that cannot answer — the `ls-remote`, the `git show`, the extraction, an unreadable CHANGELOG.md — is a soft FAIL naming the step. A gate that does not know whether the schema moved must never report that it did not.
|
||||
|
||||
Fixing a FAIL is a sentence in the changelog, not a flag: there is no override, because the only thing the gate asks for is that the release notes be true.
|
||||
|
||||
+215
-298
@@ -28,7 +28,6 @@ const Allocator = std.mem.Allocator;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const Writer = std.Io.Writer;
|
||||
const net = std.Io.net;
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
const api_limiter = @import("web/api_limiter.zig");
|
||||
const auth = @import("web/auth.zig");
|
||||
@@ -40,9 +39,7 @@ const config_export = @import("config/export.zig");
|
||||
const db = @import("storage/db.zig");
|
||||
const disk_monitor = @import("storage/disk_monitor.zig");
|
||||
const dns_cache = @import("cache/dns_cache.zig");
|
||||
const doh_client = @import("upstream/doh_client.zig");
|
||||
const doh_server = @import("server/doh_server.zig");
|
||||
const dot_client = @import("upstream/dot_client.zig");
|
||||
const dot_server = @import("server/dot_server.zig");
|
||||
const events = @import("storage/events.zig");
|
||||
const faults = @import("config/faults.zig");
|
||||
@@ -53,26 +50,25 @@ const http_util = @import("web/http_util.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const local_records = @import("local/records.zig");
|
||||
const local_tables = @import("server/local_tables.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
const logger_controller = @import("storage/logger_controller.zig");
|
||||
const logging = @import("platform/logging.zig");
|
||||
const manager_mod = @import("filter/manager.zig");
|
||||
const migrations = @import("storage/migrations.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const pause = @import("server/pause.zig");
|
||||
const pool_mod = @import("upstream/pool.zig");
|
||||
const queries_repo = @import("storage/repositories/queries_repo.zig");
|
||||
const query_sink = @import("server/query_sink.zig");
|
||||
const querylog_schema = @import("storage/querylog_schema.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const reconcile = @import("config/reconcile.zig");
|
||||
const retention_mod = @import("storage/retention.zig");
|
||||
const safe_url = @import("safe_url.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
const sse = @import("web/sse.zig");
|
||||
const static = @import("web/static.zig");
|
||||
const tcp_server = @import("server/tcp_server.zig");
|
||||
const transport = @import("upstream/transport.zig");
|
||||
const udp_server = @import("server/udp_server.zig");
|
||||
const upstream_owner = @import("upstream/owner.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const version = @import("version.zig");
|
||||
const web_server = @import("web/server.zig");
|
||||
@@ -89,11 +85,6 @@ const maintenance_interval_s = 60;
|
||||
/// takes longer than this is not going to finish at all.
|
||||
const download_budget_s = 300;
|
||||
|
||||
/// 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: {
|
||||
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
||||
@@ -220,17 +211,13 @@ fn reconcileFromFileAt(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// An upstream's identity is its url: the whole url is the key, and the
|
||||
/// redaction is the label, because a url can carry an account token.
|
||||
/// Boot's replay of one upstream finding. The rendering is the owner's, shared
|
||||
/// with the runtime reconciler so a warning raised at boot and the same warning
|
||||
/// raised by a settings PUT are byte-identical.
|
||||
fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void {
|
||||
var label_buf: [events.Store.max_subject_label_len]u8 = undefined;
|
||||
const label = std.fmt.bufPrint(&label_buf, "{f}", .{safe_url.redact(url)}) catch &label_buf;
|
||||
var detail_buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&detail_buf, "upstream {f} {s}", .{
|
||||
safe_url.redactQuoted(url),
|
||||
message,
|
||||
}) catch &detail_buf;
|
||||
config_load.note(url, label, detail);
|
||||
var rendered: upstream_owner.Rendered = .{};
|
||||
rendered.render(.{ .url = url, .message = message });
|
||||
config_load.note(url, rendered.label, rendered.detail);
|
||||
}
|
||||
|
||||
/// Returns the moment the transaction committed, which is what the settings
|
||||
@@ -529,49 +516,50 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer bundle.deinit(gpa);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
var upstreams = try Upstreams.build(io, gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
|
||||
defer upstreams.deinit(io, gpa);
|
||||
|
||||
var pool: pool_mod.Pool = .init(
|
||||
upstreams.active(),
|
||||
.{},
|
||||
.{
|
||||
const upstream_generation = try upstream_owner.build(.{
|
||||
.gpa = gpa,
|
||||
.io = io,
|
||||
.servers = cfg.upstreams,
|
||||
.http = &dns_http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
|
||||
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
|
||||
},
|
||||
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
);
|
||||
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
.diagnostics = event_store,
|
||||
});
|
||||
|
||||
pool.diagnostics = event_store;
|
||||
// `build` writes no event rows, so that a settings PUT can prepare a
|
||||
// candidate that is never published without leaving a trace. Boot has no
|
||||
// such candidate: this generation is the one that serves, and replaying its
|
||||
// report through the collector is what keeps the `configuration.load`
|
||||
// episodes — and the keys `finalize` below spares — exactly what they were
|
||||
// when this composition lived in this file.
|
||||
for (upstream_generation.report().notes) |finding| {
|
||||
noteUpstream(&config_load, finding.url, finding.message);
|
||||
}
|
||||
const upstream_count = upstream_generation.activeCount();
|
||||
|
||||
var upstreams: upstream_owner.Owner = .init(upstream_generation);
|
||||
defer upstreams.deinit(io);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// per-query state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
|
||||
defer cache.deinit();
|
||||
|
||||
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
|
||||
.limit = cfg.dns.rate_limit,
|
||||
.window_seconds = cfg.dns.rate_window_seconds,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
var paused: pause.Pause = .{};
|
||||
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
|
||||
// One cell for both retention consumers, owned here so a settings apply
|
||||
// moves the daily query-log prune and the stale-client prune together.
|
||||
var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days);
|
||||
var tracker: clients.Tracker = .init(&retention_days);
|
||||
tracker.diagnostics = event_store;
|
||||
// Naming rides the tracker's pass, on the tracker's task and connection
|
||||
// (milestone-25 ruling 1), and reads the live forward zones.
|
||||
var client_names_resolver: client_names.Resolver = .init(&tables);
|
||||
client_names_resolver.diagnostics = event_store;
|
||||
|
||||
// The queue holds waiting tasks in intrusive lists, so neither the buffer
|
||||
// nor the `Logger` may move once a task has touched either.
|
||||
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
|
||||
defer gpa.free(queue_buf);
|
||||
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
|
||||
query_logger.diagnostics = event_store;
|
||||
|
||||
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
|
||||
// exists only when the web interface does (ruling 6) — without it the sink
|
||||
// costs the query path one null check. Its rings are ~900 KiB, so it lives
|
||||
@@ -584,18 +572,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
created.init();
|
||||
hub = created;
|
||||
}
|
||||
var sink: query_sink.QuerySink = .init(&query_logger, hub);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// disk, retention and the remaining connections (ruling 21)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const data_path = try arena.dupeZ(u8, paths.data_dir);
|
||||
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
|
||||
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
|
||||
const log_dir_path: ?[:0]const u8 = if (logging.logDirname(cfg.logging)) |dir|
|
||||
try arena.dupeZ(u8, dir)
|
||||
else
|
||||
null;
|
||||
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
|
||||
// Frees whatever log-directory generation a settings apply installed; boot's
|
||||
// path is borrowed from the arena and owned by nobody here.
|
||||
defer monitor.deinit(io);
|
||||
|
||||
// Ruling 17. The scheduler consults it before every scheduled pass; the
|
||||
// startup `reload` below is an operator action and stays ungated.
|
||||
@@ -618,13 +608,31 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// policy and the right one here too.
|
||||
monitor.sample(io, event_store, boot_now_s);
|
||||
|
||||
var retention: retention_mod.Retention = .init(cfg.logging);
|
||||
var retention: retention_mod.Retention = .init(&retention_days);
|
||||
|
||||
var querylog_opened = try data.openQuerylogDb(io);
|
||||
var querylog_writer_db = querylog_opened.database;
|
||||
defer querylog_writer_db.close();
|
||||
|
||||
reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);
|
||||
|
||||
// The controller adopts that first connection and owns the query logger
|
||||
// from here: the buffer, the `Logger`, the writer task and the connection
|
||||
// are one generation, and `logging.query_log_buffer_max` can replace all
|
||||
// four while the server runs (milestone 34 §S4). Its writer starts now and
|
||||
// parks on an empty queue, which is where it would be anyway — no producer
|
||||
// exists until the listeners below start serving.
|
||||
var log_controller: logger_controller.Controller = undefined;
|
||||
try log_controller.init(io, .{
|
||||
.gpa = gpa,
|
||||
.database = querylog_writer_db,
|
||||
.source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path },
|
||||
.logging = cfg.logging,
|
||||
.monitor = &monitor,
|
||||
.diagnostics = event_store,
|
||||
});
|
||||
defer log_controller.deinit(io);
|
||||
var sink: query_sink.QuerySink = .init(&log_controller, hub);
|
||||
|
||||
var querylog_retention_db = try data.reopenQuerylogDb(io);
|
||||
defer querylog_retention_db.close();
|
||||
var tracker_db = try data.openConfigDb(io);
|
||||
@@ -683,20 +691,56 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// listeners
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Both are on the heap and both are freed through the handler rather than
|
||||
// through this frame: a `cache.size` or `dns.rate_limit` change swaps in a
|
||||
// replacement built with this same `gpa` and frees what it displaced, so
|
||||
// what the handler holds at shutdown is not necessarily what boot built.
|
||||
// Both are built inside one block so their errdefers end with it: after
|
||||
// the block the handler is the sole owner, and the teardown defers below
|
||||
// are what free them. An errdefer that outlived the block would free the
|
||||
// same object those defers free.
|
||||
const both = blk: {
|
||||
const c = try gpa.create(dns_cache.DnsCache);
|
||||
errdefer gpa.destroy(c);
|
||||
c.* = try .init(gpa, cfg.cache);
|
||||
errdefer c.deinit();
|
||||
|
||||
const l = try gpa.create(rate_limiter.RateLimiter);
|
||||
errdefer gpa.destroy(l);
|
||||
l.* = try .init(gpa, .{
|
||||
.limit = cfg.dns.rate_limit,
|
||||
.window_seconds = cfg.dns.rate_window_seconds,
|
||||
});
|
||||
break :blk .{ .cache = c, .limiter = l };
|
||||
};
|
||||
const cache = both.cache;
|
||||
const limiter = both.limiter;
|
||||
|
||||
var h: handler.Handler = .{
|
||||
.upstream = pool.client(),
|
||||
.upstream = &upstreams,
|
||||
.policy = .{
|
||||
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
||||
.ecs_mode = cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
},
|
||||
.manager = &manager,
|
||||
.local_tables = &tables,
|
||||
.cache = &cache,
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
.limiter = &limiter,
|
||||
.cache = cache,
|
||||
.limiter = limiter,
|
||||
.sink = &sink,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
};
|
||||
// These run after `group.cancel` below, so no query is inside either table.
|
||||
defer if (h.replaceCache(io, null)) |live| {
|
||||
live.deinit();
|
||||
gpa.destroy(live);
|
||||
};
|
||||
defer if (h.replaceRateLimiter(io, null)) |live| {
|
||||
live.deinit();
|
||||
gpa.destroy(live);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DoH/DoT listeners (milestone-10 ruling 11)
|
||||
@@ -770,9 +814,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// The live hash may own a gpa replacement after a settings PUT; this defer
|
||||
// runs after `group.cancel` below, so no web task can still read it.
|
||||
defer web_state.live_hash.deinit(gpa);
|
||||
// Same argument as the live hash: a settings PUT may have installed an
|
||||
// owned generation, and this runs after `group.cancel`.
|
||||
defer web_state.proxies.deinit(gpa);
|
||||
if (cfg.web.enabled) web_state = .{
|
||||
.gpa = gpa,
|
||||
.web = cfg.web,
|
||||
.proxies = .init(cfg.web.trusted_proxies),
|
||||
.authority = authority,
|
||||
.reconciled_at = reconciled_at,
|
||||
.live_hash = .init(cfg.web.password_hash orelse ""),
|
||||
@@ -781,11 +829,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
.tracker = &tracker,
|
||||
.client_names = &client_names_resolver,
|
||||
.manager = &manager,
|
||||
.pool = &pool,
|
||||
.upstreams = &upstreams,
|
||||
.upstream_build = .{
|
||||
.http = &dns_http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
},
|
||||
.monitor = &monitor,
|
||||
.local_tables = &tables,
|
||||
.logger = &query_logger,
|
||||
.logger = &log_controller,
|
||||
.retention = &retention,
|
||||
.retention_days = &retention_days,
|
||||
.sessions = if (sessions) |*s| s else null,
|
||||
.limiter = if (web_limiter) |*l| l else null,
|
||||
.hub = hub,
|
||||
@@ -894,25 +948,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
|
||||
const gate: ?*disk_monitor.Monitor = &monitor;
|
||||
|
||||
// The query-log writer is deliberately *not* in `group`, and starts before
|
||||
// every producer. Inside the group its life would end with the same
|
||||
// `cancel` that stops the producers, and cancellation would race the
|
||||
// queue's close: whichever landed first decided whether the batch the
|
||||
// writer was holding reached the database or was counted as dropped. Given
|
||||
// its own future, it outlives the producers by construction, and the
|
||||
// teardown below can close the queue with nobody left to fill it and then
|
||||
// wait for the writer to finish emptying it.
|
||||
var writer_future = try io.concurrent(
|
||||
logger_mod.Logger.runWriter,
|
||||
.{ &query_logger, io, &querylog_writer_db, gate },
|
||||
);
|
||||
|
||||
// The query-log writers are deliberately *not* in `group`, and the live one
|
||||
// started before every producer. Inside the group their lives would end
|
||||
// with the same `cancel` that stops the producers, and cancellation would
|
||||
// race the queue's close: whichever landed first decided whether the batch
|
||||
// a writer was holding reached the database or was counted as dropped. The
|
||||
// controller owns their futures instead, so they outlive the producers by
|
||||
// construction.
|
||||
//
|
||||
// Ruling 4's shutdown order, on the one path every exit from here takes:
|
||||
// every producer stops and is joined, then the queue closes, then the
|
||||
// writer is awaited — so the last batch is written rather than raced. A
|
||||
// writer the disk gate will not let write counts its batch as dropped
|
||||
// instead of holding the exit open (`logger.zig`), so this wait always
|
||||
// ends.
|
||||
// every producer stops and is joined, then `Controller.shutdown` closes
|
||||
// each queue and awaits each writer — so the last batch is written rather
|
||||
// than raced. A writer the disk gate will not let write counts its batch as
|
||||
// dropped instead of holding the exit open (`logger.zig`), so this wait
|
||||
// always ends.
|
||||
//
|
||||
// A `defer` and not straight-line code after `shutdown.wait`, because a
|
||||
// `concurrent` spawn below can fail with the DNS listeners already
|
||||
@@ -920,8 +969,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// signal gets.
|
||||
defer {
|
||||
group.cancel(io);
|
||||
query_logger.shutdown(io);
|
||||
writer_future.await(io) catch {};
|
||||
log_controller.shutdown(io);
|
||||
}
|
||||
|
||||
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||
@@ -946,7 +994,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// this box exists for — keeps serving.
|
||||
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
|
||||
|
||||
logStartup(io, authority, &manager, upstreams.active().len, .{
|
||||
logStartup(io, authority, &manager, upstream_count, .{
|
||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||
@@ -1121,18 +1169,21 @@ fn maintenanceOnce(
|
||||
api: ?*api_limiter.ApiLimiter,
|
||||
io: std.Io,
|
||||
) std.Io.Cancelable!void {
|
||||
if (h.cache) |cache| {
|
||||
// Both pointers are read inside the mutex that guards their replacement,
|
||||
// the same discipline the query path follows: a load taken before the lock
|
||||
// could sweep a table `replaceCache`/`replaceRateLimiter` has just freed.
|
||||
{
|
||||
const now_s = std.Io.Clock.real.now(io).toSeconds();
|
||||
try h.cache_mutex.lock(io);
|
||||
_ = cache.sweep(now_s);
|
||||
h.cache_mutex.unlock(io);
|
||||
defer h.cache_mutex.unlock(io);
|
||||
if (h.cache) |cache| _ = cache.sweep(now_s);
|
||||
}
|
||||
|
||||
if (h.limiter) |limiter| {
|
||||
{
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
try h.limiter_mutex.lock(io);
|
||||
_ = limiter.sweep(now);
|
||||
h.limiter_mutex.unlock(io);
|
||||
defer h.limiter_mutex.unlock(io);
|
||||
if (h.limiter) |limiter| _ = limiter.sweep(now);
|
||||
}
|
||||
|
||||
// The API limiter takes its own mutex, unlike the two above, which are the
|
||||
@@ -1140,215 +1191,6 @@ fn maintenanceOnce(
|
||||
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The pool's entries and everything they point into.
|
||||
///
|
||||
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
|
||||
/// slot of its entry, so that many exchanges can be in flight against it at
|
||||
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
|
||||
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
|
||||
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
|
||||
/// allocation here lives exactly as long as the pool does, and none of them is
|
||||
/// ever resized. One slot is used by one task at a time, which is why the
|
||||
/// buffers are per client and not shared the way `cli.probeUpstreams` shares
|
||||
/// them.
|
||||
const Upstreams = struct {
|
||||
entries: []pool_mod.Entry,
|
||||
used: usize,
|
||||
/// Sliced per entry into `Entry.slots`, never pointing into the client
|
||||
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
|
||||
slot_storage: []pool_mod.Slot,
|
||||
/// One per enabled upstream, and the reason it is a separate allocation:
|
||||
/// `Pool.init` sorts entries by value, so a counter living inside an entry
|
||||
/// would be pointed at by the wrong upstream's clients after the sort.
|
||||
recovery_counters: []std.atomic.Value(u64),
|
||||
doh: []doh_client.DohClient,
|
||||
dot: []dot_client.DotClient,
|
||||
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
|
||||
/// upstream leaves the tail of an over-allocated array undefined, and both
|
||||
/// `deinit` and `build`'s failure paths iterate only the initialized
|
||||
/// prefix — reading a `DotClient` that was never built, or closing a
|
||||
/// session that was never opened, is what these two counts prevent.
|
||||
doh_used: usize,
|
||||
dot_used: usize,
|
||||
doh_buf: []u8,
|
||||
dot_buf: []u8,
|
||||
|
||||
/// A disabled upstream is left out entirely; a malformed one warns and is
|
||||
/// skipped, because one bad row in a table of four must not take DNS down.
|
||||
/// No usable row at all is a configuration fault.
|
||||
fn build(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
servers: []const model.UpstreamServer,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
config_load: *ConfigLoad,
|
||||
) (Allocator.Error || error{NoUsableUpstreams})!Upstreams {
|
||||
var enabled: usize = 0;
|
||||
for (servers) |server| {
|
||||
if (server.enabled) enabled += 1;
|
||||
}
|
||||
if (enabled == 0) return error.NoUsableUpstreams;
|
||||
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const slots = pool_mod.slots_per_entry;
|
||||
const leaf_clients = enabled * slots;
|
||||
|
||||
var self: Upstreams = .{
|
||||
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
||||
.used = 0,
|
||||
.slot_storage = &.{},
|
||||
.recovery_counters = &.{},
|
||||
.doh = &.{},
|
||||
.dot = &.{},
|
||||
.doh_used = 0,
|
||||
.dot_used = 0,
|
||||
.doh_buf = &.{},
|
||||
.dot_buf = &.{},
|
||||
};
|
||||
errdefer self.deinit(io, gpa);
|
||||
|
||||
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
|
||||
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
|
||||
for (self.recovery_counters) |*counter| counter.* = .init(0);
|
||||
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
|
||||
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
|
||||
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
|
||||
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
|
||||
|
||||
for (servers) |server| {
|
||||
if (!server.enabled) continue;
|
||||
|
||||
const endpoint = transport.Endpoint.parse(server.url) catch {
|
||||
log.warn(
|
||||
"upstream {f} is not an https:// or tls:// endpoint; skipped",
|
||||
.{safe_url.redactQuoted(server.url)},
|
||||
);
|
||||
noteUpstream(config_load, server.url, "not an https:// or tls:// endpoint; skipped");
|
||||
continue;
|
||||
};
|
||||
|
||||
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
|
||||
switch (endpoint.scheme) {
|
||||
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
|
||||
log.warn(
|
||||
"upstream {f} is not a usable DoH url; skipped",
|
||||
.{safe_url.redactQuoted(server.url)},
|
||||
);
|
||||
noteUpstream(config_load, server.url, "not a usable DoH url; skipped");
|
||||
continue;
|
||||
},
|
||||
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
|
||||
}
|
||||
|
||||
self.entries[self.used] = .{
|
||||
.endpoint = endpoint,
|
||||
.slots = entry_slots,
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = entry_slots.len },
|
||||
.reuse_recoveries = &self.recovery_counters[self.used],
|
||||
};
|
||||
self.used += 1;
|
||||
}
|
||||
|
||||
if (self.used == 0) return error.NoUsableUpstreams;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
|
||||
/// connection pool already serves concurrent requests, and a `DohClient`'s
|
||||
/// only mutable state is the two buffers this gives each slot its own of.
|
||||
///
|
||||
/// False means the url is not a usable DoH url, which `DohClient.init`
|
||||
/// decides from the url alone — so it fails on the first slot or on none.
|
||||
/// `doh_used` still advances per client rather than per entry: it means
|
||||
/// "initialized", and a skipped entry's clients are simply never reached.
|
||||
fn wireDoh(
|
||||
self: *Upstreams,
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
slots: []pool_mod.Slot,
|
||||
) bool {
|
||||
for (slots) |*slot| {
|
||||
const index = self.doh_used;
|
||||
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
|
||||
self.doh[index] = doh_client.DohClient.init(
|
||||
http,
|
||||
endpoint,
|
||||
self.doh_buf[base..][0..doh_request_buf_len],
|
||||
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
||||
) catch return false;
|
||||
self.doh_used = index + 1;
|
||||
slot.* = .{ .client = self.doh[index].client() };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// One `DotClient` per slot, each with its own four TLS buffers and all
|
||||
/// sharing the trust store. Every client of one entry reports its stale-reuse
|
||||
/// recoveries through that entry's counter.
|
||||
fn wireDot(
|
||||
self: *Upstreams,
|
||||
gpa: Allocator,
|
||||
endpoint: transport.Endpoint,
|
||||
tls_name: []const u8,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
slots: []pool_mod.Slot,
|
||||
) void {
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const recoveries = &self.recovery_counters[self.used];
|
||||
for (slots) |*slot| {
|
||||
const index = self.dot_used;
|
||||
const base = index * 4 * chunk;
|
||||
self.dot[index] = dot_client.DotClient.init(
|
||||
endpoint,
|
||||
tls_name,
|
||||
gpa,
|
||||
bundle,
|
||||
bundle_lock,
|
||||
recoveries,
|
||||
.{
|
||||
.tls_read = self.dot_buf[base..][0..chunk],
|
||||
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
||||
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
||||
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
||||
},
|
||||
);
|
||||
self.dot_used = index + 1;
|
||||
slot.* = .{ .client = self.dot[index].client() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
||||
/// never filled, which is what keeps `deinit` able to free the whole block.
|
||||
fn active(self: *Upstreams) []pool_mod.Entry {
|
||||
return self.entries[0..self.used];
|
||||
}
|
||||
|
||||
/// Connections first, memory second: a `DotClient` holds a socket its
|
||||
/// buffers belong to, so nothing it points at may be freed before it is
|
||||
/// closed.
|
||||
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
|
||||
for (self.dot[0..self.dot_used]) |*client| client.close(io);
|
||||
gpa.free(self.dot_buf);
|
||||
gpa.free(self.doh_buf);
|
||||
gpa.free(self.dot);
|
||||
gpa.free(self.doh);
|
||||
gpa.free(self.recovery_counters);
|
||||
gpa.free(self.slot_storage);
|
||||
gpa.free(self.entries);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listeners
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2414,10 +2256,11 @@ test "one maintenance pass drops the api limiter's stale buckets" {
|
||||
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
|
||||
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
|
||||
|
||||
// Nothing here exchanges: the pass only sweeps the two tables.
|
||||
var unreachable_upstream: upstream_owner.Borrowed = .{};
|
||||
var h: handler.Handler = .{
|
||||
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.blocking = .{ .mode = .zero, .ttl = 5 },
|
||||
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
|
||||
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
|
||||
};
|
||||
try maintenanceOnce(&h, &limiter, io);
|
||||
|
||||
@@ -2520,6 +2363,80 @@ test "a configuration finding is reported once and finalize closes the rest" {
|
||||
));
|
||||
}
|
||||
|
||||
test "the upstream build's report replays into the same rows the boot path used to write" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
// Last boot's finding for a row this boot has fixed. Only `finalize`
|
||||
// closes it, which is why the replay has to run before it.
|
||||
fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale");
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const generation = try upstream_owner.build(.{
|
||||
.gpa = testing.allocator,
|
||||
.io = io,
|
||||
// The credential in the bad row is why the key and the rendered text
|
||||
// differ: the key is the whole url, and both rendered forms drop it.
|
||||
.servers = &.{
|
||||
.{ .url = "ftp://user:hunter2@nope.example" },
|
||||
.{ .url = "https://good.example/dns-query" },
|
||||
},
|
||||
.http = &http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
|
||||
},
|
||||
.seed = 1,
|
||||
});
|
||||
var upstreams: upstream_owner.Owner = .init(generation);
|
||||
defer upstreams.deinit(io);
|
||||
|
||||
// `build` itself wrote nothing: a candidate a settings PUT never publishes
|
||||
// must leave the diagnostics log exactly as it found it.
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
|
||||
for (generation.report().notes) |finding| {
|
||||
noteUpstream(&collector, finding.url, finding.message);
|
||||
}
|
||||
collector.finalize();
|
||||
|
||||
// The whole url is the key, the redaction is the label, and the detail is
|
||||
// the sentence `noteUpstream` has always written — byte for byte.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("ftp://nope.example", try fx.text(
|
||||
"SELECT subject_label FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings(
|
||||
"upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped",
|
||||
try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
|
||||
// The row that was wrong last boot and is not wrong now is closed by the
|
||||
// same `finalize` as ever: the replay is what puts the keys in front of it.
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count(
|
||||
"SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL",
|
||||
));
|
||||
}
|
||||
|
||||
test "an over-long boot finding list refuses to finalize rather than truncate" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
+6
-9
@@ -343,16 +343,13 @@ pub const DataDir = struct {
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
|
||||
/// already established. A running server needs two background ones — the
|
||||
/// log writer and the retention pass each own one (`retention.zig`'s
|
||||
/// contract) — plus a third for the web task when the web interface is
|
||||
/// enabled.
|
||||
/// already established, bound to this data directory's path.
|
||||
///
|
||||
/// The opener itself is `querylog_schema.reopen`, beside the schema it
|
||||
/// belongs to: a logger generation opens its own writer connection at
|
||||
/// runtime and has no `DataDir` to ask.
|
||||
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||
_ = io;
|
||||
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
return querylog_schema.reopen(io, std.Io.Dir.cwd(), self.querylog_db_path);
|
||||
}
|
||||
|
||||
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
|
||||
|
||||
@@ -380,10 +380,6 @@ pub fn sessionTtlSeconds(w: Web) i64 {
|
||||
return @as(i64, w.session_ttl_hours) * 3600;
|
||||
}
|
||||
|
||||
pub fn retentionSeconds(l: Logging) i64 {
|
||||
return @as(i64, l.retention_days) * 86400;
|
||||
}
|
||||
|
||||
pub fn maxLogBytes(l: Logging) u64 {
|
||||
return @as(u64, l.max_size_mb) * 1024 * 1024;
|
||||
}
|
||||
@@ -840,7 +836,6 @@ test "unit conversions" {
|
||||
totalTimeout(.{}).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
|
||||
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
|
||||
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
|
||||
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
|
||||
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
|
||||
@@ -865,10 +860,6 @@ test "unit conversions at the field maximum do not overflow" {
|
||||
@as(i64, std.math.maxInt(u16)) * 3600,
|
||||
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, std.math.maxInt(u16)) * 86400,
|
||||
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
|
||||
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
|
||||
|
||||
@@ -1311,11 +1311,13 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
|
||||
|
||||
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
|
||||
// only one the server ever calls. A disabled update stops it after the
|
||||
// startup pass, so the production path runs to completion here with no
|
||||
// interval to wait out.
|
||||
// only one the server ever calls. A disabled update parks it after the
|
||||
// startup pass, and the seam turns that park into the shutdown the task
|
||||
// only otherwise exits on, so the production path runs to completion here
|
||||
// with no interval to wait out.
|
||||
env.mgr.update.enabled = false;
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
|
||||
@@ -1555,8 +1557,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
// The restart. The old manager is gone and a new one comes up over the same
|
||||
// directory and the same database with nothing carried across in memory.
|
||||
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
|
||||
// then the startup pass — and a disabled update makes it return rather than
|
||||
// wait out an interval.
|
||||
// then the startup pass — and a disabled update parks it rather than
|
||||
// waiting out an interval; the seam turns that park into shutdown.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
@@ -1566,7 +1568,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// Nothing was downloaded. The server is still listening, so this is a
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
@@ -2456,6 +2459,62 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records the deadline of the first park and then reports the shutdown the
|
||||
/// scheduler loop only otherwise exits on. Its clock never moves, so the
|
||||
/// deadline it captures is exactly `anchor + interval`.
|
||||
const FirstParkRecorder = struct {
|
||||
deadline_s: ?i64 = null,
|
||||
parked: bool = false,
|
||||
|
||||
fn clock(self: *FirstParkRecorder) manager.ScheduleClock {
|
||||
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
|
||||
}
|
||||
|
||||
fn now(_: ?*anyopaque, _: std.Io) i64 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn wait(ctx: ?*anyopaque, _: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
|
||||
const self: *FirstParkRecorder = @ptrCast(@alignCast(ctx.?));
|
||||
self.deadline_s = deadline_s;
|
||||
self.parked = true;
|
||||
return error.Canceled;
|
||||
}
|
||||
};
|
||||
|
||||
test "34: a pass whose refresh failed still advances the schedule anchor" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, http_body);
|
||||
defer fixture.deinit(io);
|
||||
// Every download this pass makes fails.
|
||||
fixture.setRoute(.oversize);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
_ = try seedSource(&env.database, url);
|
||||
|
||||
var recorder: FirstParkRecorder = .{};
|
||||
env.mgr.schedule_clock = recorder.clock();
|
||||
env.mgr.setSchedule(io, true, 2);
|
||||
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// The startup pass tried the source and failed. The anchor still moved to
|
||||
// that pass's completion, so the next refresh is a full interval away
|
||||
// rather than immediate: a failing source must not become a download loop.
|
||||
try testing.expect(recorder.parked);
|
||||
try testing.expectEqual(@as(?i64, 2 * 3_600), recorder.deadline_s);
|
||||
}
|
||||
|
||||
test "27: one refreshAll pass records one occurrence of a failing source" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
|
||||
+404
-12
@@ -333,12 +333,83 @@ pub fn stripHeader(bytes: []const u8) []const u8 {
|
||||
return rest;
|
||||
}
|
||||
|
||||
/// The scheduler's two time operations, behind a seam. Validated intervals are
|
||||
/// at least an hour, so a test that used the real clock would either sleep an
|
||||
/// hour or prove nothing; a test installs its own step clock instead.
|
||||
pub const ScheduleClock = struct {
|
||||
ctx: ?*anyopaque = null,
|
||||
/// Seconds on a monotonic clock. Only differences matter.
|
||||
nowFn: *const fn (ctx: ?*anyopaque, io: std.Io) i64,
|
||||
/// Returns when `deadline_s` arrives or `event` is set, whichever comes
|
||||
/// first; a null deadline waits for the event alone. A spurious early
|
||||
/// return is allowed — the caller rechecks both the version and the clock.
|
||||
waitFn: *const fn (
|
||||
ctx: ?*anyopaque,
|
||||
io: std.Io,
|
||||
event: *std.Io.Event,
|
||||
deadline_s: ?i64,
|
||||
) std.Io.Cancelable!void,
|
||||
|
||||
pub const real: ScheduleClock = .{ .nowFn = realNow, .waitFn = realWait };
|
||||
|
||||
/// Test seam: the loop only ever exits on shutdown, so a test that wants
|
||||
/// `runScheduler` to run its startup pass and return installs this and
|
||||
/// gets `error.Canceled` at the first park.
|
||||
pub const shutdown_at_first_park: ScheduleClock = .{ .nowFn = realNow, .waitFn = cancelWait };
|
||||
|
||||
fn cancelWait(_: ?*anyopaque, _: std.Io, _: *std.Io.Event, _: ?i64) std.Io.Cancelable!void {
|
||||
return error.Canceled;
|
||||
}
|
||||
|
||||
/// `boot` rather than `awake`: a box that suspends overnight should still
|
||||
/// see its daily interval elapse.
|
||||
fn realNow(_: ?*anyopaque, io: std.Io) i64 {
|
||||
return std.Io.Clock.boot.now(io).toSeconds();
|
||||
}
|
||||
|
||||
fn realWait(
|
||||
_: ?*anyopaque,
|
||||
io: std.Io,
|
||||
event: *std.Io.Event,
|
||||
deadline_s: ?i64,
|
||||
) std.Io.Cancelable!void {
|
||||
const timeout: std.Io.Timeout = if (deadline_s) |seconds| .{ .deadline = .{
|
||||
.raw = .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s },
|
||||
.clock = .boot,
|
||||
} } else .none;
|
||||
event.waitTimeout(io, timeout) catch |err| switch (err) {
|
||||
error.Timeout => {},
|
||||
error.Canceled => return error.Canceled,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Manager = struct {
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
paths: Paths,
|
||||
fetcher: *fetcher.Fetcher,
|
||||
/// Read and written only under `schedule_mutex`; `setSchedule` replaces it
|
||||
/// while the scheduler is parked.
|
||||
update: model.BlocklistUpdate,
|
||||
/// Guards `update`, `schedule_version` and `schedule_anchor_s`.
|
||||
///
|
||||
/// Lock ordering: innermost. `needsRefresh` takes it while `refresh_lock`
|
||||
/// is held, and nothing that holds it takes another manager lock.
|
||||
schedule_mutex: std.Io.Mutex,
|
||||
/// Bumped by every `setSchedule`. The scheduler reads it before it parks
|
||||
/// and again after it wakes: a change that lands in that window is what the
|
||||
/// recheck catches, so no wake is lost and none is mistaken for a deadline.
|
||||
schedule_version: u64,
|
||||
/// When the last refresh pass that RAN completed, on `ScheduleClock`'s
|
||||
/// clock. Success, failure and a disk-gate skip all advance it — the
|
||||
/// scheduled slot is spent either way and is not retried early. Null until
|
||||
/// the startup pass finishes.
|
||||
schedule_anchor_s: ?i64,
|
||||
/// Sticky once set, so `setSchedule` can never signal into a gap. The loop
|
||||
/// resets it under `schedule_mutex` before it recomputes its deadline.
|
||||
schedule_event: std.Io.Event,
|
||||
schedule_clock: ScheduleClock,
|
||||
/// Bounds one download. `std.http.Client` has no per-request deadline, so
|
||||
/// the fetch runs under `io.concurrent` against a sleep of this length.
|
||||
total_budget: std.Io.Clock.Duration,
|
||||
@@ -412,6 +483,11 @@ pub const Manager = struct {
|
||||
.paths = paths,
|
||||
.fetcher = fetcher_ptr,
|
||||
.update = update,
|
||||
.schedule_mutex = .init,
|
||||
.schedule_version = 0,
|
||||
.schedule_anchor_s = null,
|
||||
.schedule_event = .unset,
|
||||
.schedule_clock = .real,
|
||||
.total_budget = total_budget,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
@@ -1490,8 +1566,9 @@ pub const Manager = struct {
|
||||
/// it has no usable compiled files or its `last_updated` is older than the
|
||||
/// interval.
|
||||
///
|
||||
/// `update.enabled == false` stops after the startup pass; manual refresh
|
||||
/// through `refreshAll` still works.
|
||||
/// `update.enabled == false` parks after the startup pass; manual refresh
|
||||
/// through `refreshAll` still works, and a later `setSchedule` wakes the
|
||||
/// loop rather than needing a restart.
|
||||
pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void {
|
||||
// Ahead of the pass, not after it. This is the sweep that collects what
|
||||
// a killed process left behind: a `.raw.tmp` as large as the body the
|
||||
@@ -1511,18 +1588,76 @@ pub const Manager = struct {
|
||||
self.flushDiagnostics(io);
|
||||
},
|
||||
};
|
||||
if (!self.update.enabled) return;
|
||||
// The startup pass ran, so it anchors the schedule — including when
|
||||
// updates are disabled, so a later enable measures its first interval
|
||||
// from real work rather than from the moment the operator flipped the
|
||||
// switch.
|
||||
self.anchorNow(io);
|
||||
|
||||
// `boot` rather than `awake`: a box that suspends overnight should
|
||||
// still see its daily interval elapse.
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(model.updateIntervalSeconds(self.update)),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
try self.scheduledPass(io);
|
||||
// One hold: read the version, reset the sticky event, and take the
|
||||
// schedule the deadline is computed from. A `setSchedule` that
|
||||
// lands after this reset completes the wait below at once, and the
|
||||
// version recheck decides whether the wake meant anything.
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
const version = self.schedule_version;
|
||||
self.schedule_event.reset();
|
||||
const enabled = self.update.enabled;
|
||||
const interval_s = model.updateIntervalSeconds(self.update);
|
||||
const anchor = self.schedule_anchor_s;
|
||||
self.schedule_mutex.unlock(io);
|
||||
|
||||
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
|
||||
// Disabled parks on the event alone. The task still exits only on
|
||||
// shutdown, exactly as it did when it returned here.
|
||||
const deadline_s: ?i64 = if (enabled) (anchor orelse now_s) + interval_s else null;
|
||||
|
||||
if (deadline_s == null or now_s < deadline_s.?) {
|
||||
try self.schedule_clock.waitFn(self.schedule_clock.ctx, io, &self.schedule_event, deadline_s);
|
||||
// Either the schedule changed under us or the wait was
|
||||
// spurious; recompute from the top rather than guess.
|
||||
if (self.scheduleVersion(io) != version) continue;
|
||||
if (deadline_s == null) continue;
|
||||
if (self.schedule_clock.nowFn(self.schedule_clock.ctx, io) < deadline_s.?) continue;
|
||||
}
|
||||
|
||||
try self.scheduledPass(io);
|
||||
self.anchorNow(io);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a new blocklist-update schedule and wakes the scheduler. The
|
||||
/// anchor is untouched: the next refresh is due one NEW interval after the
|
||||
/// last pass that ran, which the loop refreshes immediately when that
|
||||
/// moment is already past.
|
||||
pub fn setSchedule(self: *Manager, io: std.Io, enabled: bool, interval_hours: u16) void {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
self.update = .{ .enabled = enabled, .interval_hours = interval_hours };
|
||||
self.schedule_version += 1;
|
||||
self.schedule_mutex.unlock(io);
|
||||
|
||||
self.schedule_event.set(io);
|
||||
}
|
||||
|
||||
/// The live schedule. Every reader outside the scheduler loop goes through
|
||||
/// here, so none of them reads `update` while `setSchedule` writes it.
|
||||
pub fn schedule(self: *Manager, io: std.Io) model.BlocklistUpdate {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
defer self.schedule_mutex.unlock(io);
|
||||
return self.update;
|
||||
}
|
||||
|
||||
fn scheduleVersion(self: *Manager, io: std.Io) u64 {
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
defer self.schedule_mutex.unlock(io);
|
||||
return self.schedule_version;
|
||||
}
|
||||
|
||||
fn anchorNow(self: *Manager, io: std.Io) void {
|
||||
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
|
||||
self.schedule_mutex.lockUncancelable(io);
|
||||
self.schedule_anchor_s = now_s;
|
||||
self.schedule_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// What one elapsed interval does. Split from the loop above so a test can
|
||||
@@ -1643,7 +1778,7 @@ pub const Manager = struct {
|
||||
// else would ever clear it. A stamp from the future is not evidence of
|
||||
// a recent fetch.
|
||||
if (last > now) return true;
|
||||
return now - last >= model.updateIntervalSeconds(self.update);
|
||||
return now - last >= model.updateIntervalSeconds(self.schedule(io));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3188,3 +3323,260 @@ test "bodyChecksum covers the list body, then the wild body, then the allow body
|
||||
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeable scheduler (milestone-34 S3.6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `ScheduleClock` that never sleeps. Each park is recorded, then the clock
|
||||
/// jumps straight to the deadline so the loop runs the next pass at once; a
|
||||
/// budget of parks ends the run with the `error.Canceled` shutdown is the only
|
||||
/// other source of. A park may also fire a `setSchedule`, which is what a
|
||||
/// settings PUT landing while the scheduler waits looks like.
|
||||
const StepClock = struct {
|
||||
const max_parks = 16;
|
||||
|
||||
mutex: std.Io.Mutex = .init,
|
||||
manager: *Manager,
|
||||
now_s: i64 = 0,
|
||||
/// Deadline of each park in order; null means "parked with no deadline",
|
||||
/// which is what a disabled schedule does.
|
||||
parks: [max_parks]?i64 = @splat(null),
|
||||
park_count: usize = 0,
|
||||
budget: usize = 2,
|
||||
/// Fired from inside the park at this index, before the wait returns.
|
||||
change_at_park: ?usize = null,
|
||||
change_enabled: bool = true,
|
||||
change_hours: u16 = 1,
|
||||
|
||||
fn clock(self: *StepClock) ScheduleClock {
|
||||
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
|
||||
}
|
||||
|
||||
fn now(ctx: ?*anyopaque, io: std.Io) i64 {
|
||||
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.now_s;
|
||||
}
|
||||
|
||||
fn wait(ctx: ?*anyopaque, io: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
|
||||
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
const index = self.park_count;
|
||||
if (index < max_parks) self.parks[index] = deadline_s;
|
||||
self.park_count = index + 1;
|
||||
const fire_change = self.change_at_park == index;
|
||||
const over_budget = self.park_count >= self.budget;
|
||||
if (deadline_s) |d| self.now_s = d;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// Taken outside this clock's own mutex: `setSchedule` takes the
|
||||
// manager's, and the loop reads this clock under neither.
|
||||
if (fire_change) {
|
||||
self.manager.setSchedule(io, self.change_enabled, self.change_hours);
|
||||
return;
|
||||
}
|
||||
if (over_budget or deadline_s == null) return error.Canceled;
|
||||
}
|
||||
|
||||
fn parked(self: *StepClock) []const ?i64 {
|
||||
return self.parks[0..@min(self.park_count, max_parks)];
|
||||
}
|
||||
};
|
||||
|
||||
const hour = 3_600;
|
||||
|
||||
test "the scheduler parks one interval past the anchor and again past each pass" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 3 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass anchored at 0, so the first park is due at 2 h and each
|
||||
// completed pass re-anchors: 2 h, 4 h, 6 h.
|
||||
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
|
||||
}
|
||||
|
||||
test "a shortened interval moves the next refresh onto the new cadence" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 24 };
|
||||
|
||||
// The PUT lands while the loop waits out the 24-hour deadline.
|
||||
var step: StepClock = .{
|
||||
.manager = &manager,
|
||||
.budget = 4,
|
||||
.change_at_park = 0,
|
||||
.change_enabled = true,
|
||||
.change_hours = 1,
|
||||
};
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// Park 0 was the old 24-hour deadline; the change woke it, and every park
|
||||
// after it is one hour past the anchor the previous pass set.
|
||||
const parks = step.parked();
|
||||
try testing.expectEqual(@as(usize, 4), parks.len);
|
||||
try testing.expectEqual(@as(?i64, 24 * hour), parks[0]);
|
||||
try testing.expectEqual(@as(?i64, 24 * hour + hour), parks[1]);
|
||||
try testing.expectEqual(@as(?i64, 25 * hour + hour), parks[2]);
|
||||
}
|
||||
|
||||
test "a disabled schedule parks with no deadline and the startup pass still runs" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = false, .interval_hours = 1 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 8 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass ran — it published a snapshot even with updates off —
|
||||
// and then the loop parked once, on nothing.
|
||||
try testing.expect(manager.generation > 0);
|
||||
try testing.expectEqualSlices(?i64, &.{null}, step.parked());
|
||||
}
|
||||
|
||||
test "re-enabling anchors the first interval on the last pass that ran" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = false, .interval_hours = 1 };
|
||||
|
||||
var step: StepClock = .{
|
||||
.manager = &manager,
|
||||
.budget = 3,
|
||||
.change_at_park = 0,
|
||||
.change_enabled = true,
|
||||
.change_hours = 3,
|
||||
};
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
const parks = step.parked();
|
||||
// Park 0 is the disabled park; the enable wakes it, and the first deadline
|
||||
// is three hours past the STARTUP pass's anchor rather than past the
|
||||
// moment the operator flipped the switch.
|
||||
try testing.expectEqual(@as(?i64, null), parks[0]);
|
||||
try testing.expectEqual(@as(?i64, 3 * hour), parks[1]);
|
||||
}
|
||||
|
||||
test "an interval already elapsed at enable time refreshes immediately" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 2 };
|
||||
manager.schedule_clock = step.clock();
|
||||
// The anchor is four hours in the past, so two hours past it is already
|
||||
// gone and the loop must not wait at all before its first pass.
|
||||
manager.schedule_anchor_s = -4 * hour;
|
||||
step.now_s = 0;
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// The startup pass re-anchors at 0, so this proves nothing on its own
|
||||
// unless the anchor survives it; assert on the parks instead: the first
|
||||
// park is one interval past the startup anchor, never a wait for a
|
||||
// deadline already behind us.
|
||||
const parks = step.parked();
|
||||
try testing.expectEqual(@as(?i64, 2 * hour), parks[0]);
|
||||
}
|
||||
|
||||
test "a gate-skipped pass advances the anchor rather than retrying early" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
manager.update = .{ .enabled = true, .interval_hours = 2 };
|
||||
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
manager.monitor = &monitor;
|
||||
|
||||
var step: StepClock = .{ .manager = &manager, .budget = 3 };
|
||||
manager.schedule_clock = step.clock();
|
||||
|
||||
try testing.expectError(error.Canceled, manager.runScheduler(io));
|
||||
|
||||
// Every scheduled pass was refused by the gate, and each one still spent
|
||||
// its slot: the deadlines march one interval at a time instead of
|
||||
// collapsing onto the same anchor.
|
||||
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
|
||||
// The startup pass is gated too, so three refusals: one startup and the
|
||||
// two scheduled passes the parks above bracket.
|
||||
try testing.expectEqual(@as(u64, 3), manager.refreshesGated());
|
||||
}
|
||||
|
||||
test "setSchedule is what the live schedule readers see" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
|
||||
manager.setSchedule(io, false, 6);
|
||||
const live = manager.schedule(io);
|
||||
try testing.expect(!live.enabled);
|
||||
try testing.expectEqual(@as(u16, 6), live.interval_hours);
|
||||
try testing.expect(manager.schedule_event.isSet());
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@ const npm_not_shipped = [_][]const u8{
|
||||
"aria-hidden",
|
||||
"client-only",
|
||||
"tslib",
|
||||
"@types/d3-array",
|
||||
"@types/d3-color",
|
||||
"@types/d3-delaunay",
|
||||
"@types/d3-format",
|
||||
"@types/d3-geo",
|
||||
"@types/d3-interpolate",
|
||||
"@types/d3-path",
|
||||
"@types/d3-scale",
|
||||
"@types/d3-shape",
|
||||
"@types/d3-time",
|
||||
"@types/d3-time-format",
|
||||
"@types/geojson",
|
||||
"@types/react",
|
||||
"@types/react-dom",
|
||||
"csstype",
|
||||
"@visx/curve",
|
||||
"@visx/vendor",
|
||||
"d3-delaunay",
|
||||
"d3-geo",
|
||||
"d3-time-format",
|
||||
"delaunator",
|
||||
"robust-predicates",
|
||||
"react-use-measure",
|
||||
};
|
||||
|
||||
/// Components the shipped artifacts contain that no dependency file mentions,
|
||||
@@ -158,6 +181,24 @@ const npm_licence_exceptions = [_]NpmLicence{
|
||||
// and @stylexjs/stylex's compiler.
|
||||
.{ .name = "isbot", .licence = "Unlicense" },
|
||||
.{ .name = "css-mediaquery", .licence = "BSD" },
|
||||
// Shipped: the d3 modules visx computes its geometry with. Mokhtar Mial
|
||||
// accepted ISC inbound on 2026-08-24; licenses/d3-isc.txt carries the text.
|
||||
.{ .name = "d3-array", .licence = "ISC" },
|
||||
.{ .name = "d3-color", .licence = "ISC" },
|
||||
.{ .name = "d3-format", .licence = "ISC" },
|
||||
.{ .name = "d3-interpolate", .licence = "ISC" },
|
||||
.{ .name = "d3-path", .licence = "ISC" },
|
||||
.{ .name = "d3-scale", .licence = "ISC" },
|
||||
.{ .name = "d3-shape", .licence = "ISC" },
|
||||
.{ .name = "d3-time", .licence = "ISC" },
|
||||
.{ .name = "internmap", .licence = "ISC" },
|
||||
// Not shipped: the rest of the d3 closure, reached only through
|
||||
// @visx/vendor's map and Voronoi re-exports, which no chart here imports.
|
||||
.{ .name = "d3-delaunay", .licence = "ISC" },
|
||||
.{ .name = "d3-geo", .licence = "ISC" },
|
||||
.{ .name = "d3-time-format", .licence = "ISC" },
|
||||
.{ .name = "delaunator", .licence = "ISC" },
|
||||
.{ .name = "robust-predicates", .licence = "Unlicense" },
|
||||
};
|
||||
|
||||
const NpmLicence = struct {
|
||||
|
||||
@@ -252,6 +252,200 @@ fn installWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) void {
|
||||
if (state.output == .file) openFileLocked();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot apply (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which of the three disjoint shapes a `logging` apply takes. The case is
|
||||
/// decided from the FINAL MERGED config against the live sink, and it depends
|
||||
/// only on `output` and `file_path` — fields nothing but an apply writes.
|
||||
/// Rotation and write-failure recovery move `file`, `file_pos` and
|
||||
/// `rotate_pending`, never these two, so a case decided in one lock hold is
|
||||
/// still the right case in the next.
|
||||
pub const ApplyCase = enum {
|
||||
/// The merged config wants a file, and it is not the file that is open:
|
||||
/// the path differs, or output is switching TO file.
|
||||
target_changed,
|
||||
/// Output is switching away from file. Nothing to open.
|
||||
target_removed,
|
||||
/// Everything else — output stays stderr/syslog, or output stays file on
|
||||
/// the SAME path. Only config fields move; the handle and its position and
|
||||
/// rotation state stay with the rotation machinery that owns them.
|
||||
target_unchanged,
|
||||
};
|
||||
|
||||
/// The complete new target state for a `target_changed` apply. The handle
|
||||
/// couples to both other fields: inheriting the old `file_pos` would write
|
||||
/// past the new file's end, and inheriting a pending rotation would rotate the
|
||||
/// new target on its first line.
|
||||
pub const PreparedSink = struct {
|
||||
file: std.Io.File,
|
||||
file_pos: u64,
|
||||
rotate_pending: bool = false,
|
||||
};
|
||||
|
||||
pub const PrepareError = error{
|
||||
/// `file_path` does not fit in the sink's path buffer, so the sink could
|
||||
/// not name the file it was told to write.
|
||||
PathTooLong,
|
||||
/// The new target could not be opened, created, or measured.
|
||||
TargetUnopenable,
|
||||
};
|
||||
|
||||
/// A validated `logging` apply, owning everything publish needs. Publish takes
|
||||
/// no borrow from the request arena, so this outlives the request that built
|
||||
/// it.
|
||||
pub const PreparedApply = struct {
|
||||
case: ApplyCase,
|
||||
threshold: std.log.Level,
|
||||
output: model.LogOutput,
|
||||
path_buf: [std.Io.Dir.max_path_bytes]u8,
|
||||
path_len: usize,
|
||||
max_files: u8,
|
||||
max_bytes: u64,
|
||||
sink: ?PreparedSink,
|
||||
|
||||
pub fn path(self: *const PreparedApply) []const u8 {
|
||||
return self.path_buf[0..self.path_len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Prepare: everything fallible happens here, and nothing is published. A
|
||||
/// `target_changed` apply opens the NEW file and MEASURES it — the open is the
|
||||
/// step that can fail, and doing it here closes the close-then-reopen window
|
||||
/// `installWithMaxBytes` has, where a bad new path leaves no sink at all.
|
||||
pub fn prepareApply(io: std.Io, cfg: model.Logging) PrepareError!PreparedApply {
|
||||
return prepareApplyWithMaxBytes(io, cfg, model.maxLogBytes(cfg));
|
||||
}
|
||||
|
||||
/// Test-only entry point, mirroring `installForTest`.
|
||||
pub fn prepareApplyForTest(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
|
||||
return prepareApplyWithMaxBytes(io, cfg, max_bytes);
|
||||
}
|
||||
|
||||
fn prepareApplyWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
|
||||
if (cfg.file_path.len > std.Io.Dir.max_path_bytes) return error.PathTooLong;
|
||||
|
||||
var prepared: PreparedApply = .{
|
||||
.case = undefined,
|
||||
.threshold = toStdLevel(cfg.level),
|
||||
.output = cfg.output,
|
||||
.path_buf = undefined,
|
||||
.path_len = cfg.file_path.len,
|
||||
.max_files = cfg.max_files,
|
||||
.max_bytes = max_bytes,
|
||||
.sink = null,
|
||||
};
|
||||
@memcpy(prepared.path_buf[0..prepared.path_len], cfg.file_path);
|
||||
prepared.case = classifyApply(cfg);
|
||||
|
||||
if (prepared.case == .target_changed) {
|
||||
prepared.sink = try openTarget(io, prepared.path());
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
fn classifyApply(cfg: model.Logging) ApplyCase {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
const currently_file = state.output == .file;
|
||||
if (cfg.output != .file) return if (currently_file) .target_removed else .target_unchanged;
|
||||
if (!currently_file) return .target_changed;
|
||||
return if (std.mem.eql(u8, state.path(), cfg.file_path)) .target_unchanged else .target_changed;
|
||||
}
|
||||
|
||||
/// Opens `p` and measures it, without touching the live sink.
|
||||
fn openTarget(io: std.Io, p: []const u8) PrepareError!PreparedSink {
|
||||
if (p.len == 0) return error.TargetUnopenable;
|
||||
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
|
||||
const dir: std.Io.Dir = .cwd();
|
||||
const file = dir.openFile(io, p, .{ .mode = .write_only }) catch |open_err| switch (open_err) {
|
||||
error.FileNotFound => dir.createFile(io, p, .{ .truncate = false }) catch
|
||||
return error.TargetUnopenable,
|
||||
else => return error.TargetUnopenable,
|
||||
};
|
||||
errdefer file.close(io);
|
||||
|
||||
// A pre-existing nonempty target is appended to, so the new position is
|
||||
// its measured length rather than zero.
|
||||
const length = file.length(io) catch return error.TargetUnopenable;
|
||||
return .{ .file = file, .file_pos = length, .rotate_pending = false };
|
||||
}
|
||||
|
||||
/// Publish: infallible and I/O-free, one hold of the sink lock. Returns the
|
||||
/// DETACHED old handle, which `retireApply` closes — closing a file is retire
|
||||
/// work, and doing it here would put a syscall inside the publish.
|
||||
pub fn publishApply(prepared: PreparedApply) ?std.Io.File {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
state.threshold = prepared.threshold;
|
||||
state.output = prepared.output;
|
||||
state.path_len = prepared.path_len;
|
||||
@memcpy(state.path_buf[0..state.path_len], prepared.path());
|
||||
state.max_files = prepared.max_files;
|
||||
state.max_bytes = prepared.max_bytes;
|
||||
|
||||
switch (prepared.case) {
|
||||
// The handle and its position and rotation state are not this apply's
|
||||
// to move; a broken handle is repaired by the existing per-write
|
||||
// recovery, not by a config change.
|
||||
.target_unchanged => return null,
|
||||
.target_changed => {
|
||||
const detached = state.file;
|
||||
const sink = prepared.sink.?;
|
||||
state.file = sink.file;
|
||||
state.file_pos = sink.file_pos;
|
||||
state.rotate_pending = sink.rotate_pending;
|
||||
return detached;
|
||||
},
|
||||
.target_removed => {
|
||||
const detached = state.file;
|
||||
state.file = null;
|
||||
state.file_pos = 0;
|
||||
state.rotate_pending = false;
|
||||
return detached;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire: closes the handle `publishApply` detached, after no writer can
|
||||
/// reach it — the swap happened under the sink lock, so any writer that held
|
||||
/// it has already returned.
|
||||
pub fn retireApply(io: std.Io, detached: ?std.Io.File) void {
|
||||
const file = detached orelse return;
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
file.close(io);
|
||||
}
|
||||
|
||||
/// Discards a prepared apply that will not be published, because its commit
|
||||
/// failed or a sibling owner's prepare did.
|
||||
pub fn abortApply(io: std.Io, prepared: PreparedApply) void {
|
||||
const sink = prepared.sink orelse return;
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
sink.file.close(io);
|
||||
}
|
||||
|
||||
/// The directory the disk monitor should measure for `cfg`: the log file's
|
||||
/// directory when output is `file`, and null otherwise — with output on
|
||||
/// stderr or syslog there is no log file to run out of room for.
|
||||
///
|
||||
/// A path with no directory component measures the working directory, which is
|
||||
/// where a bare filename lands.
|
||||
pub fn logDirname(cfg: model.Logging) ?[]const u8 {
|
||||
if (cfg.output != .file) return null;
|
||||
if (cfg.file_path.len == 0) return null;
|
||||
return std.fs.path.dirname(cfg.file_path) orelse ".";
|
||||
}
|
||||
|
||||
/// Flushes and closes the file, and restores pass-through stderr formatting.
|
||||
pub fn deinstall() void {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
@@ -1099,3 +1293,365 @@ test "a message over the buffer is marked and counted" {
|
||||
const colon = std.mem.indexOf(u8, written, ": ").?;
|
||||
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hot apply (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The sink is one process-wide `state` behind the stderr lock, so an apply
|
||||
/// test has to save it, drive the apply, and put it back. Nothing here holds
|
||||
/// the lock across an apply call: `classifyApply` and `publishApply` take it
|
||||
/// themselves.
|
||||
const ApplyFixture = struct {
|
||||
threaded: std.Io.Threaded,
|
||||
tmp: testing.TmpDir,
|
||||
saved: State,
|
||||
|
||||
fn init(self: *ApplyFixture) void {
|
||||
self.threaded = .init(testing.allocator, .{});
|
||||
self.tmp = testing.tmpDir(.{});
|
||||
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
self.saved = state;
|
||||
state = .{};
|
||||
state.io = self.threaded.io();
|
||||
}
|
||||
|
||||
fn deinit(self: *ApplyFixture) void {
|
||||
{
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
if (state.file) |f| f.close(self.threaded.io());
|
||||
state = self.saved;
|
||||
}
|
||||
self.tmp.cleanup();
|
||||
self.threaded.deinit();
|
||||
}
|
||||
|
||||
fn io(self: *ApplyFixture) std.Io {
|
||||
return self.threaded.io();
|
||||
}
|
||||
|
||||
fn path(self: *ApplyFixture, buf: []u8, name: []const u8) []const u8 {
|
||||
return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ self.tmp.sub_path, name }) catch unreachable;
|
||||
}
|
||||
|
||||
/// Puts the sink on `p` with a real open handle, the way a running server
|
||||
/// with `output = file` sits.
|
||||
fn openOn(self: *ApplyFixture, p: []const u8) void {
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
|
||||
state.installed = true;
|
||||
state.output = .file;
|
||||
state.path_len = p.len;
|
||||
@memcpy(state.path_buf[0..p.len], p);
|
||||
state.max_bytes = 1 << 20;
|
||||
state.max_files = 3;
|
||||
openFileLocked();
|
||||
_ = self;
|
||||
}
|
||||
|
||||
fn snapshot(self: *ApplyFixture) State {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
return state;
|
||||
}
|
||||
|
||||
fn setHandleState(self: *ApplyFixture, file: ?std.Io.File, file_pos: u64, rotate_pending: bool) void {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
state.file = file;
|
||||
state.file_pos = file_pos;
|
||||
state.rotate_pending = rotate_pending;
|
||||
}
|
||||
|
||||
/// Writes one record through the live handle, as `emitFileLocked` does.
|
||||
fn writeThroughSink(self: *ApplyFixture, text: []const u8) !void {
|
||||
_ = self;
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
defer std.debug.unlockStderr();
|
||||
try writeLineLocked(state.file.?, text);
|
||||
}
|
||||
|
||||
fn read(self: *ApplyFixture, buf: []u8, name: []const u8) ![]u8 {
|
||||
return self.tmp.dir.readFileAlloc(self.io(), name, testing.allocator, .limited(buf.len)) catch |err| return err;
|
||||
}
|
||||
};
|
||||
|
||||
fn fileCfg(p: []const u8) model.Logging {
|
||||
return .{ .output = .file, .file_path = p, .level = .info, .max_files = 3, .max_size_mb = 1 };
|
||||
}
|
||||
|
||||
test "a bad target path is refused at prepare and the live sink is untouched" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
const before = fx.snapshot();
|
||||
try testing.expect(before.file != null);
|
||||
|
||||
// A directory that does not exist: the open and the create both fail.
|
||||
var bad_buf: [200]u8 = undefined;
|
||||
const bad = fx.path(&bad_buf, "no-such-dir/nxdns.log");
|
||||
try testing.expectError(
|
||||
error.TargetUnopenable,
|
||||
prepareApplyForTest(fx.io(), fileCfg(bad), 1 << 20),
|
||||
);
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(before.file.?.handle, after.file.?.handle);
|
||||
try testing.expectEqualStrings(live, after.path());
|
||||
}
|
||||
|
||||
test "a target change publishes without closing, and retire closes the old handle" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
const first = fx.path(&first_buf, "first.log");
|
||||
const second = fx.path(&second_buf, "second.log");
|
||||
fx.openOn(first);
|
||||
const before = fx.snapshot();
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_changed, prepared.case);
|
||||
|
||||
const detached = publishApply(prepared);
|
||||
const after = fx.snapshot();
|
||||
|
||||
// Publish swapped the handle and left the old one OPEN: the old descriptor
|
||||
// still writes, which it could not if publish had closed it.
|
||||
try testing.expectEqual(before.file.?.handle, detached.?.handle);
|
||||
try testing.expect(after.file.?.handle != detached.?.handle);
|
||||
try testing.expectEqualStrings(second, after.path());
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
try testing.expect(!after.rotate_pending);
|
||||
|
||||
var old_writer_buf: [64]u8 = undefined;
|
||||
var ow = detached.?.writer(fx.io(), &old_writer_buf);
|
||||
try ow.interface.writeAll("still open\n");
|
||||
try ow.interface.flush();
|
||||
|
||||
retireApply(fx.io(), detached);
|
||||
|
||||
// The new target receives lines.
|
||||
try fx.writeThroughSink("1 info: on the new target\n");
|
||||
var read_buf: [256]u8 = undefined;
|
||||
const contents = try fx.read(&read_buf, "second.log");
|
||||
defer testing.allocator.free(contents);
|
||||
try testing.expectEqualStrings("1 info: on the new target\n", contents);
|
||||
}
|
||||
|
||||
test "switching to a pre-existing nonempty file starts at its measured length" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
const existing = "already here\n";
|
||||
try fx.tmp.dir.writeFile(fx.io(), .{ .sub_path = "kept.log", .data = existing });
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var kept_buf: [160]u8 = undefined;
|
||||
fx.openOn(fx.path(&first_buf, "first.log"));
|
||||
const kept = fx.path(&kept_buf, "kept.log");
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(kept), 1 << 20);
|
||||
try testing.expectEqual(@as(u64, existing.len), prepared.sink.?.file_pos);
|
||||
retireApply(fx.io(), publishApply(prepared));
|
||||
|
||||
try testing.expectEqual(@as(u64, existing.len), fx.snapshot().file_pos);
|
||||
|
||||
// Inheriting the old position would have overwritten the existing bytes.
|
||||
try fx.writeThroughSink("appended\n");
|
||||
var read_buf: [256]u8 = undefined;
|
||||
const contents = try fx.read(&read_buf, "kept.log");
|
||||
defer testing.allocator.free(contents);
|
||||
try testing.expectEqualStrings(existing ++ "appended\n", contents);
|
||||
}
|
||||
|
||||
test "a target change does not inherit a pending rotation" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
fx.openOn(fx.path(&first_buf, "first.log"));
|
||||
const second = fx.path(&second_buf, "second.log");
|
||||
|
||||
// A rotation the old target owed and never completed: the handle is closed
|
||||
// and the rotation is still pending.
|
||||
const stale = fx.snapshot().file.?;
|
||||
stale.close(fx.io());
|
||||
fx.setHandleState(null, 0, true);
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
|
||||
try testing.expect(!prepared.sink.?.rotate_pending);
|
||||
retireApply(fx.io(), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expect(!after.rotate_pending);
|
||||
try testing.expect(after.file != null);
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
}
|
||||
|
||||
test "a file to stderr apply detaches the handle and clears position and rotation" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
fx.setHandleState(fx.snapshot().file, 4_096, true);
|
||||
const before = fx.snapshot();
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .stderr,
|
||||
.file_path = live,
|
||||
.level = .warn,
|
||||
}, 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_removed, prepared.case);
|
||||
|
||||
const detached = publishApply(prepared);
|
||||
const after = fx.snapshot();
|
||||
|
||||
try testing.expectEqual(before.file.?.handle, detached.?.handle);
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqual(@as(u64, 0), after.file_pos);
|
||||
try testing.expect(!after.rotate_pending);
|
||||
try testing.expectEqual(model.LogOutput.stderr, after.output);
|
||||
// The path still moves, so a later switch back to file opens the right one.
|
||||
try testing.expectEqualStrings(live, after.path());
|
||||
|
||||
retireApply(fx.io(), detached);
|
||||
}
|
||||
|
||||
test "a same-path apply changes only config fields, whatever the handle is doing" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const live = fx.path(&buf, "nxdns.log");
|
||||
fx.openOn(live);
|
||||
|
||||
// Publish takes the same lock a rotation and a write-failure closure hold,
|
||||
// so the only reachable interleavings are "before publish" and "after".
|
||||
// Both leave the handle state the apply must not touch; these are the two
|
||||
// states each of them leaves behind.
|
||||
const handle_states = [_]struct { file: bool, pos: u64, pending: bool }{
|
||||
// Mid-rotation: handle closed, rotation owed.
|
||||
.{ .file = false, .pos = 0, .pending = true },
|
||||
// Healthy and part-written.
|
||||
.{ .file = true, .pos = 8_192, .pending = false },
|
||||
};
|
||||
|
||||
const open_handle = fx.snapshot().file.?;
|
||||
for (handle_states) |want| {
|
||||
fx.setHandleState(if (want.file) open_handle else null, want.pos, want.pending);
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .file,
|
||||
.file_path = live,
|
||||
.level = .debug,
|
||||
.max_files = 9,
|
||||
.max_size_mb = 7,
|
||||
}, 4_242);
|
||||
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
|
||||
|
||||
// Nothing detached, so retire has nothing to close.
|
||||
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(want.pos, after.file_pos);
|
||||
try testing.expectEqual(want.pending, after.rotate_pending);
|
||||
try testing.expectEqual(want.file, after.file != null);
|
||||
// The config fields did move.
|
||||
try testing.expectEqual(std.log.Level.debug, after.threshold);
|
||||
try testing.expectEqual(@as(u8, 9), after.max_files);
|
||||
try testing.expectEqual(@as(u64, 4_242), after.max_bytes);
|
||||
}
|
||||
|
||||
fx.setHandleState(open_handle, 0, false);
|
||||
}
|
||||
|
||||
test "a file_path change while output is stderr updates the config and touches no handle" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const later = fx.path(&buf, "later.log");
|
||||
|
||||
const prepared = try prepareApplyForTest(fx.io(), .{
|
||||
.output = .syslog,
|
||||
.file_path = later,
|
||||
.level = .info,
|
||||
}, 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
|
||||
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqualStrings(later, after.path());
|
||||
|
||||
// The later switch to file opens exactly that path.
|
||||
const to_file = try prepareApplyForTest(fx.io(), fileCfg(later), 1 << 20);
|
||||
try testing.expectEqual(ApplyCase.target_changed, to_file.case);
|
||||
retireApply(fx.io(), publishApply(to_file));
|
||||
try testing.expect(fx.snapshot().file != null);
|
||||
}
|
||||
|
||||
test "an aborted apply closes the target it opened" {
|
||||
var fx: ApplyFixture = undefined;
|
||||
fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [160]u8 = undefined;
|
||||
const target = fx.path(&buf, "never.log");
|
||||
|
||||
// The commit failed, so the prepared target must not leak its descriptor.
|
||||
const prepared = try prepareApplyForTest(fx.io(), fileCfg(target), 1 << 20);
|
||||
abortApply(fx.io(), prepared);
|
||||
|
||||
const after = fx.snapshot();
|
||||
try testing.expectEqual(@as(?std.Io.File, null), after.file);
|
||||
try testing.expectEqual(model.LogOutput.stderr, after.output);
|
||||
}
|
||||
|
||||
test "logDirname follows output and file_path in both directions" {
|
||||
try testing.expectEqualStrings("/var/log/nxdns", logDirname(.{
|
||||
.output = .file,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}).?);
|
||||
// A bare filename lands in the working directory.
|
||||
try testing.expectEqualStrings(".", logDirname(.{
|
||||
.output = .file,
|
||||
.file_path = "nxdns.log",
|
||||
}).?);
|
||||
// Output away from file stops the measurement whatever the path says.
|
||||
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
|
||||
.output = .stderr,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}));
|
||||
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
|
||||
.output = .syslog,
|
||||
.file_path = "/var/log/nxdns/nxdns.log",
|
||||
}));
|
||||
}
|
||||
|
||||
+274
-8
@@ -109,10 +109,13 @@ pub const CertStore = struct {
|
||||
pub const Kind = enum { doh, dot };
|
||||
|
||||
gpa: std.mem.Allocator,
|
||||
/// Borrowed from the config; must outlive the store.
|
||||
cert_path: []const u8,
|
||||
/// Borrowed from the config; must outlive the store.
|
||||
key_path: []const u8,
|
||||
/// Owned. A settings apply replaces both paths, so a borrowed config slice
|
||||
/// would dangle the moment the row it came from went away. Read and
|
||||
/// written only under `reload_mutex`, which every apply and every reload
|
||||
/// holds for its whole read-build-publish sequence.
|
||||
cert_path: []u8,
|
||||
/// Owned; see `cert_path`.
|
||||
key_path: []u8,
|
||||
/// Passed through to every `ServerContext.init`; the same lifetime rule
|
||||
/// applies — a comptime-constant NULL-terminated array, never memory that
|
||||
/// can go away before the store.
|
||||
@@ -136,7 +139,8 @@ pub const CertStore = struct {
|
||||
/// Test seam: runs inside `reload` between a successful `load` and the
|
||||
/// publish, i.e. inside `reload_mutex`. Lets a test occupy the window
|
||||
/// where an unserialized reload could be overtaken. Must not call
|
||||
/// `reload` synchronously (that would self-deadlock on `reload_mutex`).
|
||||
/// `reload`, `pollOnce` or `preparePathChange` synchronously — all four
|
||||
/// take `reload_mutex`, which is not reentrant.
|
||||
after_load_hook: ?ReloadHook,
|
||||
|
||||
/// Set by the composition root right after `init`, with `diagnostics`.
|
||||
@@ -168,10 +172,17 @@ pub const CertStore = struct {
|
||||
alpn: ?[*:null]const ?[*:0]const u8,
|
||||
) ReloadError!CertStore {
|
||||
const first = try load(gpa, io, cert_path, key_path, alpn);
|
||||
errdefer {
|
||||
first.entry.ctx.deinit(gpa);
|
||||
gpa.destroy(first.entry);
|
||||
}
|
||||
const owned_cert = try gpa.dupe(u8, cert_path);
|
||||
errdefer gpa.free(owned_cert);
|
||||
const owned_key = try gpa.dupe(u8, key_path);
|
||||
return .{
|
||||
.gpa = gpa,
|
||||
.cert_path = cert_path,
|
||||
.key_path = key_path,
|
||||
.cert_path = owned_cert,
|
||||
.key_path = owned_key,
|
||||
.alpn = alpn,
|
||||
.mutex = .init,
|
||||
.reload_mutex = .init,
|
||||
@@ -193,6 +204,8 @@ pub const CertStore = struct {
|
||||
std.debug.assert(current.refs == 0);
|
||||
self.mutex.unlock(io);
|
||||
self.destroyEntry(current);
|
||||
self.gpa.free(self.cert_path);
|
||||
self.gpa.free(self.key_path);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
@@ -226,7 +239,14 @@ pub const CertStore = struct {
|
||||
pub fn reload(self: *CertStore, io: std.Io) ReloadError!void {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
defer self.reload_mutex.unlock(io);
|
||||
return self.reloadLocked(io);
|
||||
}
|
||||
|
||||
/// `reload`'s body, for callers that already hold `reload_mutex` —
|
||||
/// `pollOnce` does, because it must read `cert_path`/`key_path` under the
|
||||
/// same lock an apply replaces them under. `std.Io.Mutex` is not
|
||||
/// reentrant, so this exists rather than a recursive `reload` call.
|
||||
fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void {
|
||||
const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| {
|
||||
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
||||
return err;
|
||||
@@ -247,6 +267,93 @@ pub const CertStore = struct {
|
||||
self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic);
|
||||
}
|
||||
|
||||
/// A candidate certificate loaded from new paths, not yet published.
|
||||
/// Holding one means holding `reload_mutex`: exactly one of
|
||||
/// `publishPathChange` or `abortPathChange` must follow, and it releases
|
||||
/// the lock.
|
||||
pub const PreparedPaths = struct {
|
||||
cert_path: []u8,
|
||||
key_path: []u8,
|
||||
loaded: Loaded,
|
||||
/// Read at prepare so publish reads no clock: publish must touch
|
||||
/// nothing outside memory it already owns.
|
||||
loaded_at_unix: i64,
|
||||
};
|
||||
|
||||
/// Prepare half of a `doh_server`/`dot_server` cert-path change: takes
|
||||
/// `reload_mutex` and loads the certificate and key from the NEW paths.
|
||||
/// Nothing is published, so a failure leaves the store exactly as it was —
|
||||
/// the old certificate keeps serving and the caller writes no DB row. The
|
||||
/// lock is released on failure and held on success, which is what makes
|
||||
/// the whole apply serialized against `reload` and `pollOnce`.
|
||||
pub fn preparePathChange(
|
||||
self: *CertStore,
|
||||
io: std.Io,
|
||||
cert_path: []const u8,
|
||||
key_path: []const u8,
|
||||
) ReloadError!PreparedPaths {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
errdefer self.reload_mutex.unlock(io);
|
||||
|
||||
const owned_cert = try self.gpa.dupe(u8, cert_path);
|
||||
errdefer self.gpa.free(owned_cert);
|
||||
const owned_key = try self.gpa.dupe(u8, key_path);
|
||||
errdefer self.gpa.free(owned_key);
|
||||
|
||||
const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| {
|
||||
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
||||
return err;
|
||||
};
|
||||
return .{
|
||||
.cert_path = owned_cert,
|
||||
.key_path = owned_key,
|
||||
.loaded = next,
|
||||
.loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Publish half: infallible and I/O-free. The paths and the generation
|
||||
/// they were loaded from are installed together — the generation `mutex`
|
||||
/// is taken only for that swap, inside `reload_mutex`, the same lock order
|
||||
/// `reload` uses. Releases `reload_mutex`.
|
||||
///
|
||||
/// Connections that pinned the old generation finish on the old
|
||||
/// certificate; the old entry is freed once its last reader releases.
|
||||
pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
||||
const old_cert_path = self.cert_path;
|
||||
const old_key_path = self.key_path;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
const old = self.current;
|
||||
self.cert_path = prepared.cert_path;
|
||||
self.key_path = prepared.key_path;
|
||||
self.current = prepared.loaded.entry;
|
||||
self.loaded = prepared.loaded.sig;
|
||||
old.retired = true;
|
||||
const free_old = old.refs == 0;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// The branch runs before the counter bump, never after: a `bool` still
|
||||
// live across an atomic read-modify-write is the zig 0.16.0 Debug
|
||||
// miscompile AGENTS.md documents.
|
||||
if (free_old) self.destroyEntry(old);
|
||||
self.gpa.free(old_cert_path);
|
||||
self.gpa.free(old_key_path);
|
||||
|
||||
_ = self.reloads.fetchAdd(1, .monotonic);
|
||||
self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic);
|
||||
self.reload_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Discards a prepared candidate — the commit that would have published it
|
||||
/// failed, or a sibling owner's prepare did. Releases `reload_mutex`.
|
||||
pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
||||
self.gpa.free(prepared.cert_path);
|
||||
self.gpa.free(prepared.key_path);
|
||||
self.destroyEntry(prepared.loaded.entry);
|
||||
self.reload_mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Sleep first: `init` just loaded the files this poll would compare
|
||||
/// against. `.boot` so a suspended box still sees the interval elapse.
|
||||
pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void {
|
||||
@@ -265,7 +372,15 @@ pub const CertStore = struct {
|
||||
/// changed, and the old one keeps serving either way. A failed reload
|
||||
/// warns and counts (`reload_failures`); the signature stays at the loaded
|
||||
/// pair, so every subsequent poll retries until the files parse.
|
||||
///
|
||||
/// The whole pass runs under `reload_mutex`: the paths it stats are the
|
||||
/// ones an apply replaces, and a poll that read a path outside the lock
|
||||
/// could stat a freed slice or reload a pair that was never published
|
||||
/// together.
|
||||
pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void {
|
||||
self.reload_mutex.lockUncancelable(io);
|
||||
defer self.reload_mutex.unlock(io);
|
||||
|
||||
const cert_sig = statSig(io, self.cert_path) catch |err| {
|
||||
log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path});
|
||||
self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err));
|
||||
@@ -290,7 +405,7 @@ pub const CertStore = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.reload(io)) {
|
||||
if (self.reloadLocked(io)) {
|
||||
log.info("certificate reloaded from {s}", .{self.cert_path});
|
||||
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
|
||||
} else |err| {
|
||||
@@ -993,3 +1108,154 @@ test "an unchanged poll closes the episode a transient stat failure opened" {
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path apply (milestone-34 S3.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "a bad candidate is refused at prepare and the store keeps serving" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const before = store.acquire(io);
|
||||
store.release(io, before);
|
||||
const before_paths_cert = store.cert_path;
|
||||
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" });
|
||||
var bad_buf: [128]u8 = undefined;
|
||||
const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path});
|
||||
|
||||
try testing.expectError(
|
||||
error.CertParse,
|
||||
store.preparePathChange(io, bad_path, env.key_path),
|
||||
);
|
||||
|
||||
// Nothing published: same generation, same paths, and `reload_mutex` was
|
||||
// released — a second prepare would deadlock otherwise.
|
||||
const after = store.acquire(io);
|
||||
store.release(io, after);
|
||||
try testing.expectEqual(before, after);
|
||||
try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr);
|
||||
try testing.expectEqualStrings(env.cert_path, store.cert_path);
|
||||
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
|
||||
|
||||
// A missing candidate path is refused the same way.
|
||||
try testing.expectError(
|
||||
error.CertUnreadable,
|
||||
store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path),
|
||||
);
|
||||
}
|
||||
|
||||
test "a published path change installs the new pair and retires the old generation" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
// A second, byte-different copy of the same valid pair under new names.
|
||||
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
||||
defer testing.allocator.free(grown);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
||||
|
||||
// A connection pinned to the old generation finishes on it.
|
||||
const pinned = store.acquire(io);
|
||||
|
||||
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
||||
store.publishPathChange(io, prepared);
|
||||
|
||||
try testing.expectEqualStrings(next_cert, store.cert_path);
|
||||
try testing.expectEqualStrings(next_key, store.key_path);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
||||
try testing.expect(pinned.retired);
|
||||
|
||||
const serving = store.acquire(io);
|
||||
try testing.expect(serving != pinned);
|
||||
store.release(io, serving);
|
||||
store.release(io, pinned);
|
||||
|
||||
// The watcher now measures the new pair, so an untouched pair polls clean
|
||||
// and a rewritten one reloads.
|
||||
store.pollOnce(io, 1_000);
|
||||
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem });
|
||||
store.pollOnce(io, 1_100);
|
||||
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
||||
}
|
||||
|
||||
test "an aborted path change frees the candidate and leaves the store untouched" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const before = store.acquire(io);
|
||||
store.release(io, before);
|
||||
|
||||
// The commit this candidate was built for failed; the testing allocator
|
||||
// proves the abort frees everything the prepare took.
|
||||
const prepared = try store.preparePathChange(io, env.cert_path, env.key_path);
|
||||
store.abortPathChange(io, prepared);
|
||||
|
||||
const after = store.acquire(io);
|
||||
store.release(io, after);
|
||||
try testing.expectEqual(before, after);
|
||||
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
||||
|
||||
// `reload_mutex` came back, so the store still reloads.
|
||||
try store.reload(io);
|
||||
}
|
||||
|
||||
test "a reload racing a path apply is serialized behind it" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
||||
defer testing.allocator.free(grown);
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
||||
|
||||
// Prepare holds `reload_mutex` across the whole apply.
|
||||
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
||||
try testing.expect(!store.reload_mutex.tryLock());
|
||||
|
||||
// The concurrent reload cannot start, so it cannot publish the OLD paths
|
||||
// over the new generation.
|
||||
var racing = try io.concurrent(CertStore.reload, .{ &store, io });
|
||||
store.publishPathChange(io, prepared);
|
||||
try racing.await(io);
|
||||
|
||||
// Two publications, and the last word is the apply's pair: the racing
|
||||
// reload reread the paths the apply installed.
|
||||
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
||||
try testing.expectEqualStrings(next_cert, store.cert_path);
|
||||
store.mutex.lockUncancelable(io);
|
||||
const final = store.loaded;
|
||||
store.mutex.unlock(io);
|
||||
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
|
||||
}
|
||||
|
||||
+59
-18
@@ -27,6 +27,7 @@ const db = @import("../storage/db.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const events = @import("../storage/events.zig");
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const retention = @import("../storage/retention.zig");
|
||||
|
||||
const log = std.log.scoped(.clients);
|
||||
|
||||
@@ -59,7 +60,7 @@ pub const Tracker = struct {
|
||||
/// Guards `pending`, `count`, `passes` and `stats`. Every field below is
|
||||
/// written under it, so a reader takes it too; see `snapshotStats`.
|
||||
mutex: std.Io.Mutex,
|
||||
retention_days: u16,
|
||||
retention_days: *const retention.RetentionDays,
|
||||
pending: [max_pending]Pending,
|
||||
count: u32,
|
||||
passes: u64,
|
||||
@@ -70,8 +71,9 @@ pub const Tracker = struct {
|
||||
|
||||
/// `retention_days` is `logging.retention_days`, the same knob the query log
|
||||
/// prunes by (milestone-7 ruling 16). A client silent for that long is as
|
||||
/// uninteresting as a query that old.
|
||||
pub fn init(retention_days: u16) Tracker {
|
||||
/// uninteresting as a query that old — so both consumers share ONE cell
|
||||
/// and a settings apply moves them together.
|
||||
pub fn init(retention_days: *const retention.RetentionDays) Tracker {
|
||||
return .{
|
||||
.mutex = .init,
|
||||
.retention_days = retention_days,
|
||||
@@ -225,7 +227,7 @@ pub const Tracker = struct {
|
||||
self.mutex.unlock(io);
|
||||
|
||||
if (due) {
|
||||
const cutoff = now_s - @as(i64, self.retention_days) * 86_400;
|
||||
const cutoff = now_s - self.retention_days.seconds();
|
||||
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.stats.pruned += deleted;
|
||||
@@ -328,7 +330,8 @@ test "a client tracked twice before a flush yields one row at the later time" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
// A table with room reports no drops.
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
|
||||
@@ -354,7 +357,8 @@ test "distinct clients each get a row and ipv6 text is canonical" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||
_ = tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||
@@ -378,7 +382,8 @@ test "a full table drops further clients and counts them" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
for (0..Tracker.max_pending) |i| {
|
||||
var octets: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &octets, @intCast(i), .big);
|
||||
@@ -421,7 +426,8 @@ test "a flush touches a hand-edited row without changing what the operator set"
|
||||
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
|
||||
@@ -449,7 +455,8 @@ test "a gated pass writes nothing and keeps the pending clients" {
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
|
||||
|
||||
@@ -477,7 +484,8 @@ test "a failing upsert counts and leaves the client to be tracked again" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
@@ -507,7 +515,8 @@ test "the pass that comes due prunes the clients that went quiet" {
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
// Every pass before the due one leaves both rows alone.
|
||||
for (0..Tracker.prune_every_passes - 1) |_| {
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
@@ -534,7 +543,8 @@ test "a shorter retention prunes what the default keeps" {
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||
|
||||
var tracker: Tracker = .init(1);
|
||||
var days: retention.RetentionDays = .init(1);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
|
||||
@@ -542,6 +552,31 @@ test "a shorter retention prunes what the default keeps" {
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
}
|
||||
|
||||
test "setRetentionDays changes the cutoff the next prune pass uses" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||
|
||||
var days: retention.RetentionDays = .init(7);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
|
||||
// The shared cell, not a copy taken at construction.
|
||||
days.setRetentionDays(1);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, null);
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
}
|
||||
|
||||
test "the run loop flushes on its interval and returns on cancel" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -550,7 +585,8 @@ test "the run loop flushes on its interval and returns on cancel" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
|
||||
var future = try io.concurrent(Tracker.run, .{
|
||||
@@ -622,7 +658,8 @@ test "the drain lands before any exchange, and attempts stop at the cap" {
|
||||
names.exchange_fn = CountingExchange.exchange;
|
||||
CountingExchange.reset(&database);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
const pending = clients_repo.max_per_pass + 4;
|
||||
for (0..pending) |i| {
|
||||
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
|
||||
@@ -656,7 +693,8 @@ test "a row the due pass prunes is never asked about" {
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true, &names);
|
||||
|
||||
@@ -681,7 +719,8 @@ test "a gated pass attempts no naming either" {
|
||||
CountingExchange.reset(&database);
|
||||
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.flushOnce(io, &database, false, &names);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
|
||||
@@ -700,7 +739,8 @@ test "a failing materialise opens one episode per pass and a clean pass closes i
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.diagnostics = &fx.store;
|
||||
|
||||
try database.exec(
|
||||
@@ -742,7 +782,8 @@ test "a failing prune opens its own episode the next due pass closes" {
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
var days: retention.RetentionDays = .init(30);
|
||||
var tracker: Tracker = .init(&days);
|
||||
tracker.diagnostics = &fx.store;
|
||||
// One pass short of due, so the pass below is the pruning one.
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
pub const dns_query_path = "/dns-query";
|
||||
|
||||
@@ -619,6 +620,7 @@ const Harness = struct {
|
||||
store: cert_store.CertStore,
|
||||
tables: local_tables_mod.LocalTables,
|
||||
upstream: FailingUpstream,
|
||||
upstream_owner: upstream_owner.Borrowed,
|
||||
h: handler.Handler,
|
||||
server: DohServer,
|
||||
group: std.Io.Group,
|
||||
@@ -646,10 +648,10 @@ const Harness = struct {
|
||||
errdefer hx.tables.deinit(testing.allocator);
|
||||
|
||||
hx.upstream = .{};
|
||||
hx.upstream_owner = .{};
|
||||
hx.h = .{
|
||||
.upstream = hx.upstream.client(),
|
||||
.blocking = test_blocking,
|
||||
.forward_read_timeout = test_forward_timeout,
|
||||
.upstream = hx.upstream_owner.client(hx.upstream.client()),
|
||||
.policy = .{ .blocking = test_blocking, .forward_read_timeout = test_forward_timeout },
|
||||
.local_tables = &hx.tables,
|
||||
};
|
||||
|
||||
|
||||
+103
-9
@@ -28,6 +28,7 @@ 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 upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
|
||||
/// record tail pass through here, while whole messages go straight to
|
||||
@@ -312,11 +313,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -567,7 +567,8 @@ test "dot: two framed queries share one TLS connection" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -607,7 +608,8 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -645,7 +647,8 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -731,7 +734,8 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
@@ -759,6 +763,95 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
|
||||
};
|
||||
}
|
||||
|
||||
/// S3.4 across a live listener: a cert PATH change — new files, not rewritten
|
||||
/// ones — serves the new certificate on the next handshake while the
|
||||
/// connection pinned to the old generation finishes on it.
|
||||
fn dotPathChangeServesNewCert(io: std.Io, address_: std.Io.net.IpAddress, env: *CertEnv) anyerror!void {
|
||||
var first: TestTls = undefined;
|
||||
try first.connect(io, address_);
|
||||
defer first.close(io);
|
||||
|
||||
try first.sendQuery();
|
||||
try expectAnswersQuery(try first.readReply());
|
||||
|
||||
const old_entry = env.store.acquire(io);
|
||||
defer env.store.release(io, old_entry);
|
||||
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert2.pem", .data = fixtures.cert2_pem });
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "key2.pem", .data = fixtures.key2_pem });
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert2.pem", .{env.tmp.sub_path});
|
||||
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key2.pem", .{env.tmp.sub_path});
|
||||
|
||||
const prepared = try env.store.preparePathChange(io, next_cert, next_key);
|
||||
env.store.publishPathChange(io, prepared);
|
||||
|
||||
const new_entry = env.store.acquire(io);
|
||||
defer env.store.release(io, new_entry);
|
||||
try testing.expect(old_entry != new_entry);
|
||||
|
||||
var second: TestTls = undefined;
|
||||
try second.connect(io, address_);
|
||||
defer second.close(io);
|
||||
try second.sendQuery();
|
||||
try expectAnswersQuery(try second.readReply());
|
||||
|
||||
try first.sendQuery();
|
||||
try expectAnswersQuery(try first.readReply());
|
||||
|
||||
try second.client.end();
|
||||
try second.net_writer.interface.flush();
|
||||
var second_tail: [1]u8 = undefined;
|
||||
try testing.expectError(error.EndOfStream, second.client.reader.readSliceAll(&second_tail));
|
||||
|
||||
try first.client.end();
|
||||
try first.net_writer.interface.flush();
|
||||
var first_tail: [1]u8 = undefined;
|
||||
try testing.expectError(error.EndOfStream, first.client.reader.readSliceAll(&first_tail));
|
||||
}
|
||||
|
||||
test "dot: a cert path change serves the new certificate on the next handshake" {
|
||||
const build_options = @import("build_options");
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var env: CertEnv = undefined;
|
||||
try env.init(io);
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, DotServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, dotPathChangeServesNewCert, .{ io, server_address, &env });
|
||||
|
||||
const stats = server.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 2), stats.connections);
|
||||
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
|
||||
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
|
||||
|
||||
const store_stats = env.store.snapshotStats();
|
||||
try testing.expectEqual(@as(u64, 1), store_stats.reloads);
|
||||
try testing.expectEqual(@as(u64, 0), store_stats.reload_failures);
|
||||
|
||||
server.deinit(io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "dot: an idle connection is closed with close_notify and counted" {
|
||||
const build_options = @import("build_options");
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
@@ -773,7 +866,8 @@ test "dot: an idle connection is closed with close_notify and counted" {
|
||||
defer env.deinit(io);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{
|
||||
|
||||
+532
-142
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ const forward_zones = @import("../local/forward_zones.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const local_tables = @import("local_tables.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const provenance = @import("../storage/provenance.zig");
|
||||
const manager = @import("../filter/manager.zig");
|
||||
@@ -47,8 +48,10 @@ const rate_limiter = @import("rate_limiter.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const shutdown = @import("shutdown.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
|
||||
@@ -79,11 +82,10 @@ const zone_ttl: u32 = 120;
|
||||
|
||||
/// The handler every case starts from: an upstream, the blocking options and
|
||||
/// the empty local tables. Each case wires in the collaborators it exercises.
|
||||
fn baseHandler(client: transport.Client) handler.Handler {
|
||||
fn baseHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +267,11 @@ fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||
.paths = undefined,
|
||||
.fetcher = undefined,
|
||||
.update = .{},
|
||||
.schedule_mutex = .init,
|
||||
.schedule_version = 0,
|
||||
.schedule_anchor_s = null,
|
||||
.schedule_event = .unset,
|
||||
.schedule_clock = .real,
|
||||
.total_budget = forward_timeout,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
@@ -317,10 +324,12 @@ test "S7 case 1: a blocked domain is answered with the zero address and logged"
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -374,7 +383,8 @@ test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -411,7 +421,8 @@ test "S7 case 3: a local record answers authoritatively without an upstream" {
|
||||
defer table.deinit(gpa);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
var tables: local_tables.LocalTables = .{ .records = table };
|
||||
h.local_tables = &tables;
|
||||
|
||||
@@ -479,12 +490,13 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
|
||||
defer cache.deinit();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
var tables: local_tables.LocalTables = .{ .zones = zones };
|
||||
h.local_tables = &tables;
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
h.policy.negative_ttl_max = 3600;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
@@ -533,12 +545,14 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
h.policy.negative_ttl_max = 3600;
|
||||
h.sink = &sink;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -622,10 +636,12 @@ test "S7 case 6: a cname into a blocked target blocks the original question" {
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -681,7 +697,8 @@ test "S7 case 7: safe search answers the original question with a cname to the t
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
@@ -733,10 +750,12 @@ test "S7 case 8: the third query inside the window is refused" {
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.limiter = &limiter;
|
||||
h.sink = &sink;
|
||||
|
||||
@@ -785,7 +804,8 @@ test "S7 case 9: pause lifts filtering and unpause restores it" {
|
||||
paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.manager = &mgr;
|
||||
h.pause = &paused;
|
||||
|
||||
@@ -831,10 +851,12 @@ test "S7 case 10: the querying client is materialised as a row" {
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var tracker: clients.Tracker = .init(30);
|
||||
var retention_days: retention_mod.RetentionDays = .init(30);
|
||||
var tracker: clients.Tracker = .init(&retention_days);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = baseHandler(h_owner.client(fake.client()));
|
||||
h.tracker = &tracker;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
|
||||
@@ -13,24 +13,36 @@
|
||||
const std = @import("std");
|
||||
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const sse = @import("../web/sse.zig");
|
||||
|
||||
pub const QuerySink = struct {
|
||||
logger: *logger.Logger,
|
||||
/// The controller, not a `Logger`: `logging.query_log_buffer_max` can
|
||||
/// change while the server runs, and the generation a producer enqueues
|
||||
/// into has to be the one that is live at that moment.
|
||||
controller: *logger_controller.Controller,
|
||||
/// Null when `web.enabled` is false: nothing subscribes, so nothing needs
|
||||
/// a hub, and the DNS path pays one null check.
|
||||
hub: ?*sse.Hub,
|
||||
|
||||
pub fn init(query_logger: *logger.Logger, hub: ?*sse.Hub) QuerySink {
|
||||
return .{ .logger = query_logger, .hub = hub };
|
||||
pub fn init(controller: *logger_controller.Controller, hub: ?*sse.Hub) QuerySink {
|
||||
return .{ .controller = controller, .hub = hub };
|
||||
}
|
||||
|
||||
/// Transforms once, publishes, then enqueues. Never blocks the query path
|
||||
/// and never fails: both consumers drop rather than wait.
|
||||
///
|
||||
/// The borrow spans both halves. A resize that lands between them would
|
||||
/// otherwise leave this entry going into a queue retirement has already
|
||||
/// closed, and that is exactly the drop window the controller exists to
|
||||
/// make impossible.
|
||||
pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void {
|
||||
const transformed = self.logger.transformed(entry);
|
||||
const generation = self.controller.acquire(io);
|
||||
defer self.controller.release(io, generation);
|
||||
|
||||
const transformed = generation.logger.transformed(entry);
|
||||
if (self.hub) |hub| hub.publish(io, transformed);
|
||||
self.logger.logTransformed(io, transformed);
|
||||
generation.logger.logTransformed(io, transformed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,7 +72,8 @@ test "the sink publishes and logs the same entry" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -87,7 +100,8 @@ test "fanout does not depend on the entry reaching the queue" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -115,7 +129,8 @@ test "the privacy transforms run once, before both consumers" {
|
||||
.{ .hide_domains = true, .hide_client_ips = true },
|
||||
&queue_buf,
|
||||
);
|
||||
var sink: QuerySink = .init(&query_logger, hub);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
@@ -138,7 +153,8 @@ test "a sink without a hub still logs" {
|
||||
|
||||
var queue_buf: [4]logger.Entry = undefined;
|
||||
var query_logger: logger.Logger = .init(.{}, &queue_buf);
|
||||
var sink: QuerySink = .init(&query_logger, null);
|
||||
var owner: logger_controller.Borrowed = .{};
|
||||
var sink: QuerySink = .init(owner.over(&query_logger), null);
|
||||
|
||||
sink.log(io, sampleEntry(5, "nohub.example"));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const types = @import("../dns/types.zig");
|
||||
const health = @import("../upstream/health.zig");
|
||||
const pool = @import("../upstream/pool.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -42,11 +43,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listeners and the pool are what this test exercises, so the handler is the
|
||||
/// same bare one its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +275,9 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
|
||||
};
|
||||
var upstreams: pool.Pool = .init(&entries, test_cfg, pool_timeouts, 1);
|
||||
|
||||
var h = bareHandler(upstreams.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
|
||||
var h = bareHandler(h_owner.client(upstreams.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -21,6 +21,7 @@ const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -37,11 +38,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ test "two length-prefixed queries share one connection" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
@@ -205,7 +206,8 @@ test "the claimed slot records the connecting client" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
@@ -282,7 +284,8 @@ test "a canceled serve does not wait for a live connection" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
// The idle budget is the whole time a drain would have to wait out, so it
|
||||
@@ -344,7 +347,8 @@ test "an idle connection is closed and counted" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -377,7 +381,8 @@ test "a zero-length message is a connection error" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -429,7 +434,8 @@ test "deinit ends a serve loop that is blocked on accept" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
|
||||
@@ -20,6 +20,7 @@ const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -36,11 +37,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.upstream = up,
|
||||
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ test "a udp query is answered on the loopback" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -150,7 +151,8 @@ test "a runt datagram is dropped and no reply is sent" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -185,7 +187,8 @@ test "an oversize datagram arrives truncated and is dropped" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -226,7 +229,8 @@ test "deinit ends a serve loop that is blocked on receive" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
var h_owner: upstream_owner.Borrowed = .{};
|
||||
var h = bareHandler(h_owner.client(fake.client()));
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -33,11 +33,65 @@ pub fn classify(free_bytes: u64, cfg: model.Disk) State {
|
||||
return .ok;
|
||||
}
|
||||
|
||||
/// `min_free_mb` and `warn_free_mb` are one invariant pair — `classify` reads
|
||||
/// both and reports the more severe verdict — so they live in one atomic word
|
||||
/// and a reader unpacks a single load. Two atomics would let a sample land
|
||||
/// between the two stores and classify against half of one configuration and
|
||||
/// half of another.
|
||||
fn packThresholds(d: model.Disk) u64 {
|
||||
return (@as(u64, d.min_free_mb) << 32) | d.warn_free_mb;
|
||||
}
|
||||
|
||||
fn unpackThresholds(bits: u64) model.Disk {
|
||||
return .{
|
||||
.min_free_mb = @truncate(bits >> 32),
|
||||
.warn_free_mb = @truncate(bits),
|
||||
};
|
||||
}
|
||||
|
||||
/// Where the measured log directory comes from. `sample` borrows the path
|
||||
/// across a directory scan, so the path cannot simply be replaced under it: a
|
||||
/// reader pins a generation for the whole borrow and `setLogDir` retires the
|
||||
/// old one, which is freed by whichever of the two — the last reader or the
|
||||
/// setter — finds it retired with no refs.
|
||||
pub const LogDirSource = union(enum) {
|
||||
/// The path `init` was given, borrowed from the config. It outlives the
|
||||
/// process, so a reader still holding it after a swap is safe and it needs
|
||||
/// no pin. Null means logs do not go to a file.
|
||||
boot: ?[:0]const u8,
|
||||
/// Every generation `setLogDir` installs. Null means the same as above.
|
||||
installed: ?*LogDir,
|
||||
};
|
||||
|
||||
/// A reader's hold on the log directory for the length of one scan. `pinned`
|
||||
/// is null for the boot source, which nothing frees.
|
||||
pub const LogDirBorrow = struct {
|
||||
path: ?[:0]const u8,
|
||||
pinned: ?*LogDir,
|
||||
};
|
||||
|
||||
pub const LogDir = struct {
|
||||
path: [:0]const u8,
|
||||
refs: u32 = 0,
|
||||
retired: bool = false,
|
||||
/// Non-null exactly for heap generations, and the allocator that frees
|
||||
/// them.
|
||||
gpa: ?std.mem.Allocator = null,
|
||||
|
||||
fn destroy(self: *LogDir) void {
|
||||
const gpa = self.gpa orelse return;
|
||||
gpa.free(self.path);
|
||||
gpa.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Monitor = struct {
|
||||
cfg: model.Disk,
|
||||
thresholds_packed: std.atomic.Value(u64),
|
||||
data_dir: std.Io.Dir,
|
||||
data_path: [:0]const u8,
|
||||
log_dir_path: ?[:0]const u8,
|
||||
/// Guards `log_dir` and every generation's `refs`/`retired`.
|
||||
log_dir_mutex: std.Io.Mutex,
|
||||
log_dir: LogDirSource,
|
||||
|
||||
state_raw: std.atomic.Value(u8),
|
||||
free_bytes: std.atomic.Value(u64),
|
||||
@@ -57,10 +111,11 @@ pub const Monitor = struct {
|
||||
log_dir_path: ?[:0]const u8,
|
||||
) Monitor {
|
||||
return .{
|
||||
.cfg = cfg,
|
||||
.thresholds_packed = .init(packThresholds(cfg)),
|
||||
.data_dir = data_dir,
|
||||
.data_path = data_path,
|
||||
.log_dir_path = log_dir_path,
|
||||
.log_dir_mutex = .init,
|
||||
.log_dir = .{ .boot = log_dir_path },
|
||||
.state_raw = .init(@intFromEnum(State.ok)),
|
||||
.free_bytes = .init(0),
|
||||
.db_bytes = .init(0),
|
||||
@@ -69,6 +124,90 @@ pub const Monitor = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// The live threshold pair, from one load: `warn >= min` holds for every
|
||||
/// value this ever returns, whatever a concurrent `setThresholds` does.
|
||||
pub fn thresholds(self: *const Monitor) model.Disk {
|
||||
return unpackThresholds(self.thresholds_packed.load(.monotonic));
|
||||
}
|
||||
|
||||
pub fn setThresholds(self: *Monitor, d: model.Disk) void {
|
||||
self.thresholds_packed.store(packThresholds(d), .monotonic);
|
||||
}
|
||||
|
||||
/// Pins the log directory for one scan. Every borrow is matched by a
|
||||
/// `releaseLogDir`, which is what lets `setLogDir` free a generation the
|
||||
/// moment no scan is reading its path.
|
||||
pub fn acquireLogDir(self: *Monitor, io: std.Io) LogDirBorrow {
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
defer self.log_dir_mutex.unlock(io);
|
||||
|
||||
switch (self.log_dir) {
|
||||
.boot => |path| return .{ .path = path, .pinned = null },
|
||||
.installed => |maybe| {
|
||||
const gen = maybe orelse return .{ .path = null, .pinned = null };
|
||||
gen.refs += 1;
|
||||
return .{ .path = gen.path, .pinned = gen };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn releaseLogDir(self: *Monitor, io: std.Io, borrow: LogDirBorrow) void {
|
||||
const gen = borrow.pinned orelse return;
|
||||
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
std.debug.assert(gen.refs > 0);
|
||||
gen.refs -= 1;
|
||||
const free_it = gen.retired and gen.refs == 0;
|
||||
self.log_dir_mutex.unlock(io);
|
||||
|
||||
if (free_it) gen.destroy();
|
||||
}
|
||||
|
||||
/// Prepare half of a log-directory change: allocates the owned path and
|
||||
/// its generation node before any commit, so publish cannot fail. `path`
|
||||
/// null means logs no longer go to a file and nothing is measured.
|
||||
pub fn prepareLogDir(
|
||||
gpa: std.mem.Allocator,
|
||||
path: ?[]const u8,
|
||||
) std.mem.Allocator.Error!?*LogDir {
|
||||
const p = path orelse return null;
|
||||
const owned = try gpa.dupeZ(u8, p);
|
||||
errdefer gpa.free(owned);
|
||||
const gen = try gpa.create(LogDir);
|
||||
gen.* = .{ .path = owned, .gpa = gpa };
|
||||
return gen;
|
||||
}
|
||||
|
||||
/// Discards a generation `prepareLogDir` built that will not be published.
|
||||
pub fn destroyPreparedLogDir(prepared: ?*LogDir) void {
|
||||
if (prepared) |gen| gen.destroy();
|
||||
}
|
||||
|
||||
/// Publish half: infallible and I/O-free. The old generation is retired
|
||||
/// and freed here when no scan holds it, or by the last release otherwise.
|
||||
pub fn setLogDir(self: *Monitor, io: std.Io, prepared: ?*LogDir) void {
|
||||
self.log_dir_mutex.lockUncancelable(io);
|
||||
const old: ?*LogDir = switch (self.log_dir) {
|
||||
.boot => null,
|
||||
.installed => |maybe| maybe,
|
||||
};
|
||||
self.log_dir = .{ .installed = prepared };
|
||||
var free_old = false;
|
||||
if (old) |gen| {
|
||||
gen.retired = true;
|
||||
free_old = gen.refs == 0;
|
||||
}
|
||||
self.log_dir_mutex.unlock(io);
|
||||
|
||||
if (free_old) old.?.destroy();
|
||||
}
|
||||
|
||||
/// Frees any installed log-directory generation. Every scan must have
|
||||
/// released first, which shutdown ordering guarantees.
|
||||
pub fn deinit(self: *Monitor, io: std.Io) void {
|
||||
self.setLogDir(io, null);
|
||||
}
|
||||
|
||||
pub fn state(self: *const Monitor) State {
|
||||
return @enumFromInt(self.state_raw.load(.monotonic));
|
||||
}
|
||||
@@ -109,7 +248,9 @@ pub const Monitor = struct {
|
||||
probeFailed(store, io, now_s, "data_dir", "sizing the data directory failed", err);
|
||||
}
|
||||
|
||||
if (self.log_dir_path) |path| {
|
||||
const borrow = self.acquireLogDir(io);
|
||||
defer self.releaseLogDir(io, borrow);
|
||||
if (borrow.path) |path| {
|
||||
if (self.sumLogDir(io, path)) |bytes| {
|
||||
self.log_bytes.store(bytes, .monotonic);
|
||||
if (store) |s| s.resolve(io, now_s, .disk_probe, "log_dir");
|
||||
@@ -120,7 +261,7 @@ pub const Monitor = struct {
|
||||
}
|
||||
}
|
||||
|
||||
self.publish(io, store, now_s, classify(free, self.cfg), free);
|
||||
self.publish(io, store, now_s, classify(free, self.thresholds()), free);
|
||||
}
|
||||
|
||||
/// Sample first, then sleep: a process that starts on a full disk must not
|
||||
@@ -466,7 +607,7 @@ test "a threshold above the real free space drives the state to critical" {
|
||||
try testing.expectEqual(State.critical, monitor.state());
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
@@ -509,7 +650,7 @@ test "a disk transition records an episode per severity and closes it on recover
|
||||
monitor.sample(io, &fx.store, 1060);
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, &fx.store, 1120);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expectEqual(
|
||||
@@ -551,7 +692,9 @@ test "a failed probe opens an episode the next clean pass closes" {
|
||||
|
||||
try tmp.dir.createDirPath(io, "logs");
|
||||
var path_buf: [256]u8 = undefined;
|
||||
monitor.log_dir_path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
const good_dir = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, good_dir));
|
||||
defer monitor.deinit(io);
|
||||
monitor.sample(io, &fx.store, 1100);
|
||||
|
||||
try testing.expectEqual(
|
||||
@@ -560,6 +703,37 @@ test "a failed probe opens an episode the next clean pass closes" {
|
||||
);
|
||||
}
|
||||
|
||||
/// Alternates between two pairs that each satisfy `warn >= min`, so any
|
||||
/// observed pair violating it can only have been torn out of two stores.
|
||||
fn storeThresholdPairs(monitor: *Monitor, rounds: usize) void {
|
||||
for (0..rounds) |i| {
|
||||
monitor.setThresholds(if (i % 2 == 0)
|
||||
.{ .min_free_mb = 1, .warn_free_mb = 2 }
|
||||
else
|
||||
.{ .min_free_mb = 3_000_000, .warn_free_mb = 4_000_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test "a threshold reader never observes a pair from two different stores" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 1, .warn_free_mb = 2 }, std.Io.Dir.cwd(), ".", null);
|
||||
|
||||
const rounds = 20_000;
|
||||
var writer = try io.concurrent(storeThresholdPairs, .{ &monitor, rounds });
|
||||
// Recorded, not asserted, while the writer runs: an assertion that returned
|
||||
// here would leave `Threaded.deinit` joining a task nothing ends.
|
||||
var torn = false;
|
||||
for (0..rounds) |_| {
|
||||
const pair = monitor.thresholds();
|
||||
if (pair.warn_free_mb < pair.min_free_mb) torn = true;
|
||||
}
|
||||
writer.await(io);
|
||||
try testing.expect(!torn);
|
||||
}
|
||||
|
||||
test "every emit site is inert when the store is absent" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -574,3 +748,89 @@ test "every emit site is inert when the store is absent" {
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setLogDir (milestone-34 S3.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "setLogDir re-points the measurement and frees the retired generation" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(io, "first");
|
||||
try tmp.dir.createDirPath(io, "second");
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "first/nxdns.log", .data = "aaaa" });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "second/nxdns.log", .data = "bbbbbbbb" });
|
||||
|
||||
var first_buf: [160]u8 = undefined;
|
||||
var second_buf: [160]u8 = undefined;
|
||||
const first = try std.fmt.bufPrint(&first_buf, ".zig-cache/tmp/{s}/first", .{tmp.sub_path});
|
||||
const second = try std.fmt.bufPrint(&second_buf, ".zig-cache/tmp/{s}/second", .{tmp.sub_path});
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
|
||||
defer monitor.deinit(io);
|
||||
|
||||
// Boot measures nothing.
|
||||
monitor.sample(io, null, 1_000);
|
||||
try testing.expectEqual(@as(u64, 0), monitor.gauges().log_bytes);
|
||||
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, first));
|
||||
monitor.sample(io, null, 1_100);
|
||||
try testing.expectEqual(@as(u64, 4), monitor.gauges().log_bytes);
|
||||
|
||||
// The retired generation is freed here; the testing allocator says so.
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, second));
|
||||
monitor.sample(io, null, 1_200);
|
||||
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
|
||||
|
||||
// Output moved away from file: nothing is measured, and the gauge keeps
|
||||
// its last reading rather than claiming zero bytes of logs.
|
||||
monitor.setLogDir(io, null);
|
||||
monitor.sample(io, null, 1_300);
|
||||
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
|
||||
}
|
||||
|
||||
test "a prepared log directory that is never published is freed by the caller" {
|
||||
const prepared = try Monitor.prepareLogDir(testing.allocator, "/var/log/nxdns");
|
||||
Monitor.destroyPreparedLogDir(prepared);
|
||||
try testing.expectEqual(@as(?*LogDir, null), try Monitor.prepareLogDir(testing.allocator, null));
|
||||
}
|
||||
|
||||
test "a sample borrowing a log directory survives a concurrent setLogDir" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(io, "logs");
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "logs/nxdns.log", .data = "aaaa" });
|
||||
|
||||
var path_buf: [160]u8 = undefined;
|
||||
const logs = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
|
||||
defer monitor.deinit(io);
|
||||
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, logs));
|
||||
|
||||
const Racer = struct {
|
||||
fn sample(m: *Monitor, sio: std.Io) void {
|
||||
for (0..200) |i| m.sample(sio, null, @intCast(1_000 + i));
|
||||
}
|
||||
fn repoint(m: *Monitor, sio: std.Io, p: []const u8) void {
|
||||
for (0..200) |i| {
|
||||
const prepared = Monitor.prepareLogDir(testing.allocator, if (i % 2 == 0) p else null) catch return;
|
||||
m.setLogDir(sio, prepared);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, Racer.sample, .{ &monitor, io });
|
||||
try group.concurrent(io, Racer.repoint, .{ &monitor, io, logs });
|
||||
try group.await(io);
|
||||
}
|
||||
|
||||
+247
-8
@@ -399,8 +399,23 @@ const discard_stall = if (builtin.is_test) struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// The §11.4 privacy policy: one value, never two. A producer decides the
|
||||
/// domain fields and the client field from ONE load of `privacy_packed`, so no
|
||||
/// entry can leave `transformed` with the domain redacted and the client
|
||||
/// exposed, or the reverse, because a `setPrivacy` landed between the two
|
||||
/// decisions.
|
||||
pub const Privacy = packed struct(u8) {
|
||||
hide_domains: bool = false,
|
||||
hide_client_ips: bool = false,
|
||||
_reserved: u6 = 0,
|
||||
};
|
||||
|
||||
pub const Logger = struct {
|
||||
cfg: model.Logging,
|
||||
/// Both privacy flags in one atomic byte, loaded once per entry.
|
||||
privacy_packed: std.atomic.Value(u8),
|
||||
/// Independent of the privacy policy: it governs when a batch commits, not
|
||||
/// what a row contains, so nothing pairs the two.
|
||||
flush_interval_s: std.atomic.Value(u16),
|
||||
queue: EntryQueue,
|
||||
queries_dropped: std.atomic.Value(u64),
|
||||
/// When the newest drop happened, in unix seconds; 0 means none yet. Read
|
||||
@@ -434,7 +449,11 @@ pub const Logger = struct {
|
||||
/// touched it.
|
||||
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
|
||||
return .{
|
||||
.cfg = cfg,
|
||||
.privacy_packed = .init(@bitCast(Privacy{
|
||||
.hide_domains = cfg.hide_domains,
|
||||
.hide_client_ips = cfg.hide_client_ips,
|
||||
})),
|
||||
.flush_interval_s = .init(cfg.query_log_flush_interval_s),
|
||||
.queue = .init(queue_buf),
|
||||
.queries_dropped = .init(0),
|
||||
.last_drop_s = .init(0),
|
||||
@@ -464,8 +483,9 @@ pub const Logger = struct {
|
||||
/// configuration labels the operator wrote, identical on every row that
|
||||
/// hits them, and they say nothing about which name a client looked up.
|
||||
pub fn transformed(self: *const Logger, entry: Entry) Entry {
|
||||
const policy = self.privacy();
|
||||
var out = entry;
|
||||
if (self.cfg.hide_domains) {
|
||||
if (policy.hide_domains) {
|
||||
out.setDomain(hidden_marker);
|
||||
// Only where there is something to hide: an empty field means the
|
||||
// query had no such value, and writing a marker would claim it did.
|
||||
@@ -473,10 +493,24 @@ pub const Logger = struct {
|
||||
if (out.cname_len != 0) out.setCnameTarget(hidden_marker);
|
||||
if (out.safe_search_len != 0) out.setSafeSearchTarget(hidden_marker);
|
||||
}
|
||||
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
if (policy.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The live policy, from one load. Every producer decision about one entry
|
||||
/// must come from a single call to this.
|
||||
pub fn privacy(self: *const Logger) Privacy {
|
||||
return @bitCast(self.privacy_packed.load(.monotonic));
|
||||
}
|
||||
|
||||
pub fn setPrivacy(self: *Logger, p: Privacy) void {
|
||||
self.privacy_packed.store(@bitCast(p), .monotonic);
|
||||
}
|
||||
|
||||
pub fn setFlushInterval(self: *Logger, seconds: u16) void {
|
||||
self.flush_interval_s.store(seconds, .monotonic);
|
||||
}
|
||||
|
||||
/// `log` without the transforms, for a caller that already applied them.
|
||||
pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
|
||||
self.enqueue(io, entry);
|
||||
@@ -540,7 +574,22 @@ pub const Logger = struct {
|
||||
return;
|
||||
};
|
||||
defer writer.deinit();
|
||||
return self.runPrepared(io, &writer, monitor);
|
||||
}
|
||||
|
||||
/// The writer loop over statements someone else prepared.
|
||||
///
|
||||
/// `runWriter` prepares and then calls this. A logger generation created by
|
||||
/// a resize prepares separately, before anything is published, so that a
|
||||
/// statement failure is refused at prepare time instead of silently killing
|
||||
/// the writer of a queue producers are already filling
|
||||
/// (`logger_controller.zig`).
|
||||
pub fn runPrepared(
|
||||
self: *Logger,
|
||||
io: std.Io,
|
||||
writer: *queries_repo.BatchWriter,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
var batch: [flush_batch]Entry = undefined;
|
||||
while (true) {
|
||||
// A closed queue hands over its buffered elements before it reports
|
||||
@@ -564,7 +613,7 @@ pub const Logger = struct {
|
||||
self.countDropped(io, n, at);
|
||||
return err;
|
||||
};
|
||||
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
|
||||
self.flush(io, writer, batch[0..n], monitor) catch |err| switch (err) {
|
||||
error.Canceled => |e| {
|
||||
self.countDropped(io, n, at);
|
||||
return e;
|
||||
@@ -606,7 +655,7 @@ pub const Logger = struct {
|
||||
/// returns without waiting for anything.
|
||||
fn flushDeadline(self: *const Logger, io: std.Io) std.Io.Clock.Timestamp {
|
||||
return .fromNow(io, .{
|
||||
.raw = .fromSeconds(self.cfg.query_log_flush_interval_s),
|
||||
.raw = .fromSeconds(self.flush_interval_s.load(.monotonic)),
|
||||
.clock = .boot,
|
||||
});
|
||||
}
|
||||
@@ -644,6 +693,18 @@ pub const Logger = struct {
|
||||
self.draining.store(true, .release);
|
||||
}
|
||||
|
||||
/// `shutdown` without `draining`: this generation is being replaced, not
|
||||
/// the process stopped.
|
||||
///
|
||||
/// The flag is what turns a gate-held batch into a counted loss
|
||||
/// (`flush`'s `GatedAtShutdown`), and a retired writer must not take that
|
||||
/// path — the disk can still recover, and the rows it is holding are still
|
||||
/// going to be written when it does. Every producer of this generation
|
||||
/// must have released it before the close, exactly as at shutdown.
|
||||
pub fn retire(self: *Logger, io: std.Io) void {
|
||||
self.queue.close(io);
|
||||
}
|
||||
|
||||
/// Fills `batch` behind the entry already in slot 0, until it is full or
|
||||
/// `deadline` passes. `n` counts the slots that hold an entry, and stays
|
||||
/// accurate on the cancellation path so the caller can count what is lost.
|
||||
@@ -1240,6 +1301,80 @@ test "log hides only the field its switch names" {
|
||||
try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
|
||||
}
|
||||
|
||||
/// The rendezvous that forces the flip to land BETWEEN two producer entries
|
||||
/// rather than whenever the scheduler feels like it.
|
||||
const PrivacyFlip = struct {
|
||||
logger: *Logger,
|
||||
/// Set by the producer once its pre-flip entry is enqueued.
|
||||
before_done: std.Io.Event = .unset,
|
||||
/// Set by the flipper once `setPrivacy` has returned.
|
||||
flipped: std.Io.Event = .unset,
|
||||
|
||||
fn run(self: *PrivacyFlip, io: std.Io) void {
|
||||
self.before_done.wait(io) catch return;
|
||||
self.logger.setPrivacy(.{ .hide_domains = true, .hide_client_ips = true });
|
||||
self.flipped.set(io);
|
||||
}
|
||||
};
|
||||
|
||||
test "a privacy flip redacts every entry after it and none before it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
var flip: PrivacyFlip = .{ .logger = &logger };
|
||||
|
||||
var future = try io.concurrent(PrivacyFlip.run, .{ &flip, io });
|
||||
logger.log(io, sampleEntry(1, "before.example"));
|
||||
flip.before_done.set(io);
|
||||
try flip.flipped.wait(io);
|
||||
logger.log(io, sampleEntry(2, "after.example"));
|
||||
future.await(io);
|
||||
|
||||
const before = try logger.queue.getOne(io);
|
||||
try testing.expectEqualStrings("before.example", before.domain());
|
||||
try testing.expectEqualStrings("192.0.2.10", before.clientIp());
|
||||
|
||||
const after = try logger.queue.getOne(io);
|
||||
try testing.expectEqualStrings(hidden_marker, after.domain());
|
||||
try testing.expectEqualStrings(hidden_marker, after.clientIp());
|
||||
}
|
||||
|
||||
fn flipPrivacyRepeatedly(logger: *Logger, rounds: usize) void {
|
||||
for (0..rounds) |i| {
|
||||
logger.setPrivacy(if (i % 2 == 0)
|
||||
.{}
|
||||
else
|
||||
.{ .hide_domains = true, .hide_client_ips = true });
|
||||
}
|
||||
}
|
||||
|
||||
test "no producer observes a privacy policy that redacts one field and not the other" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
const source = sampleEntry(1, "tracker.example");
|
||||
|
||||
const rounds = 20_000;
|
||||
var flipper = try io.concurrent(flipPrivacyRepeatedly, .{ &logger, rounds });
|
||||
// Recorded, not asserted, while the flipper runs: an assertion that
|
||||
// returned here would leave `Threaded.deinit` joining a task nothing ends.
|
||||
var mixed = false;
|
||||
for (0..rounds) |_| {
|
||||
const out = logger.transformed(source);
|
||||
const domain_hidden = std.mem.eql(u8, out.domain(), hidden_marker);
|
||||
const client_hidden = std.mem.eql(u8, out.clientIp(), hidden_marker);
|
||||
if (domain_hidden != client_hidden) mixed = true;
|
||||
}
|
||||
flipper.await(io);
|
||||
try testing.expect(!mixed);
|
||||
}
|
||||
|
||||
test "the split halves reproduce log byte for byte" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -1334,6 +1469,13 @@ test "shutdown writes the batch the writer holds and the rest of the queue" {
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// Declared after the `database.close` defer so LIFO stops the writer first:
|
||||
// an early return anywhere below would otherwise close the handle under a
|
||||
// live writer and leave `Threaded.deinit` joining a task nothing ends.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
var names: [250][32]u8 = undefined;
|
||||
for (&names, 0..) |*name, i| {
|
||||
@@ -1420,6 +1562,12 @@ test "the writer holds an entry for the length of the flush interval" {
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
logger.log(io, sampleEntry(1, "only.example"));
|
||||
|
||||
@@ -1466,6 +1614,12 @@ test "a full batch flushes without waiting for the interval" {
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
for (0..150) |i| logger.log(io, sampleEntry(@intCast(i), "burst.example"));
|
||||
|
||||
@@ -1488,6 +1642,57 @@ test "a full batch flushes without waiting for the interval" {
|
||||
try testing.expectEqual(@as(i64, 150), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "the writer's next cycle uses the interval set since its last one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var buf: [8]Entry = undefined;
|
||||
// Zero: every cycle commits what it has and goes straight back to `getOne`,
|
||||
// so the first entry proves the writer is running and parked between cycles.
|
||||
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
|
||||
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
logger.log(io, sampleEntry(1, "first.example"));
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
while (logger.rows_written.load(.monotonic) == 0 and waited < 400) : (waited += 1) {
|
||||
try poll.sleep(io);
|
||||
}
|
||||
|
||||
// An hour, installed while the writer is parked: the cycle the next entry
|
||||
// starts must wait it out instead of committing at once.
|
||||
logger.setFlushInterval(3600);
|
||||
logger.log(io, sampleEntry(2, "second.example"));
|
||||
const quarter: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(250), .clock = .awake };
|
||||
try quarter.sleep(io);
|
||||
const written_under_the_new_interval = logger.rows_written.load(.monotonic);
|
||||
|
||||
logger.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
try testing.expect(waited < 400);
|
||||
// The first entry, and only the first: the second is still held.
|
||||
try testing.expectEqual(@as(u64, 1), written_under_the_new_interval);
|
||||
// The close releases it, which is what makes the hold a hold and not a loss.
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "a gated flush holds the batch until the disk recovers" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -1513,6 +1718,12 @@ test "a gated flush holds the batch until the disk recovers" {
|
||||
@as([]const Entry, &entries),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// This task is `flush`, not `runWriter`: no queue shutdown can release it,
|
||||
// so only cancellation ends it on an early return. Declared after the
|
||||
// `writer.deinit`/`database.close` defers so LIFO runs it first.
|
||||
defer {
|
||||
_ = future.cancel(io) catch {};
|
||||
}
|
||||
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
@@ -1634,6 +1845,12 @@ test "the gating episode opens on the gate, turns losing on a drop, and clears o
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
@@ -1657,11 +1874,14 @@ test "the gating episode opens on the gate, turns losing on a drop, and clears o
|
||||
// The disk recovers: the held batch goes out and the episode ends.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
waited = 0;
|
||||
while (logger.gateEpisode() != .open) : (waited += 1) {
|
||||
// The write is the completion condition, not the episode: `flush` reopens
|
||||
// the gate before it calls `writeBatch`, so a poll on the episode alone
|
||||
// returns while the row is still in flight and `rows_written` is still 0.
|
||||
while (logger.rows_written.load(.monotonic) == 0) : (waited += 1) {
|
||||
try testing.expect(waited < 400);
|
||||
try poll.sleep(io);
|
||||
}
|
||||
try testing.expect(logger.rows_written.load(.monotonic) > 0);
|
||||
try testing.expectEqual(GateEpisode.open, logger.gateEpisode());
|
||||
// The count keeps the history the state does not.
|
||||
try testing.expect(logger.queries_dropped.load(.monotonic) > 0);
|
||||
|
||||
@@ -1736,6 +1956,13 @@ test "a gate that closes mid-enqueue still gets the discard that follows it" {
|
||||
// The producer enters `enqueue` with the gate open — which is the reading a
|
||||
// sample taken before the loop would keep for the rest of the call.
|
||||
var producer = try io.concurrent(logOne, .{ &logger, io });
|
||||
// The producer parks inside `enqueue` and only `release` frees it: an early
|
||||
// return below would otherwise leave `Threaded.deinit` joining it forever.
|
||||
defer {
|
||||
discard_stall.armed = false;
|
||||
discard_stall.release.set(io);
|
||||
producer.await(io);
|
||||
}
|
||||
discard_stall.parked.waitUncancelable(io);
|
||||
|
||||
// The producer is parked with the row it will evict still on the queue, so
|
||||
@@ -1799,6 +2026,12 @@ test "a canceled writer counts the batch it was holding" {
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// Cancellation is this case's subject, so the guard is the same operation
|
||||
// the body performs; declared after the `database.close` defer so LIFO ends
|
||||
// the writer before the handle goes away.
|
||||
defer {
|
||||
_ = future.cancel(io) catch {};
|
||||
}
|
||||
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
@@ -1842,6 +2075,12 @@ test "a disk-gated writer drops what it holds at shutdown instead of hanging" {
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// See the note in "shutdown writes the batch the writer holds": this must
|
||||
// run before the deferred `fx.deinit` and `database.close`.
|
||||
defer {
|
||||
logger.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
var waited: usize = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,13 @@ test "S8 case 1: the logger writes a real querylog.db end to end" {
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// Declared after the `log_db.deinit` defer so LIFO stops the writer first:
|
||||
// an early return below would otherwise close the database under a live
|
||||
// writer and leave the never-closed queue parking it forever.
|
||||
defer {
|
||||
query_log.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
var name_buf: [32]u8 = undefined;
|
||||
for (0..250) |i| {
|
||||
@@ -252,6 +259,13 @@ test "S8 case 2: a single entry reaches the file once the flush interval passes"
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// Declared after the `log_db.deinit` defer so LIFO stops the writer first:
|
||||
// an early return below would otherwise close the database under a live
|
||||
// writer and leave the never-closed queue parking it forever.
|
||||
defer {
|
||||
query_log.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
query_log.log(io, entryAt(1, "only.example"));
|
||||
|
||||
@@ -300,6 +314,13 @@ test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// Declared after the `log_db.deinit` defer so LIFO stops the writer first:
|
||||
// an early return below would otherwise close the database under a live
|
||||
// writer and leave the never-closed queue parking it forever.
|
||||
defer {
|
||||
query_log.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
try awaitCount(&query_log.batches_gated, 1, 200);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
@@ -341,6 +362,13 @@ test "S8 case 4: the privacy transforms reach the stored rows" {
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// Declared after the `log_db.deinit` defer so LIFO stops the writer first:
|
||||
// an early return below would otherwise close the database under a live
|
||||
// writer and leave the never-closed queue parking it forever.
|
||||
defer {
|
||||
query_log.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
var name_buf: [32]u8 = undefined;
|
||||
for (0..20) |i| {
|
||||
@@ -378,7 +406,8 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
|
||||
try testing.expectEqual(@as(i64, 4), try queries_repo.countRows(log_db.database()));
|
||||
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
|
||||
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
var pass_days: retention.RetentionDays = .init(30);
|
||||
var pass: retention.Retention = .init(&pass_days);
|
||||
pass.runOnce(io, log_db.database(), null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||
@@ -433,12 +462,19 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
// Declared after the `log_db.deinit` defer so LIFO stops the writer first:
|
||||
// an early return below would otherwise close the database under a live
|
||||
// writer and leave the never-closed queue parking it forever.
|
||||
defer {
|
||||
query_log.shutdown(io);
|
||||
future.await(io) catch {};
|
||||
}
|
||||
|
||||
try awaitCount(&query_log.batches_gated, 1, 200);
|
||||
try testing.expectEqual(@as(u64, 0), query_log.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(disk_monitor.State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
|
||||
@@ -80,6 +80,14 @@ pub const ddl: [:0]const u8 =
|
||||
\\VALUES (1, unixepoch(), unixepoch() + 1);
|
||||
;
|
||||
|
||||
/// The fingerprint of an arbitrary DDL text. `tools/cut.zig` calls this at
|
||||
/// runtime on the DDL of the previous release tag, so the release gate and the
|
||||
/// server compute the same number from the same function rather than from two
|
||||
/// copies of one expression.
|
||||
pub fn fingerprintOf(text: []const u8) i32 {
|
||||
return @bitCast(std.hash.Crc32.hash(text));
|
||||
}
|
||||
|
||||
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
|
||||
/// the DDL means editing the schema automatically invalidates every existing
|
||||
/// file — which is exactly the policy.
|
||||
@@ -87,7 +95,7 @@ pub const fingerprint: i32 = blk: {
|
||||
// Covers the CRC lookup-table generation in std.hash.crc, which evaluates
|
||||
// under this scope's quota and overflows the 1000 default (and 100k).
|
||||
@setEvalBranchQuota(2_000_000);
|
||||
break :blk @bitCast(std.hash.Crc32.hash(ddl));
|
||||
break :blk fingerprintOf(ddl);
|
||||
};
|
||||
|
||||
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
|
||||
@@ -191,6 +199,29 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `open` has already
|
||||
/// established, with the pragmas every connection to the file needs.
|
||||
///
|
||||
/// The canonical opener for every background connection: the log writer, the
|
||||
/// retention pass and the web task each own one (`retention.zig`'s contract),
|
||||
/// and a logger generation opens one per writer for that writer's whole life
|
||||
/// (`logger_controller.zig`) — two writers must never share a handle.
|
||||
///
|
||||
/// `dir` and `path` follow `open`'s resolution rule, and `dir` participates in
|
||||
/// it the same way: the caller passes either an absolute path with `dir` open
|
||||
/// on its parent, or `std.Io.Dir.cwd()` with a cwd-relative path. Nothing here
|
||||
/// touches the directory itself — the file already exists by contract — so the
|
||||
/// handle is present to make the pairing explicit at every call site rather
|
||||
/// than to be dereferenced.
|
||||
pub fn reopen(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) db.Error!db.Db {
|
||||
_ = io;
|
||||
_ = dir;
|
||||
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
}
|
||||
|
||||
/// The whitelist. `null` means "propagate, do not touch the file".
|
||||
fn recreatable(e: db.Error) ?RecreateReason {
|
||||
return switch (e) {
|
||||
@@ -280,6 +311,10 @@ const testing = std.testing;
|
||||
|
||||
test "fingerprint matches a fresh hash of the DDL" {
|
||||
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
|
||||
// The runtime entry point the release gate uses is the same function the
|
||||
// comptime constant is built from.
|
||||
try testing.expectEqual(fingerprint, fingerprintOf(ddl));
|
||||
try testing.expect(fingerprintOf(ddl[0 .. ddl.len - 1]) != fingerprint);
|
||||
}
|
||||
|
||||
test "ddl creates the query-log tables and every index" {
|
||||
|
||||
+77
-17
@@ -14,7 +14,6 @@ const std = @import("std");
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const events = @import("events.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
|
||||
const log = std.log.scoped(.retention);
|
||||
@@ -50,15 +49,42 @@ const Counters = struct {
|
||||
vacuums_gated: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// `logging.retention_days`, shared by its two consumers — this pass and
|
||||
/// `server/clients.zig`'s stale-client prune. One cell rather than a copy in
|
||||
/// each: the two must never prune to different cutoffs, and a settings apply
|
||||
/// stores once. Owned by app-level state and outlives both readers.
|
||||
///
|
||||
/// `.monotonic` is enough: the value stands alone and orders nothing else, and
|
||||
/// each consumer reads it once per pass.
|
||||
pub const RetentionDays = struct {
|
||||
value: std.atomic.Value(u32),
|
||||
|
||||
pub fn init(days: u16) RetentionDays {
|
||||
return .{ .value = .init(days) };
|
||||
}
|
||||
|
||||
pub fn get(self: *const RetentionDays) u32 {
|
||||
return self.value.load(.monotonic);
|
||||
}
|
||||
|
||||
pub fn setRetentionDays(self: *RetentionDays, days: u16) void {
|
||||
self.value.store(days, .monotonic);
|
||||
}
|
||||
|
||||
pub fn seconds(self: *const RetentionDays) i64 {
|
||||
return @as(i64, self.get()) * std.time.s_per_day;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Retention = struct {
|
||||
cfg: model.Logging,
|
||||
days: *const RetentionDays,
|
||||
counters: Counters,
|
||||
/// Passes since the last vacuum that succeeded. Plain rather than atomic:
|
||||
/// only the retention task reads or writes it, and no consumer reports it.
|
||||
passes_since_vacuum: u32,
|
||||
|
||||
pub fn init(cfg: model.Logging) Retention {
|
||||
return .{ .cfg = cfg, .counters = .{}, .passes_since_vacuum = 0 };
|
||||
pub fn init(days: *const RetentionDays) Retention {
|
||||
return .{ .days = days, .counters = .{}, .passes_since_vacuum = 0 };
|
||||
}
|
||||
|
||||
/// The counters, read one at a time. A scrape that lands mid-pass can see a
|
||||
@@ -100,7 +126,7 @@ pub const Retention = struct {
|
||||
) void {
|
||||
add(&self.counters.passes, 1);
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const cutoff = now - model.retentionSeconds(self.cfg);
|
||||
const cutoff = now - self.days.seconds();
|
||||
|
||||
// Diagnostics retention rides this pass rather than a schedule of its
|
||||
// own: one daily housekeeping task, and a box restarted every night
|
||||
@@ -268,7 +294,8 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
|
||||
const day = 86_400;
|
||||
try writeRows(&database, &.{ now - 40 * day, now - 31 * day, now - 29 * day, now - 60 });
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
@@ -292,17 +319,41 @@ test "the cutoff follows retention_days" {
|
||||
// window of the other.
|
||||
try writeRows(&database, &.{now - 3 * day});
|
||||
|
||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||
var keeps_days: RetentionDays = .init(7);
|
||||
var keeps: Retention = .init(&keeps_days);
|
||||
keeps.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
|
||||
|
||||
var prunes: Retention = .init(.{ .retention_days = 1 });
|
||||
var prunes_days: RetentionDays = .init(1);
|
||||
var prunes: Retention = .init(&prunes_days);
|
||||
prunes.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "setRetentionDays changes the cutoff the next pass prunes by" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try writeRows(&database, &.{now - 3 * 86_400});
|
||||
|
||||
var days: RetentionDays = .init(7);
|
||||
var pass: Retention = .init(&days);
|
||||
pass.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
|
||||
days.setRetentionDays(1);
|
||||
pass.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "the seventh pass vacuums and the six before it do not" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -311,7 +362,8 @@ test "the seventh pass vacuums and the six before it do not" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..6) |_| {
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
@@ -340,7 +392,8 @@ test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var gated: Retention = .init(.{});
|
||||
var gated_days: RetentionDays = .init(30);
|
||||
var gated: Retention = .init(&gated_days);
|
||||
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor, null);
|
||||
|
||||
// Prune and checkpoint ran on every pass; only the vacuum was refused.
|
||||
@@ -371,7 +424,8 @@ test "a warn state still allows the vacuum" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
@@ -386,7 +440,8 @@ test "a pass over an empty database still counts" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -410,7 +465,8 @@ test "a failing prune counts the pass and leaves the rows alone" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
@@ -435,7 +491,8 @@ test "the next pass retries what the failed one could not do" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
|
||||
@@ -466,7 +523,8 @@ test "a failing prune opens a maintenance episode the next clean pass closes" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
// Only the prune failed; the checkpoint succeeded, and a success writes no
|
||||
@@ -501,7 +559,8 @@ test "a gated vacuum is a maintenance failure the next ungated pass closes" {
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, &fx.store);
|
||||
|
||||
try testing.expectEqualStrings("vacuum", try fx.text(
|
||||
@@ -536,7 +595,8 @@ test "a pass prunes the diagnostics store once" {
|
||||
fx.store.reportResolved(io, stale, .query_log_recreated, "one-shot", "one-shot", .warning, "aside kept");
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
var retention_days: RetentionDays = .init(30);
|
||||
var retention: Retention = .init(&retention_days);
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
@@ -22,6 +22,7 @@ comptime {
|
||||
_ = @import("upstream/doh_client.zig");
|
||||
_ = @import("upstream/doh_client_live_test.zig");
|
||||
_ = @import("upstream/pool.zig");
|
||||
_ = @import("upstream/owner.zig");
|
||||
_ = @import("upstream/dot_client.zig");
|
||||
_ = @import("upstream/dot_client_live_test.zig");
|
||||
_ = @import("upstream/dot_client_integration_test.zig");
|
||||
@@ -82,6 +83,7 @@ comptime {
|
||||
_ = @import("storage/events.zig");
|
||||
_ = @import("storage/events_fixture.zig");
|
||||
_ = @import("storage/logger.zig");
|
||||
_ = @import("storage/logger_controller.zig");
|
||||
_ = @import("platform/statfs.zig");
|
||||
_ = @import("storage/disk_monitor.zig");
|
||||
_ = @import("platform/logging.zig");
|
||||
@@ -110,6 +112,7 @@ comptime {
|
||||
_ = @import("web/handlers/lookup.zig");
|
||||
_ = @import("web/handlers/health.zig");
|
||||
_ = @import("web/handlers/version.zig");
|
||||
_ = @import("web/handlers/apply.zig");
|
||||
_ = @import("web/handlers/mutations.zig");
|
||||
_ = @import("web/handlers/groups.zig");
|
||||
_ = @import("web/handlers/blocklists.zig");
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
//! The upstream generation and the owner that publishes it.
|
||||
//!
|
||||
//! A `Generation` is everything one upstream configuration needs to answer a
|
||||
//! query: the parsed endpoints, the leaf clients behind them, the pool that
|
||||
//! chooses between them, and — the reason it is a generation rather than a
|
||||
//! plain struct — every configuration string it borrows, copied into an arena
|
||||
//! it owns. `transport.Endpoint` borrows the URL text (transport.zig:51), so a
|
||||
//! generation built from database rows that live in a request arena must not
|
||||
//! outlive that arena unless it took its own copy. It takes its own copy.
|
||||
//!
|
||||
//! `Owner` publishes one generation at a time under the `CertStore` discipline
|
||||
//! (cert_store.zig:199/:237): a mutex held briefly around a refcounted borrow,
|
||||
//! a swap that marks the old generation retired, and a last release that tears
|
||||
//! it down. A query acquires for the length of one exchange and copies whatever
|
||||
//! it needs out of the generation before releasing, so a `replace` landing
|
||||
//! mid-query never frees anything the query still reads.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
const doh_client = @import("doh_client.zig");
|
||||
const dot_client = @import("dot_client.zig");
|
||||
const events = @import("../storage/events.zig");
|
||||
const health = @import("health.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const pool_mod = @import("pool.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const transport = @import("transport.zig");
|
||||
|
||||
const log = std.log.scoped(.nxdns);
|
||||
|
||||
const doh_request_buf_len = doh_client.default_request_buf_len;
|
||||
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
|
||||
|
||||
/// The `configuration.load` findings of one `build`, without the side effects.
|
||||
///
|
||||
/// `build` writes no event rows: at boot the caller replays this report through
|
||||
/// its own `ConfigLoad.note` so the diagnostics are exactly what they were when
|
||||
/// the composition lived in `app.zig`, and at runtime a candidate that is never
|
||||
/// published must leave no trace at all. The strings are owned by the
|
||||
/// generation's arena, so the report is readable for as long as the generation
|
||||
/// is.
|
||||
pub const BuildReport = struct {
|
||||
notes: []const Note = &.{},
|
||||
|
||||
/// `url` is the subject key an upstream finding is filed under: the whole
|
||||
/// URL is the identity, and redaction is the caller's job because the
|
||||
/// caller is what renders it.
|
||||
pub const Note = struct {
|
||||
url: []const u8,
|
||||
message: []const u8,
|
||||
};
|
||||
};
|
||||
|
||||
/// One finding rendered for the event store: the redacted label and the detail
|
||||
/// line. Both borrow `Rendered`'s own buffers, so it must outlive the call that
|
||||
/// writes the row.
|
||||
pub const Rendered = struct {
|
||||
label_buf: [events.Store.max_subject_label_len]u8 = undefined,
|
||||
detail_buf: [events.Store.max_detail_len]u8 = undefined,
|
||||
label: []const u8 = "",
|
||||
detail: []const u8 = "",
|
||||
|
||||
/// An upstream's identity is its url: the whole url is the key, and the
|
||||
/// redaction is the label, because a url can carry an account token.
|
||||
pub fn render(self: *Rendered, finding: BuildReport.Note) void {
|
||||
self.label = std.fmt.bufPrint(&self.label_buf, "{f}", .{
|
||||
safe_url.redact(finding.url),
|
||||
}) catch &self.label_buf;
|
||||
self.detail = std.fmt.bufPrint(&self.detail_buf, "upstream {f} {s}", .{
|
||||
safe_url.redactQuoted(finding.url),
|
||||
finding.message,
|
||||
}) catch &self.detail_buf;
|
||||
}
|
||||
};
|
||||
|
||||
/// Reconciles the `configuration.load` episodes of an upstream replace, on the
|
||||
/// task that published it and after the publish.
|
||||
///
|
||||
/// SCOPED, never `Store.resolveExcept`: that call resolves every active
|
||||
/// `configuration.load` episode outside its kept set, which at runtime would
|
||||
/// falsely close boot warnings about settings this replace never touched. Only
|
||||
/// the keys the previous generation reported and the new one does not are
|
||||
/// resolved, one at a time.
|
||||
///
|
||||
/// `previous_keys` is the copy taken at prepare, so nothing here depends on the
|
||||
/// retired generation still being alive.
|
||||
pub fn reconcileReport(
|
||||
store: *events.Store,
|
||||
io: std.Io,
|
||||
now_s: i64,
|
||||
new_notes: []const BuildReport.Note,
|
||||
previous_keys: []const []const u8,
|
||||
) void {
|
||||
var rendered: Rendered = .{};
|
||||
for (new_notes) |finding| {
|
||||
rendered.render(finding);
|
||||
store.report(
|
||||
io,
|
||||
now_s,
|
||||
.configuration_load,
|
||||
finding.url,
|
||||
rendered.label,
|
||||
.warning,
|
||||
rendered.detail,
|
||||
);
|
||||
}
|
||||
|
||||
var previous_buf: [events.Store.max_subject_key_len]u8 = undefined;
|
||||
var current_buf: [events.Store.max_subject_key_len]u8 = undefined;
|
||||
outer: for (previous_keys) |key| {
|
||||
const previous = events.canonicalKey(key, &previous_buf);
|
||||
for (new_notes) |finding| {
|
||||
if (std.mem.eql(u8, previous, events.canonicalKey(finding.url, ¤t_buf))) continue :outer;
|
||||
}
|
||||
store.resolve(io, now_s, .configuration_load, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a generation built from configuration rows owns. Absent when the
|
||||
/// caller supplied the `transport.Client` directly — a test seam, and the shape
|
||||
/// `nxdns check` would want for a single probe.
|
||||
const Built = struct {
|
||||
gpa: Allocator,
|
||||
/// Every configuration string the generation borrows: the URL text each
|
||||
/// `Endpoint` slices its host and path out of, and each DoT `tls_name`.
|
||||
arena: std.heap.ArenaAllocator,
|
||||
upstreams: Upstreams,
|
||||
pool: pool_mod.Pool,
|
||||
report: BuildReport,
|
||||
};
|
||||
|
||||
pub const Generation = struct {
|
||||
/// The exchange entry point. `built.pool.client()` for a configured
|
||||
/// generation, the caller's client otherwise.
|
||||
client: transport.Client,
|
||||
/// The pool behind `client`, for the health and metrics reads. Null when
|
||||
/// the client is not a pool.
|
||||
pool: ?*pool_mod.Pool = null,
|
||||
built: ?Built = null,
|
||||
/// Set when `retire` must free the generation's own storage. Null when the
|
||||
/// caller owns it — a generation on a test's stack.
|
||||
destroy_with: ?Allocator = null,
|
||||
/// Guarded by the owner's mutex, never touched outside it.
|
||||
refs: usize = 0,
|
||||
retired: bool = false,
|
||||
|
||||
/// A generation over a client the caller owns and keeps alive. Owns
|
||||
/// nothing, so `retire` only invalidates it.
|
||||
pub fn borrowing(client: transport.Client) Generation {
|
||||
return .{ .client = client };
|
||||
}
|
||||
|
||||
/// A generation over a pool the caller owns and keeps alive. The metrics
|
||||
/// and health paths read the pool through this.
|
||||
pub fn borrowingPool(pool: *pool_mod.Pool) Generation {
|
||||
return .{ .client = pool.client(), .pool = pool };
|
||||
}
|
||||
|
||||
pub fn report(self: *const Generation) BuildReport {
|
||||
const built = self.built orelse return .{};
|
||||
return built.report;
|
||||
}
|
||||
|
||||
/// The active entries, for a caller that wants the count rather than the
|
||||
/// health of each.
|
||||
pub fn activeCount(self: *Generation) usize {
|
||||
const built = &(self.built orelse return 0);
|
||||
return built.upstreams.used;
|
||||
}
|
||||
|
||||
/// Connections first, memory second, storage last. Only ever called with
|
||||
/// `refs == 0`: by `Owner.release` when the last reader of a retired
|
||||
/// generation leaves, by the caller `Owner.replace` handed an idle
|
||||
/// generation back to, or by `Owner.deinit` at shutdown.
|
||||
pub fn retire(self: *Generation, io: std.Io) void {
|
||||
if (self.built) |*built| {
|
||||
built.upstreams.deinit(io, built.gpa);
|
||||
built.arena.deinit();
|
||||
}
|
||||
const destroy_with = self.destroy_with;
|
||||
self.* = undefined;
|
||||
if (destroy_with) |gpa| gpa.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const BuildError = Allocator.Error || error{NoUsableUpstreams};
|
||||
|
||||
pub const BuildOptions = struct {
|
||||
gpa: Allocator,
|
||||
io: std.Io,
|
||||
/// Borrowed for the length of the call only: every string this generation
|
||||
/// keeps is copied into its arena before `build` returns.
|
||||
servers: []const model.UpstreamServer,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
timeouts: pool_mod.Timeouts,
|
||||
health_config: health.Config = .{},
|
||||
seed: u64,
|
||||
diagnostics: ?*events.Store = null,
|
||||
};
|
||||
|
||||
/// Builds one heap-stable generation from a row set. Side-effect free: it
|
||||
/// writes no event rows and publishes nothing. Every failure frees everything
|
||||
/// it allocated.
|
||||
pub fn build(opts: BuildOptions) BuildError!*Generation {
|
||||
const gpa = opts.gpa;
|
||||
|
||||
const generation = try gpa.create(Generation);
|
||||
errdefer gpa.destroy(generation);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
errdefer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// The rows this generation keeps, copied out of whatever memory the caller
|
||||
// read them into. `Endpoint.parse` slices host and path out of the URL, so
|
||||
// duplicating the URL covers all three.
|
||||
const owned = try arena.alloc(model.UpstreamServer, opts.servers.len);
|
||||
for (opts.servers, owned) |server, *copy| {
|
||||
copy.* = .{
|
||||
.url = try arena.dupe(u8, server.url),
|
||||
.priority = server.priority,
|
||||
.enabled = server.enabled,
|
||||
.tls_name = try arena.dupe(u8, server.tls_name),
|
||||
};
|
||||
}
|
||||
|
||||
var notes: std.ArrayList(BuildReport.Note) = .empty;
|
||||
var upstreams = try Upstreams.build(
|
||||
opts.io,
|
||||
gpa,
|
||||
arena,
|
||||
owned,
|
||||
opts.http,
|
||||
opts.bundle,
|
||||
opts.bundle_lock,
|
||||
¬es,
|
||||
);
|
||||
errdefer upstreams.deinit(opts.io, gpa);
|
||||
|
||||
generation.* = .{
|
||||
.client = undefined,
|
||||
.pool = null,
|
||||
.destroy_with = gpa,
|
||||
.built = .{
|
||||
.gpa = gpa,
|
||||
.arena = arena_state,
|
||||
.upstreams = upstreams,
|
||||
.pool = .init(
|
||||
upstreams.active(),
|
||||
opts.health_config,
|
||||
opts.timeouts,
|
||||
opts.seed,
|
||||
),
|
||||
.report = .{ .notes = try notes.toOwnedSlice(arena) },
|
||||
},
|
||||
};
|
||||
const built = &generation.built.?;
|
||||
built.pool.diagnostics = opts.diagnostics;
|
||||
// Taken after the generation is in its final storage: the client is a
|
||||
// pointer to the pool inside it.
|
||||
generation.pool = &built.pool;
|
||||
generation.client = built.pool.client();
|
||||
return generation;
|
||||
}
|
||||
|
||||
/// Publishes one generation at a time.
|
||||
///
|
||||
/// The initial generation is the caller's to provide and the owner's to tear
|
||||
/// down: `deinit` retires whatever is live, and every generation a `replace`
|
||||
/// displaces is retired by the owner or handed back to the caller idle.
|
||||
pub const Owner = struct {
|
||||
mutex: std.Io.Mutex = .init,
|
||||
live: *Generation,
|
||||
/// How many generations `replace` has published. One candidate per owner
|
||||
/// means one increment per configuration write, however many keys of this
|
||||
/// owner that write named. Guarded by `mutex`, like everything else here.
|
||||
///
|
||||
/// Not a `std.atomic.Value(u64)`: in Debug the x86_64 self-hosted backend
|
||||
/// of zig 0.16.0 miscompiles `replace` when a `lock xadd` sits between the
|
||||
/// `old.refs == 0` comparison and the branch on its result, and `replace`
|
||||
/// then returns null for every input. See AGENTS.md.
|
||||
published: u64 = 0,
|
||||
|
||||
pub fn init(live: *Generation) Owner {
|
||||
return .{ .live = live };
|
||||
}
|
||||
|
||||
/// Pins the live generation for one exchange or one scrape. The returned
|
||||
/// generation stays valid until the matching `release`, across any number
|
||||
/// of replaces.
|
||||
pub fn acquire(self: *Owner, io: std.Io) *Generation {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
self.live.refs += 1;
|
||||
return self.live;
|
||||
}
|
||||
|
||||
/// The subject keys the live generation's report is filed under, copied
|
||||
/// into `arena`.
|
||||
///
|
||||
/// Taken at prepare so that the retire-time reconciliation never depends on
|
||||
/// the displaced generation still being alive: by then its last reader may
|
||||
/// have freed it. The copy runs under the mutex because that is what pins
|
||||
/// the generation whose arena the strings live in; the allocation is from a
|
||||
/// bump arena and touches no `std.Io` primitive, so the hold stays as brief
|
||||
/// as every other one this type takes.
|
||||
pub fn copyLiveReportKeys(
|
||||
self: *Owner,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
) Allocator.Error![]const []const u8 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const notes = self.live.report().notes;
|
||||
const copies = try arena.alloc([]const u8, notes.len);
|
||||
for (notes, copies) |finding, *slot| slot.* = try arena.dupe(u8, finding.url);
|
||||
return copies;
|
||||
}
|
||||
|
||||
pub fn release(self: *Owner, io: std.Io, generation: *Generation) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
std.debug.assert(generation.refs > 0);
|
||||
generation.refs -= 1;
|
||||
const retire_it = generation.retired and generation.refs == 0;
|
||||
self.mutex.unlock(io);
|
||||
if (retire_it) generation.retire(io);
|
||||
}
|
||||
|
||||
/// Publishes `prepared` and retires the live generation. Infallible and
|
||||
/// I/O-free by construction: a pointer swap under the mutex.
|
||||
///
|
||||
/// Returns the displaced generation when no reader held it, because there
|
||||
/// is then no release left to retire it and the caller must — the same
|
||||
/// `refs == 0` branch `CertStore.reload` takes. Returns null when a reader
|
||||
/// still holds it; that reader's release retires it.
|
||||
pub fn replace(self: *Owner, io: std.Io, prepared: *Generation) ?*Generation {
|
||||
std.debug.assert(prepared.refs == 0);
|
||||
std.debug.assert(!prepared.retired);
|
||||
self.mutex.lockUncancelable(io);
|
||||
const old = self.live;
|
||||
self.live = prepared;
|
||||
old.retired = true;
|
||||
const idle = old.refs == 0;
|
||||
self.published += 1;
|
||||
self.mutex.unlock(io);
|
||||
return if (idle) old else null;
|
||||
}
|
||||
|
||||
/// How many replaces this owner has published.
|
||||
pub fn publishedCount(self: *Owner, io: std.Io) u64 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.published;
|
||||
}
|
||||
|
||||
/// Shutdown teardown. Every listener and every metrics reader must have
|
||||
/// stopped: a live generation with readers left is a caller that tore down
|
||||
/// out of order, and a retired one with readers was freed by its own last
|
||||
/// release already.
|
||||
pub fn deinit(self: *Owner, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
const live = self.live;
|
||||
std.debug.assert(live.refs == 0);
|
||||
self.mutex.unlock(io);
|
||||
live.retire(io);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// An owner over transport the caller already has: a fake client in a handler
|
||||
/// test, or a pool a fixture built from fake entries. Owns nothing, so there is
|
||||
/// nothing to tear down; both parts are inline so a caller keeps them on its
|
||||
/// stack beside the handler.
|
||||
pub const Borrowed = struct {
|
||||
generation: Generation = undefined,
|
||||
owner: Owner = undefined,
|
||||
|
||||
pub fn client(self: *Borrowed, c: transport.Client) *Owner {
|
||||
self.generation = .borrowing(c);
|
||||
self.owner = .init(&self.generation);
|
||||
return &self.owner;
|
||||
}
|
||||
|
||||
pub fn pool(self: *Borrowed, p: *pool_mod.Pool) *Owner {
|
||||
self.generation = .borrowingPool(p);
|
||||
self.owner = .init(&self.generation);
|
||||
return &self.owner;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the pool's entries and everything they point into
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
|
||||
/// slot of its entry, so that many exchanges can be in flight against it at
|
||||
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
|
||||
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
|
||||
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
|
||||
/// allocation here lives exactly as long as the generation does, and none of
|
||||
/// them is ever resized. One slot is used by one task at a time, which is why
|
||||
/// the buffers are per client and not shared the way `cli.probeUpstreams`
|
||||
/// shares them.
|
||||
const Upstreams = struct {
|
||||
entries: []pool_mod.Entry,
|
||||
used: usize,
|
||||
/// Sliced per entry into `Entry.slots`, never pointing into the client
|
||||
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
|
||||
slot_storage: []pool_mod.Slot,
|
||||
/// One per enabled upstream, and the reason it is a separate allocation:
|
||||
/// `Pool.init` sorts entries by value, so a counter living inside an entry
|
||||
/// would be pointed at by the wrong upstream's clients after the sort.
|
||||
recovery_counters: []std.atomic.Value(u64),
|
||||
doh: []doh_client.DohClient,
|
||||
dot: []dot_client.DotClient,
|
||||
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
|
||||
/// upstream leaves the tail of an over-allocated array undefined, and both
|
||||
/// `deinit` and `build`'s failure paths iterate only the initialized
|
||||
/// prefix — reading a `DotClient` that was never built, or closing a
|
||||
/// session that was never opened, is what these two counts prevent.
|
||||
doh_used: usize,
|
||||
dot_used: usize,
|
||||
doh_buf: []u8,
|
||||
dot_buf: []u8,
|
||||
|
||||
/// A disabled upstream is left out entirely; a malformed one is noted and
|
||||
/// skipped, because one bad row in a table of four must not take DNS down.
|
||||
/// No usable row at all is a configuration fault.
|
||||
///
|
||||
/// `arena` is the generation's: `notes` borrows from it and so does every
|
||||
/// string in `servers`, which the caller has already copied there.
|
||||
fn build(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
arena: Allocator,
|
||||
servers: []const model.UpstreamServer,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
notes: *std.ArrayList(BuildReport.Note),
|
||||
) BuildError!Upstreams {
|
||||
var enabled: usize = 0;
|
||||
for (servers) |server| {
|
||||
if (server.enabled) enabled += 1;
|
||||
}
|
||||
if (enabled == 0) return error.NoUsableUpstreams;
|
||||
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const slots = pool_mod.slots_per_entry;
|
||||
const leaf_clients = enabled * slots;
|
||||
|
||||
var self: Upstreams = .{
|
||||
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
||||
.used = 0,
|
||||
.slot_storage = &.{},
|
||||
.recovery_counters = &.{},
|
||||
.doh = &.{},
|
||||
.dot = &.{},
|
||||
.doh_used = 0,
|
||||
.dot_used = 0,
|
||||
.doh_buf = &.{},
|
||||
.dot_buf = &.{},
|
||||
};
|
||||
errdefer self.deinit(io, gpa);
|
||||
|
||||
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
|
||||
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
|
||||
for (self.recovery_counters) |*counter| counter.* = .init(0);
|
||||
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
|
||||
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
|
||||
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
|
||||
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
|
||||
|
||||
for (servers) |server| {
|
||||
if (!server.enabled) continue;
|
||||
|
||||
const endpoint = transport.Endpoint.parse(server.url) catch {
|
||||
try note(arena, notes, server.url, "not an https:// or tls:// endpoint; skipped");
|
||||
continue;
|
||||
};
|
||||
|
||||
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
|
||||
switch (endpoint.scheme) {
|
||||
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
|
||||
try note(arena, notes, server.url, "not a usable DoH url; skipped");
|
||||
continue;
|
||||
},
|
||||
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
|
||||
}
|
||||
|
||||
self.entries[self.used] = .{
|
||||
.endpoint = endpoint,
|
||||
.slots = entry_slots,
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = entry_slots.len },
|
||||
.reuse_recoveries = &self.recovery_counters[self.used],
|
||||
};
|
||||
self.used += 1;
|
||||
}
|
||||
|
||||
if (self.used == 0) return error.NoUsableUpstreams;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
|
||||
/// connection pool already serves concurrent requests, and a `DohClient`'s
|
||||
/// only mutable state is the two buffers this gives each slot its own of.
|
||||
///
|
||||
/// False means the url is not a usable DoH url, which `DohClient.init`
|
||||
/// decides from the url alone — so it fails on the first slot or on none.
|
||||
/// `doh_used` still advances per client rather than per entry: it means
|
||||
/// "initialized", and a skipped entry's clients are simply never reached.
|
||||
fn wireDoh(
|
||||
self: *Upstreams,
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
slots: []pool_mod.Slot,
|
||||
) bool {
|
||||
for (slots) |*slot| {
|
||||
const index = self.doh_used;
|
||||
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
|
||||
self.doh[index] = doh_client.DohClient.init(
|
||||
http,
|
||||
endpoint,
|
||||
self.doh_buf[base..][0..doh_request_buf_len],
|
||||
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
||||
) catch return false;
|
||||
self.doh_used = index + 1;
|
||||
slot.* = .{ .client = self.doh[index].client() };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// One `DotClient` per slot, each with its own four TLS buffers and all
|
||||
/// sharing the trust store. Every client of one entry reports its
|
||||
/// stale-reuse recoveries through that entry's counter.
|
||||
fn wireDot(
|
||||
self: *Upstreams,
|
||||
gpa: Allocator,
|
||||
endpoint: transport.Endpoint,
|
||||
tls_name: []const u8,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
slots: []pool_mod.Slot,
|
||||
) void {
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const recoveries = &self.recovery_counters[self.used];
|
||||
for (slots) |*slot| {
|
||||
const index = self.dot_used;
|
||||
const base = index * 4 * chunk;
|
||||
self.dot[index] = dot_client.DotClient.init(
|
||||
endpoint,
|
||||
tls_name,
|
||||
gpa,
|
||||
bundle,
|
||||
bundle_lock,
|
||||
recoveries,
|
||||
.{
|
||||
.tls_read = self.dot_buf[base..][0..chunk],
|
||||
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
||||
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
||||
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
||||
},
|
||||
);
|
||||
self.dot_used = index + 1;
|
||||
slot.* = .{ .client = self.dot[index].client() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
||||
/// never filled, which is what keeps `deinit` able to free the whole block.
|
||||
fn active(self: *Upstreams) []pool_mod.Entry {
|
||||
return self.entries[0..self.used];
|
||||
}
|
||||
|
||||
/// Connections first, memory second: a `DotClient` holds a socket its
|
||||
/// buffers belong to, so nothing it points at may be freed before it is
|
||||
/// closed.
|
||||
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
|
||||
for (self.dot[0..self.dot_used]) |*client| client.close(io);
|
||||
gpa.free(self.dot_buf);
|
||||
gpa.free(self.doh_buf);
|
||||
gpa.free(self.dot);
|
||||
gpa.free(self.doh);
|
||||
gpa.free(self.recovery_counters);
|
||||
gpa.free(self.slot_storage);
|
||||
gpa.free(self.entries);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// The warning goes out here as well as into the report: `std.log` is the
|
||||
/// operator's boot transcript and a runtime candidate that is refused is still
|
||||
/// worth a line, while the report is what writes the event row — at boot, or in
|
||||
/// retire once a candidate is published.
|
||||
fn note(
|
||||
arena: Allocator,
|
||||
notes: *std.ArrayList(BuildReport.Note),
|
||||
url: []const u8,
|
||||
message: []const u8,
|
||||
) Allocator.Error!void {
|
||||
log.warn("upstream {f} {s}", .{ safe_url.redactQuoted(url), message });
|
||||
// `url` already lives in the generation's arena; the message is a literal.
|
||||
try notes.append(arena, .{ .url = url, .message = message });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const TestIo = struct {
|
||||
threaded: std.Io.Threaded,
|
||||
|
||||
fn init(gpa: Allocator) TestIo {
|
||||
return .{ .threaded = .init(gpa, .{}) };
|
||||
}
|
||||
|
||||
fn io(self: *TestIo) std.Io {
|
||||
return self.threaded.io();
|
||||
}
|
||||
|
||||
fn deinit(self: *TestIo) void {
|
||||
self.threaded.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
const test_timeouts: pool_mod.Timeouts = .{
|
||||
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
|
||||
};
|
||||
|
||||
/// A client that answers from a fixed reply and records the identity it named,
|
||||
/// so a test can prove which generation served an exchange.
|
||||
const FakeClient = struct {
|
||||
identity: []const u8,
|
||||
calls: std.atomic.Value(u64) = .init(0),
|
||||
/// Set once the exchange is inside the client, so a test knows the
|
||||
/// generation is really pinned before it swaps.
|
||||
entered: ?*std.Io.Event = null,
|
||||
/// Waited on before the exchange returns, so a test can hold an exchange
|
||||
/// open across a `replace`.
|
||||
gate: ?*std.Io.Event = null,
|
||||
|
||||
fn client(self: *FakeClient) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
selected: *?[]const u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const self: *FakeClient = @ptrCast(@alignCast(ptr));
|
||||
selected.* = self.identity;
|
||||
_ = self.calls.fetchAdd(1, .monotonic);
|
||||
if (self.entered) |entered| entered.set(io);
|
||||
if (self.gate) |gate| gate.wait(io) catch return error.Canceled;
|
||||
@memcpy(response_buf[0..query.len], query);
|
||||
return response_buf[0..query.len];
|
||||
}
|
||||
};
|
||||
|
||||
fn buildTestGeneration(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
servers: []const model.UpstreamServer,
|
||||
) BuildError!*Generation {
|
||||
return build(.{
|
||||
.gpa = gpa,
|
||||
.io = io,
|
||||
.servers = servers,
|
||||
.http = http,
|
||||
.bundle = bundle,
|
||||
.bundle_lock = bundle_lock,
|
||||
.timeouts = test_timeouts,
|
||||
.seed = 1,
|
||||
});
|
||||
}
|
||||
|
||||
test "an owner hands out the live generation and retires the old one on the last release" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var first: FakeClient = .{ .identity = "fake://g1" };
|
||||
var second: FakeClient = .{ .identity = "fake://g2" };
|
||||
var g1: Generation = .borrowing(first.client());
|
||||
var g2: Generation = .borrowing(second.client());
|
||||
|
||||
var owner: Owner = .init(&g1);
|
||||
|
||||
const held = owner.acquire(io);
|
||||
try testing.expectEqual(&g1, held);
|
||||
|
||||
// A reader holds G1, so the swap cannot retire it here.
|
||||
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
|
||||
try testing.expect(g1.retired);
|
||||
|
||||
// A new acquire lands on G2 while the old reader is still on G1.
|
||||
const fresh = owner.acquire(io);
|
||||
try testing.expectEqual(&g2, fresh);
|
||||
owner.release(io, fresh);
|
||||
|
||||
owner.release(io, held);
|
||||
owner.deinit(io);
|
||||
}
|
||||
|
||||
test "a replace with no reader holding the live generation retires it through the return path" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
||||
.{ .url = "https://one.example/dns-query" },
|
||||
});
|
||||
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
||||
.{ .url = "https://two.example/dns-query" },
|
||||
});
|
||||
|
||||
var owner: Owner = .init(g1);
|
||||
|
||||
// Nobody holds G1: `replace` must hand it back, because no release will.
|
||||
const displaced = owner.replace(io, g2) orelse return error.ExpectedIdleGeneration;
|
||||
try testing.expectEqual(g1, displaced);
|
||||
displaced.retire(io);
|
||||
|
||||
owner.deinit(io);
|
||||
}
|
||||
|
||||
test "a generation owns its configuration strings after the rows they came from are freed" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
// The rows a request arena would hand `build`. Freeing the arena poisons
|
||||
// every byte of them, so a generation that kept a borrow reads garbage.
|
||||
var rows_arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
const rows = rows_arena.allocator();
|
||||
const servers = try rows.dupe(model.UpstreamServer, &.{
|
||||
.{ .url = try rows.dupe(u8, "tls://dot.example.net:853"), .tls_name = try rows.dupe(u8, "dot.example.net") },
|
||||
});
|
||||
|
||||
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, servers);
|
||||
rows_arena.deinit();
|
||||
|
||||
var owner: Owner = .init(generation);
|
||||
defer owner.deinit(io);
|
||||
|
||||
const held = owner.acquire(io);
|
||||
defer owner.release(io, held);
|
||||
|
||||
var snapshots: [4]pool_mod.Snapshot = undefined;
|
||||
const count = try held.pool.?.snapshot(io, &snapshots);
|
||||
try testing.expectEqual(@as(usize, 1), count);
|
||||
try testing.expectEqualStrings("tls://dot.example.net:853", snapshots[0].url);
|
||||
try testing.expectEqual(@as(usize, 1), held.activeCount());
|
||||
}
|
||||
|
||||
test "build reports a malformed row instead of writing it, and refuses a row set with nothing usable" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
||||
.{ .url = "ftp://nope.example" },
|
||||
.{ .url = "https://good.example/dns-query" },
|
||||
.{ .url = "https://disabled.example/dns-query", .enabled = false },
|
||||
});
|
||||
var owner: Owner = .init(generation);
|
||||
defer owner.deinit(io);
|
||||
|
||||
const report = generation.report();
|
||||
try testing.expectEqual(@as(usize, 1), report.notes.len);
|
||||
try testing.expectEqualStrings("ftp://nope.example", report.notes[0].url);
|
||||
try testing.expectEqualStrings("not an https:// or tls:// endpoint; skipped", report.notes[0].message);
|
||||
try testing.expectEqual(@as(usize, 1), generation.activeCount());
|
||||
|
||||
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
|
||||
io,
|
||||
testing.allocator,
|
||||
&http,
|
||||
&bundle,
|
||||
&bundle_lock,
|
||||
&.{.{ .url = "ftp://nope.example" }},
|
||||
));
|
||||
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
|
||||
io,
|
||||
testing.allocator,
|
||||
&http,
|
||||
&bundle,
|
||||
&bundle_lock,
|
||||
&.{.{ .url = "https://off.example", .enabled = false }},
|
||||
));
|
||||
}
|
||||
|
||||
// The concurrency criterion: an exchange in flight on G1 completes on G1, G1
|
||||
// deinits only after that reader releases, and every exchange started after
|
||||
// the swap runs on G2.
|
||||
test "an exchange in flight survives a replace and the old generation retires after it" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var entered: std.Io.Event = .unset;
|
||||
var gate: std.Io.Event = .unset;
|
||||
var first: FakeClient = .{ .identity = "fake://g1", .entered = &entered, .gate = &gate };
|
||||
var second: FakeClient = .{ .identity = "fake://g2" };
|
||||
var g1: Generation = .borrowing(first.client());
|
||||
var g2: Generation = .borrowing(second.client());
|
||||
var owner: Owner = .init(&g1);
|
||||
|
||||
const Exchange = struct {
|
||||
fn run(o: *Owner, inner_io: std.Io, out: *?[]const u8) void {
|
||||
const generation = o.acquire(inner_io);
|
||||
defer o.release(inner_io, generation);
|
||||
var buf: [16]u8 = undefined;
|
||||
var selected: ?[]const u8 = null;
|
||||
_ = generation.client.exchange(inner_io, "abc", &buf, &selected) catch {};
|
||||
out.* = selected;
|
||||
}
|
||||
};
|
||||
|
||||
var in_flight: ?[]const u8 = null;
|
||||
var future = try io.concurrent(Exchange.run, .{ &owner, io, &in_flight });
|
||||
|
||||
// The swap must land with G1 really pinned, not merely likely to be.
|
||||
entered.waitUncancelable(io);
|
||||
|
||||
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
|
||||
|
||||
var after: ?[]const u8 = null;
|
||||
Exchange.run(&owner, io, &after);
|
||||
try testing.expectEqualStrings("fake://g2", after.?);
|
||||
|
||||
gate.set(io);
|
||||
future.await(io);
|
||||
try testing.expectEqualStrings("fake://g1", in_flight.?);
|
||||
|
||||
owner.deinit(io);
|
||||
}
|
||||
|
||||
test "a metrics scrape running against the owner survives a replace under it" {
|
||||
var t: TestIo = .init(testing.allocator);
|
||||
defer t.deinit();
|
||||
const io = t.io();
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
||||
.{ .url = "https://one.example/dns-query" },
|
||||
});
|
||||
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
||||
.{ .url = "https://two.example/dns-query" },
|
||||
.{ .url = "tls://two.example:853", .tls_name = "two.example" },
|
||||
});
|
||||
var owner: Owner = .init(g1);
|
||||
defer owner.deinit(io);
|
||||
|
||||
const Scrape = struct {
|
||||
/// What every scrape must be true of, whichever generation answered
|
||||
/// it: a URL the scrape read out of a generation it holds is a URL
|
||||
/// nothing has freed.
|
||||
fn run(o: *Owner, inner_io: std.Io, started: *std.Io.Event, seen: *usize) void {
|
||||
for (0..256) |i| {
|
||||
const generation = o.acquire(inner_io);
|
||||
defer o.release(inner_io, generation);
|
||||
|
||||
var raw: [8]pool_mod.Snapshot = undefined;
|
||||
const count = generation.pool.?.snapshot(inner_io, &raw) catch 0;
|
||||
for (raw[0..count]) |entry| {
|
||||
if (std.mem.startsWith(u8, entry.url, "https://") or
|
||||
std.mem.startsWith(u8, entry.url, "tls://")) seen.* += 1;
|
||||
}
|
||||
if (i == 0) started.set(inner_io);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var started: std.Io.Event = .unset;
|
||||
var seen: usize = 0;
|
||||
var future = try io.concurrent(Scrape.run, .{ &owner, io, &started, &seen });
|
||||
|
||||
started.waitUncancelable(io);
|
||||
if (owner.replace(io, g2)) |old| old.retire(io);
|
||||
|
||||
future.await(io);
|
||||
// Every one of the 256 scrapes read at least one intact URL.
|
||||
try testing.expect(seen >= 256);
|
||||
}
|
||||
+194
-4
@@ -134,16 +134,16 @@ pub const ApiLimiter = struct {
|
||||
|
||||
/// Spends one token for a request from `addr`. Never allocates, never fails.
|
||||
pub fn check(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, addr: address.NetAddress) Result {
|
||||
if (self.config.localhost_exempt and isLoopback(addr)) {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
// `localhost_exempt` is read under the mutex like every other config
|
||||
// field: `setLimits` may be installing a new one concurrently.
|
||||
if (self.config.localhost_exempt and isLoopback(addr)) {
|
||||
self.stats.exempt += 1;
|
||||
return .ok;
|
||||
}
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const bucket = self.bucketLocked(addr.key(), now) orelse {
|
||||
self.stats.untracked += 1;
|
||||
self.stats.refused += 1;
|
||||
@@ -238,6 +238,53 @@ pub const ApiLimiter = struct {
|
||||
return self.table.count();
|
||||
}
|
||||
|
||||
/// Installs new limits. Every live bucket is first refilled THROUGH `now`
|
||||
/// at the OLD rate — the time already elapsed was earned under the rate
|
||||
/// that was in force, and refilling it at the new rate would credit or
|
||||
/// deny tokens retroactively — then clamped to the new capacity, and only
|
||||
/// then does the new rate take over.
|
||||
///
|
||||
/// Per-address SSE counts are untouched: they count live connections, not
|
||||
/// a budget, and the new `sse_max_per_ip` applies to the next acquire.
|
||||
///
|
||||
/// No bucket's clock moves BACKWARD here. `now` is read before the write
|
||||
/// this install belongs to commits, so a request served in between leaves
|
||||
/// a bucket already newer than it; rewinding that bucket to `now` would
|
||||
/// let the next request buy the same interval a second time, at the new
|
||||
/// rate. Such a bucket is only clamped to the new capacity — it has
|
||||
/// already been refilled through a later reading at the old rate, which is
|
||||
/// exactly what this phase owes it.
|
||||
pub fn setLimits(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, limits: Config) void {
|
||||
std.debug.assert(limits.rate_per_min > 0);
|
||||
const new_capacity = @as(u64, limits.rate_per_min) * token_scale;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const old_capacity = self.capacity;
|
||||
var it = self.table.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const bucket = entry.value_ptr;
|
||||
if (bucket.updated_ns >= now.nanoseconds) {
|
||||
bucket.tokens = @min(bucket.tokens, new_capacity);
|
||||
continue;
|
||||
}
|
||||
const state = refilled(bucket.*, now, old_capacity);
|
||||
bucket.tokens = @min(state.tokens, new_capacity);
|
||||
bucket.updated_ns = state.updated_ns;
|
||||
}
|
||||
|
||||
self.config = limits;
|
||||
self.capacity = new_capacity;
|
||||
}
|
||||
|
||||
/// The live limits, read under the same mutex that installs them.
|
||||
pub fn snapshotConfig(self: *ApiLimiter, io: std.Io) Config {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.config;
|
||||
}
|
||||
|
||||
pub fn snapshotStats(self: *ApiLimiter, io: std.Io) Stats {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
@@ -685,3 +732,146 @@ fn initCheckDeinit(allocator: Allocator) !void {
|
||||
test "init surfaces allocation failure without leaking" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, initCheckDeinit, .{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setLimits (milestone-34 S3.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "setLimits refills through now at the OLD rate before the new one applies" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 5);
|
||||
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
// Thirty seconds under the old 60/min rate is worth 30 tokens. Refilling
|
||||
// those same thirty seconds at the new 600/min rate would be worth 300 —
|
||||
// a burst the operator never authorized for time already spent.
|
||||
fx.limiter.setLimits(fx.io(), at(30), .{
|
||||
.rate_per_min = 600,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..30) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
}
|
||||
|
||||
test "a bucket newer than the reading setLimits was given keeps its clock" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 8);
|
||||
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
// The request that lands between the settings prepare and its publish.
|
||||
// Thirty seconds at 60/min is worth thirty tokens, one of which it spends.
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
|
||||
// The install carries the reading taken at prepare, which is now stale.
|
||||
// Rewinding the bucket to it would sell those same thirty seconds again,
|
||||
// and at 600/min they are worth three hundred tokens rather than thirty.
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 600,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..29) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
|
||||
}
|
||||
|
||||
test "a capacity cut clamps a bucket that held more than the new capacity" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 6);
|
||||
// One spend creates the bucket, leaving it at 599 of 600.
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 5,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
|
||||
const refused = fx.limiter.check(fx.io(), at(0), client);
|
||||
try testing.expect(!refused.allowed);
|
||||
// At 5 per minute a whole token is twelve seconds.
|
||||
try testing.expectEqual(@as(u32, 12), refused.retry_after_s);
|
||||
}
|
||||
|
||||
test "setLimits leaves per-address SSE counts alone" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const client = v4(10, 0, 0, 7);
|
||||
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 120,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 2,
|
||||
});
|
||||
|
||||
// The live connections survive; the tightened cap governs the next one.
|
||||
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
|
||||
try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), client));
|
||||
}
|
||||
|
||||
test "a localhost exemption flip is observed by the next check" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = true, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const local = v4(127, 0, 0, 1);
|
||||
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
try testing.expectEqual(@as(u64, 5), fx.limiter.snapshotStats(fx.io()).exempt);
|
||||
|
||||
fx.limiter.setLimits(fx.io(), at(0), .{
|
||||
.rate_per_min = 1,
|
||||
.localhost_exempt = false,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
|
||||
try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
try testing.expect(!fx.limiter.check(fx.io(), at(0), local).allowed);
|
||||
const stats = fx.limiter.snapshotStats(fx.io());
|
||||
try testing.expectEqual(@as(u64, 5), stats.exempt);
|
||||
try testing.expectEqual(@as(u64, 1), stats.refused);
|
||||
}
|
||||
|
||||
test "concurrent checks against a running setLimits stay consistent" {
|
||||
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
|
||||
defer fx.deinit();
|
||||
|
||||
const Racer = struct {
|
||||
fn spend(limiter: *ApiLimiter, io: std.Io) void {
|
||||
for (0..2_000) |i| {
|
||||
_ = limiter.check(io, atMillis(@intCast(i)), indexed(@intCast(i % 64)));
|
||||
}
|
||||
}
|
||||
fn reconfigure(limiter: *ApiLimiter, io: std.Io) void {
|
||||
for (0..2_000) |i| {
|
||||
limiter.setLimits(io, atMillis(@intCast(i)), .{
|
||||
.rate_per_min = if (i % 2 == 0) 5 else 600,
|
||||
.localhost_exempt = i % 3 == 0,
|
||||
.sse_max_per_ip = 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(fx.io());
|
||||
try group.concurrent(fx.io(), Racer.spend, .{ &fx.limiter, fx.io() });
|
||||
try group.concurrent(fx.io(), Racer.reconfigure, .{ &fx.limiter, fx.io() });
|
||||
try group.await(fx.io());
|
||||
|
||||
// Every non-exempt call landed on exactly one side of the ledger.
|
||||
const stats = fx.limiter.snapshotStats(fx.io());
|
||||
try testing.expectEqual(@as(u64, 2_000), stats.allowed + stats.refused + stats.exempt);
|
||||
}
|
||||
|
||||
+86
-2
@@ -245,11 +245,14 @@ pub const LiveHash = struct {
|
||||
};
|
||||
|
||||
/// One live session. `last_used` drives the LRU eviction and moves on every
|
||||
/// successful validation; `expires_at` is fixed at login, so a session ends at
|
||||
/// its TTL however busy it was.
|
||||
/// successful validation; `expires_at` is `issued_at + ttl`, so a session ends
|
||||
/// at its TTL however busy it was. `issued_at` is kept so `setTtl` can
|
||||
/// recompute `expires_at` for live sessions from their origin rather than from
|
||||
/// the moment of the change.
|
||||
const Slot = struct {
|
||||
used: bool,
|
||||
digest: [Sha256.digest_length]u8,
|
||||
issued_at: i64,
|
||||
expires_at: i64,
|
||||
last_used: i64,
|
||||
};
|
||||
@@ -282,6 +285,7 @@ pub const Sessions = struct {
|
||||
.slots = @splat(.{
|
||||
.used = false,
|
||||
.digest = @splat(0),
|
||||
.issued_at = 0,
|
||||
.expires_at = 0,
|
||||
.last_used = 0,
|
||||
}),
|
||||
@@ -311,6 +315,7 @@ pub const Sessions = struct {
|
||||
slot.* = .{
|
||||
.used = true,
|
||||
.digest = digest,
|
||||
.issued_at = now_s,
|
||||
.expires_at = now_s + self.ttl_seconds,
|
||||
.last_used = now_s,
|
||||
};
|
||||
@@ -381,6 +386,36 @@ pub const Sessions = struct {
|
||||
return live;
|
||||
}
|
||||
|
||||
/// Installs a new TTL and re-dates every live session from its
|
||||
/// `issued_at`, so the change is retroactive rather than sliding.
|
||||
///
|
||||
/// The semantics are SERVER-SIDE only. Shortening takes effect for every
|
||||
/// session at once, including ones that are already over the new age — the
|
||||
/// next sweep drops them. Lengthening extends how long the table honours a
|
||||
/// session, but the browser still holds the cookie's ORIGINAL `Max-Age`:
|
||||
/// the cookie is never refreshed, so a session does not become sliding and
|
||||
/// a lengthened session ends when the browser drops the cookie.
|
||||
pub fn setTtl(self: *Sessions, io: std.Io, ttl_hours: u16) void {
|
||||
std.debug.assert(ttl_hours > 0);
|
||||
const ttl_seconds = @as(i64, ttl_hours) * 3600;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.ttl_seconds = ttl_seconds;
|
||||
for (&self.slots) |*slot| {
|
||||
if (!slot.used) continue;
|
||||
slot.expires_at = slot.issued_at + ttl_seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// The live TTL in seconds, for the login cookie's `Max-Age`.
|
||||
pub fn ttlSeconds(self: *Sessions, io: std.Io) i64 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.ttl_seconds;
|
||||
}
|
||||
|
||||
fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot {
|
||||
var found: ?*Slot = null;
|
||||
for (&self.slots) |*slot| {
|
||||
@@ -557,6 +592,55 @@ test "a session expires at its ttl and frees its slot" {
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200));
|
||||
}
|
||||
|
||||
test "shortening the ttl expires an over-age live session at once" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// Two hours, so a session minted at 0 is live at 3_600.
|
||||
var sessions: Sessions = .init(2);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 3_600));
|
||||
|
||||
// One hour re-dates it from `issued_at`, which puts its expiry at 3_600.
|
||||
sessions.setTtl(io, 1);
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 3_600));
|
||||
}
|
||||
|
||||
test "lengthening the ttl extends a live session server-side" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(1);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
|
||||
|
||||
// Re-dating from `issued_at` rather than from now: three hours after
|
||||
// minting, not three hours from here.
|
||||
var extended: Sessions = .init(1);
|
||||
const live = extended.createWithToken(io, tokenOf(9), 0);
|
||||
extended.setTtl(io, 3);
|
||||
try testing.expect(extended.validateAt(io, &live, 3_600));
|
||||
try testing.expect(extended.validateAt(io, &live, 10_799));
|
||||
try testing.expect(!extended.validateAt(io, &live, 10_800));
|
||||
}
|
||||
|
||||
test "a session minted after setTtl uses the new ttl" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
sessions.setTtl(io, 2);
|
||||
try testing.expectEqual(@as(i64, 7_200), sessions.ttlSeconds(io));
|
||||
|
||||
const cookie = sessions.createWithToken(io, tokenOf(4), 100);
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 7_299));
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 7_300));
|
||||
}
|
||||
|
||||
test "the thirty-third session evicts the least recently used one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
//! The apply table and the prepare → commit → publish → retire machinery a
|
||||
//! configuration write drives.
|
||||
//!
|
||||
//! Every settings key names one CONCRETE operation — the owner that has to be
|
||||
//! told, not a class of change. `bind` and `web_lifecycle` are the two that
|
||||
//! cannot be executed in-process: they create or destroy a socket, so they set
|
||||
//! `restart_pending` and milestone 35 executes them. Everything else applies
|
||||
//! live, and no response or document claims otherwise.
|
||||
//!
|
||||
//! The four phases are the write contract, and `Plan` is what carries state
|
||||
//! between them:
|
||||
//!
|
||||
//! 1. `prepare` builds and validates ONE candidate per affected owner from the
|
||||
//! FINAL MERGED configuration. It is the only fallible phase. A failure
|
||||
//! leaves the database and the running server untouched, and the caller
|
||||
//! calls `abandon`.
|
||||
//! 2. The caller commits its transaction. A commit failure is also `abandon`.
|
||||
//! 3. `publish` swaps handles and pointers and stores atomics. Infallible and
|
||||
//! I/O-free: nothing here opens, closes, joins or frees.
|
||||
//! 4. `retire` closes and frees what publish displaced, and reconciles the
|
||||
//! upstream diagnostics — event rows are SQLite I/O and are banned from a
|
||||
//! publish.
|
||||
//!
|
||||
//! An operation whose owner this `WebState` does not have is skipped: the web
|
||||
//! layer must build without a whole running server, and a handler test wires
|
||||
//! only what it asserts on. The database row is still written, because the row
|
||||
//! is the configuration and the runtime is a consumer of it.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const cert_store = @import("../../server/cert_store.zig");
|
||||
const dns_cache = @import("../../cache/dns_cache.zig");
|
||||
const dns_handler = @import("../../server/handler.zig");
|
||||
const disk_monitor = @import("../../storage/disk_monitor.zig");
|
||||
const logger_controller = @import("../../storage/logger_controller.zig");
|
||||
const logging = @import("../../platform/logging.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const rate_limiter = @import("../../server/rate_limiter.zig");
|
||||
const server = @import("../server.zig");
|
||||
const upstream_owner = @import("../../upstream/owner.zig");
|
||||
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
|
||||
const log = std.log.scoped(.web_api);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the key table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The owner a settings key belongs to. One entry per owner, never one per key:
|
||||
/// two keys of the same owner produce ONE candidate.
|
||||
pub const Operation = enum {
|
||||
dns_policy,
|
||||
trusted_proxies,
|
||||
logger_privacy,
|
||||
logger_flush,
|
||||
disk_thresholds,
|
||||
upstream_generation,
|
||||
cache,
|
||||
rate_limiter,
|
||||
sessions_ttl,
|
||||
api_limiter,
|
||||
retention,
|
||||
certs_doh,
|
||||
certs_dot,
|
||||
log_sink,
|
||||
scheduler,
|
||||
logger_queue,
|
||||
/// A listener's address, port, or existence. Milestone 35 executes these.
|
||||
bind,
|
||||
/// `web.enabled`. Milestone 35 executes it.
|
||||
web_lifecycle,
|
||||
|
||||
/// The broad class, DERIVED rather than listed a second time: a second
|
||||
/// table would be a second authority, and the two would drift.
|
||||
pub fn class(self: Operation) Class {
|
||||
return switch (self) {
|
||||
.bind => .bind,
|
||||
.web_lifecycle => .web_lifecycle,
|
||||
// Everything a candidate has to be built for before it can be
|
||||
// published: memory, a file, a connection, a task.
|
||||
.upstream_generation,
|
||||
.cache,
|
||||
.rate_limiter,
|
||||
.certs_doh,
|
||||
.certs_dot,
|
||||
.log_sink,
|
||||
.logger_queue,
|
||||
.trusted_proxies,
|
||||
=> .subsystem,
|
||||
.dns_policy,
|
||||
.logger_privacy,
|
||||
.logger_flush,
|
||||
.disk_thresholds,
|
||||
.sessions_ttl,
|
||||
.api_limiter,
|
||||
.retention,
|
||||
.scheduler,
|
||||
=> .live,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a change to this owner waits for a restart. The two that do are
|
||||
/// the two milestone 35 owns.
|
||||
pub fn needsRestart(self: Operation) bool {
|
||||
return switch (self.class()) {
|
||||
.bind, .web_lifecycle => true,
|
||||
.live, .subsystem => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Class = enum { live, subsystem, bind, web_lifecycle };
|
||||
|
||||
pub const Set = std.EnumSet(Operation);
|
||||
|
||||
pub const Entry = struct { key: []const u8, operation: Operation };
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order, with the owner it
|
||||
/// belongs to. `web.password` is write-only and `web.password_hash` is hidden;
|
||||
/// neither is a settings key, and the completeness test below spells out the
|
||||
/// same two exclusions the read and write shapes use.
|
||||
pub const table = [_]Entry{
|
||||
.{ .key = "upstream.attempt_timeout_ms", .operation = .upstream_generation },
|
||||
// The forward-zone client's read deadline, which the per-query policy
|
||||
// carries — not one of the pool's two timeouts.
|
||||
.{ .key = "upstream.read_timeout_ms", .operation = .dns_policy },
|
||||
.{ .key = "upstream.total_timeout_ms", .operation = .upstream_generation },
|
||||
|
||||
.{ .key = "dns.bind_ipv4", .operation = .bind },
|
||||
.{ .key = "dns.bind_ipv6", .operation = .bind },
|
||||
.{ .key = "dns.port", .operation = .bind },
|
||||
.{ .key = "dns.rate_limit", .operation = .rate_limiter },
|
||||
.{ .key = "dns.rate_window_seconds", .operation = .rate_limiter },
|
||||
|
||||
.{ .key = "blocking.response", .operation = .dns_policy },
|
||||
.{ .key = "blocking.ttl", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "cache.size", .operation = .cache },
|
||||
// The cache keeps no copy of its configuration: `classify` reads the
|
||||
// negative ceiling off the per-query policy.
|
||||
.{ .key = "cache.negative_ttl_max", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "web.enabled", .operation = .web_lifecycle },
|
||||
.{ .key = "web.bind", .operation = .bind },
|
||||
.{ .key = "web.port", .operation = .bind },
|
||||
.{ .key = "web.session_ttl_hours", .operation = .sessions_ttl },
|
||||
.{ .key = "web.api_rate_limit_per_min", .operation = .api_limiter },
|
||||
.{ .key = "web.api_localhost_exempt", .operation = .api_limiter },
|
||||
.{ .key = "web.sse_max_connections_per_ip", .operation = .api_limiter },
|
||||
.{ .key = "web.trusted_proxies", .operation = .trusted_proxies },
|
||||
|
||||
.{ .key = "doh_server.enabled", .operation = .bind },
|
||||
.{ .key = "doh_server.bind", .operation = .bind },
|
||||
.{ .key = "doh_server.port", .operation = .bind },
|
||||
.{ .key = "doh_server.cert_path", .operation = .certs_doh },
|
||||
.{ .key = "doh_server.key_path", .operation = .certs_doh },
|
||||
|
||||
.{ .key = "dot_server.enabled", .operation = .bind },
|
||||
.{ .key = "dot_server.bind", .operation = .bind },
|
||||
.{ .key = "dot_server.port", .operation = .bind },
|
||||
.{ .key = "dot_server.cert_path", .operation = .certs_dot },
|
||||
.{ .key = "dot_server.key_path", .operation = .certs_dot },
|
||||
|
||||
.{ .key = "edns.ecs_mode", .operation = .dns_policy },
|
||||
|
||||
.{ .key = "logging.level", .operation = .log_sink },
|
||||
.{ .key = "logging.retention_days", .operation = .retention },
|
||||
.{ .key = "logging.query_log_buffer_max", .operation = .logger_queue },
|
||||
.{ .key = "logging.query_log_flush_interval_s", .operation = .logger_flush },
|
||||
.{ .key = "logging.hide_domains", .operation = .logger_privacy },
|
||||
.{ .key = "logging.hide_client_ips", .operation = .logger_privacy },
|
||||
.{ .key = "logging.output", .operation = .log_sink },
|
||||
.{ .key = "logging.file_path", .operation = .log_sink },
|
||||
.{ .key = "logging.max_size_mb", .operation = .log_sink },
|
||||
.{ .key = "logging.max_files", .operation = .log_sink },
|
||||
|
||||
.{ .key = "disk.min_free_mb", .operation = .disk_thresholds },
|
||||
.{ .key = "disk.warn_free_mb", .operation = .disk_thresholds },
|
||||
|
||||
.{ .key = "blocklist_update.enabled", .operation = .scheduler },
|
||||
.{ .key = "blocklist_update.interval_hours", .operation = .scheduler },
|
||||
};
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash nxdns
|
||||
/// never computed.
|
||||
pub fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned. It is not a settings
|
||||
/// key: the hash it produces is, and its apply is the live-hash install.
|
||||
pub fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
pub fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
fn find(comptime key: []const u8) ?Operation {
|
||||
@setEvalBranchQuota(20_000);
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.key, key)) return entry.operation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The keys whose change waits for a restart, which is exactly the two
|
||||
/// milestone-35 operations. `/api/settings` reports this list as field-level
|
||||
/// metadata about the form.
|
||||
pub const restart_required_keys: []const []const u8 = &restart_keys;
|
||||
|
||||
const restart_keys = blk: {
|
||||
var list: [table.len][]const u8 = undefined;
|
||||
var count = 0;
|
||||
for (table) |entry| {
|
||||
if (!entry.operation.needsRestart()) continue;
|
||||
list[count] = entry.key;
|
||||
count += 1;
|
||||
}
|
||||
const final = list[0..count].*;
|
||||
break :blk final;
|
||||
};
|
||||
|
||||
/// Whether a scalar settings value differs between two configurations. Strings
|
||||
/// compare by bytes; everything else a settings row can hold compares by value.
|
||||
fn differs(comptime T: type, a: T, b: T) bool {
|
||||
if (T == []const u8) return !std.mem.eql(u8, a, b);
|
||||
return a != b;
|
||||
}
|
||||
|
||||
/// The owners a change from `before` to `after` has to tell.
|
||||
///
|
||||
/// Derived from the values, not from the keys the patch named: a PUT that
|
||||
/// rewrites a setting to what it already was changes nothing, so it must not
|
||||
/// resize a queue, rebuild a pool, or owe a restart. The admin form submits
|
||||
/// every field, and treating that as eighteen applies would be wrong as well as
|
||||
/// wasteful.
|
||||
pub fn changedOperations(before: model.Config, after: model.Config) Set {
|
||||
var ops: Set = .initEmpty();
|
||||
inline for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
comptime if (isHidden(section_field.name, field.name)) continue;
|
||||
comptime if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
const operation = comptime find(section_field.name ++ "." ++ field.name).?;
|
||||
const old = @field(@field(before, section_field.name), field.name);
|
||||
const new = @field(@field(after, section_field.name), field.name);
|
||||
if (differs(@TypeOf(old), old, new)) ops.insert(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything one configuration write prepared, published and still owes a
|
||||
/// retire. Exactly one of `abandon` or `publish` consumes a prepared plan, and
|
||||
/// a published one is always followed by `retire`.
|
||||
pub const Plan = struct {
|
||||
state: *server.WebState,
|
||||
/// The per-request arena. Holds the copied upstream report keys, which
|
||||
/// nothing outside this request reads.
|
||||
arena: Allocator,
|
||||
/// The final merged configuration every candidate is built from.
|
||||
cfg: model.Config,
|
||||
ops: Set,
|
||||
|
||||
// prepared candidates ---------------------------------------------------
|
||||
proxies: ?[]u8 = null,
|
||||
cache: ?*dns_cache.DnsCache = null,
|
||||
limiter: ?*rate_limiter.RateLimiter = null,
|
||||
upstream: ?*upstream_owner.Generation = null,
|
||||
upstream_previous_keys: []const []const u8 = &.{},
|
||||
doh_paths: ?cert_store.CertStore.PreparedPaths = null,
|
||||
dot_paths: ?cert_store.CertStore.PreparedPaths = null,
|
||||
sink: ?logging.PreparedApply = null,
|
||||
log_dir: ?*disk_monitor.LogDir = null,
|
||||
queue: ?logger_controller.Prepared = null,
|
||||
/// The reading `setLimits` refills its buckets against. Taken at prepare
|
||||
/// because publish reads no clock: a clock is I/O.
|
||||
limiter_now: ?std.Io.Timestamp = null,
|
||||
|
||||
// what publish displaced, for retire ------------------------------------
|
||||
retired_proxies: ?[]const u8 = null,
|
||||
retired_cache: ?*dns_cache.DnsCache = null,
|
||||
retired_limiter: ?*rate_limiter.RateLimiter = null,
|
||||
retired_upstream: ?*upstream_owner.Generation = null,
|
||||
detached_log_file: ?std.Io.File = null,
|
||||
published_upstream: bool = false,
|
||||
|
||||
pub fn init(
|
||||
state: *server.WebState,
|
||||
arena: Allocator,
|
||||
cfg: model.Config,
|
||||
ops: Set,
|
||||
) Plan {
|
||||
return .{ .state = state, .arena = arena, .cfg = cfg, .ops = ops };
|
||||
}
|
||||
|
||||
/// Builds every candidate the change needs. Returns the failure that
|
||||
/// refused it, in which case the caller must still call `abandon`: an
|
||||
/// earlier owner's candidate may already exist.
|
||||
///
|
||||
/// A propagated error abandons here instead, because the caller has no
|
||||
/// plan left to abandon: an earlier owner may already hold memory, a file,
|
||||
/// a connection, a parked writer or a lock — the cert store keeps
|
||||
/// `reload_mutex` held between its prepare and its publish.
|
||||
pub fn prepare(self: *Plan, io: std.Io) error{OutOfMemory}!?Failure {
|
||||
errdefer self.abandon(io);
|
||||
|
||||
const state = self.state;
|
||||
const gpa = state.gpa;
|
||||
|
||||
if (self.ops.contains(.trusted_proxies)) {
|
||||
self.proxies = try gpa.dupe(u8, self.cfg.web.trusted_proxies);
|
||||
}
|
||||
|
||||
if (self.ops.contains(.cache)) {
|
||||
const candidate = try gpa.create(dns_cache.DnsCache);
|
||||
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
|
||||
gpa.destroy(candidate);
|
||||
return err;
|
||||
};
|
||||
self.cache = candidate;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.rate_limiter)) {
|
||||
const candidate = try gpa.create(rate_limiter.RateLimiter);
|
||||
candidate.* = rate_limiter.RateLimiter.init(gpa, .{
|
||||
.limit = self.cfg.dns.rate_limit,
|
||||
.window_seconds = self.cfg.dns.rate_window_seconds,
|
||||
}) catch |err| {
|
||||
gpa.destroy(candidate);
|
||||
return err;
|
||||
};
|
||||
self.limiter = candidate;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.upstream_generation)) {
|
||||
if (try self.prepareUpstreams(io, self.cfg.upstreams)) |failure| return failure;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.certs_doh)) {
|
||||
// A disabled endpoint has no store: the change is a database row
|
||||
// and nothing else, and milestone 35 validates the paths when it
|
||||
// implements enable.
|
||||
if (state.doh_certs) |store| {
|
||||
self.doh_paths = store.preparePathChange(
|
||||
io,
|
||||
self.cfg.doh_server.cert_path,
|
||||
self.cfg.doh_server.key_path,
|
||||
) catch |err| return certFailure("doh_server", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.certs_dot)) {
|
||||
if (state.dot_certs) |store| {
|
||||
self.dot_paths = store.preparePathChange(
|
||||
io,
|
||||
self.cfg.dot_server.cert_path,
|
||||
self.cfg.dot_server.key_path,
|
||||
) catch |err| return certFailure("dot_server", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.log_sink)) {
|
||||
self.sink = logging.prepareApply(io, self.cfg.logging) catch |err| return switch (err) {
|
||||
error.PathTooLong => Failure{ .invalid = "logging.file_path: too long for this system" },
|
||||
error.TargetUnopenable => Failure{
|
||||
.invalid = "logging.file_path: this file cannot be opened for writing",
|
||||
},
|
||||
};
|
||||
// Derived from the final merged output AND file_path on every sink
|
||||
// apply, in both directions: output `file` measures the file's
|
||||
// directory, anything else measures nothing.
|
||||
self.log_dir = try disk_monitor.Monitor.prepareLogDir(gpa, logging.logDirname(self.cfg.logging));
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_queue)) {
|
||||
if (state.logger) |controller| {
|
||||
// The merged configuration, not the live one: a PUT that
|
||||
// changes the buffer size and a privacy flag together must not
|
||||
// hand the replacement generation the privacy it replaced.
|
||||
if (controller.prepare(io, self.cfg.logging)) |prepared| {
|
||||
self.queue = prepared;
|
||||
} else |err| {
|
||||
const entries = self.cfg.logging.query_log_buffer_max;
|
||||
if (try self.queueFailure(err, entries)) |failure| return failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.api_limiter)) self.limiter_now = std.Io.Clock.awake.now(io);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// What a refused resize means to the client, or null when it means nothing
|
||||
/// — a controller with no generation to replace is a test's borrowed one,
|
||||
/// and the row is still the configuration.
|
||||
fn queueFailure(
|
||||
self: *Plan,
|
||||
err: logger_controller.PrepareError,
|
||||
entries: u32,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
var buf: [96]u8 = undefined;
|
||||
if (logger_controller.sizeMessage(err, entries, &buf)) |message| {
|
||||
return Failure{ .invalid = try std.fmt.allocPrint(
|
||||
self.arena,
|
||||
"logging.query_log_buffer_max: {s}",
|
||||
.{message},
|
||||
) };
|
||||
}
|
||||
return switch (err) {
|
||||
error.NotResizable => null,
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.PreviousResizeDraining => Failure{
|
||||
.conflict = "a previous query-log resize is still draining",
|
||||
},
|
||||
else => blk: {
|
||||
log.warn("preparing the query-log resize failed: {s}", .{@errorName(err)});
|
||||
break :blk Failure{ .unavailable = "the query logger could not be resized" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// The upstream half, shared with the upstream resource handlers: they
|
||||
/// build their candidate from the HYPOTHETICAL post-mutation row set,
|
||||
/// before the repository write.
|
||||
pub fn prepareUpstreams(
|
||||
self: *Plan,
|
||||
io: std.Io,
|
||||
servers: []const model.UpstreamServer,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const state = self.state;
|
||||
const owner = state.upstreams orelse return null;
|
||||
const inputs = state.upstream_build orelse return null;
|
||||
|
||||
self.upstream_previous_keys = try owner.copyLiveReportKeys(io, self.arena);
|
||||
self.upstream = upstream_owner.build(.{
|
||||
.gpa = state.gpa,
|
||||
.io = io,
|
||||
.servers = servers,
|
||||
.http = inputs.http,
|
||||
.bundle = inputs.bundle,
|
||||
.bundle_lock = inputs.bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = model.attemptTimeout(self.cfg.upstream), .clock = .awake },
|
||||
.total = .{ .raw = model.totalTimeout(self.cfg.upstream), .clock = .awake },
|
||||
},
|
||||
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
.diagnostics = state.events,
|
||||
}) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.NoUsableUpstreams => return Failure{
|
||||
.conflict = "no usable upstream would be left",
|
||||
},
|
||||
};
|
||||
self.ops.insert(.upstream_generation);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Frees every candidate. The change did not happen: nothing was published,
|
||||
/// so nothing the running server holds is touched.
|
||||
pub fn abandon(self: *Plan, io: std.Io) void {
|
||||
const gpa = self.state.gpa;
|
||||
|
||||
if (self.proxies) |text| gpa.free(text);
|
||||
if (self.cache) |candidate| {
|
||||
candidate.deinit();
|
||||
gpa.destroy(candidate);
|
||||
}
|
||||
if (self.limiter) |candidate| {
|
||||
candidate.deinit();
|
||||
gpa.destroy(candidate);
|
||||
}
|
||||
if (self.upstream) |candidate| candidate.retire(io);
|
||||
if (self.doh_paths) |prepared| self.state.doh_certs.?.abortPathChange(io, prepared);
|
||||
if (self.dot_paths) |prepared| self.state.dot_certs.?.abortPathChange(io, prepared);
|
||||
if (self.sink) |prepared| logging.abortApply(io, prepared);
|
||||
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
|
||||
if (self.queue) |prepared| self.state.logger.?.abandon(io, prepared);
|
||||
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Infallible and I/O-free. Every operation whose owner is wired is told,
|
||||
/// and what it displaced is recorded for `retire`.
|
||||
pub fn publish(self: *Plan, io: std.Io) void {
|
||||
const state = self.state;
|
||||
|
||||
if (self.ops.contains(.dns_policy)) {
|
||||
if (state.handler) |h| h.setPolicy(io, .{
|
||||
.blocking = .{ .mode = self.cfg.blocking.response, .ttl = self.cfg.blocking.ttl },
|
||||
.ecs_mode = self.cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(self.cfg.upstream), .clock = .awake },
|
||||
.negative_ttl_max = self.cfg.cache.negative_ttl_max,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.proxies) |prepared| {
|
||||
self.retired_proxies = state.proxies.install(io, prepared);
|
||||
self.proxies = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_privacy)) {
|
||||
if (state.logger) |controller| controller.setPrivacy(io, .{
|
||||
.hide_domains = self.cfg.logging.hide_domains,
|
||||
.hide_client_ips = self.cfg.logging.hide_client_ips,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.ops.contains(.logger_flush)) {
|
||||
if (state.logger) |controller| {
|
||||
controller.setFlushInterval(io, self.cfg.logging.query_log_flush_interval_s);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.ops.contains(.disk_thresholds)) {
|
||||
if (state.monitor) |monitor| monitor.setThresholds(self.cfg.disk);
|
||||
}
|
||||
|
||||
if (self.upstream) |candidate| {
|
||||
self.retired_upstream = state.upstreams.?.replace(io, candidate);
|
||||
self.published_upstream = true;
|
||||
self.upstream = null;
|
||||
}
|
||||
|
||||
if (self.cache) |candidate| {
|
||||
// A handler-less state still owns the candidate, and dropping it
|
||||
// here would leak it: `replaceCache` is the only thing that can
|
||||
// hand the old one back.
|
||||
if (state.handler) |h| {
|
||||
self.retired_cache = h.replaceCache(io, candidate);
|
||||
} else {
|
||||
self.retired_cache = candidate;
|
||||
}
|
||||
self.cache = null;
|
||||
}
|
||||
|
||||
if (self.limiter) |candidate| {
|
||||
if (state.handler) |h| {
|
||||
self.retired_limiter = h.replaceRateLimiter(io, candidate);
|
||||
} else {
|
||||
self.retired_limiter = candidate;
|
||||
}
|
||||
self.limiter = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.sessions_ttl)) {
|
||||
if (state.sessions) |sessions| sessions.setTtl(io, self.cfg.web.session_ttl_hours);
|
||||
}
|
||||
|
||||
if (self.limiter_now) |now| {
|
||||
if (state.limiter) |limiter| limiter.setLimits(io, now, .{
|
||||
.rate_per_min = self.cfg.web.api_rate_limit_per_min,
|
||||
.localhost_exempt = self.cfg.web.api_localhost_exempt,
|
||||
.sse_max_per_ip = self.cfg.web.sse_max_connections_per_ip,
|
||||
});
|
||||
}
|
||||
|
||||
if (self.ops.contains(.retention)) {
|
||||
if (state.retention_days) |days| days.setRetentionDays(self.cfg.logging.retention_days);
|
||||
}
|
||||
|
||||
if (self.doh_paths) |prepared| {
|
||||
state.doh_certs.?.publishPathChange(io, prepared);
|
||||
self.doh_paths = null;
|
||||
}
|
||||
if (self.dot_paths) |prepared| {
|
||||
state.dot_certs.?.publishPathChange(io, prepared);
|
||||
self.dot_paths = null;
|
||||
}
|
||||
|
||||
if (self.sink) |prepared| {
|
||||
self.detached_log_file = logging.publishApply(prepared);
|
||||
self.sink = null;
|
||||
if (state.monitor) |monitor| {
|
||||
monitor.setLogDir(io, self.log_dir);
|
||||
} else {
|
||||
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
|
||||
}
|
||||
self.log_dir = null;
|
||||
}
|
||||
|
||||
if (self.ops.contains(.scheduler)) {
|
||||
if (state.manager) |manager| manager.setSchedule(
|
||||
io,
|
||||
self.cfg.blocklist_update.enabled,
|
||||
self.cfg.blocklist_update.interval_hours,
|
||||
);
|
||||
}
|
||||
|
||||
if (self.queue) |prepared| {
|
||||
state.logger.?.publish(io, prepared);
|
||||
self.queue = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes, frees and reconciles after the publish. Runs on the writing
|
||||
/// task, never on a reader's release path.
|
||||
pub fn retire(self: *Plan, io: std.Io) void {
|
||||
const gpa = self.state.gpa;
|
||||
|
||||
if (self.retired_proxies) |text| gpa.free(text);
|
||||
if (self.retired_cache) |old| {
|
||||
old.deinit();
|
||||
gpa.destroy(old);
|
||||
}
|
||||
if (self.retired_limiter) |old| {
|
||||
old.deinit();
|
||||
gpa.destroy(old);
|
||||
}
|
||||
logging.retireApply(io, self.detached_log_file);
|
||||
|
||||
if (self.published_upstream) {
|
||||
if (self.state.events) |store| {
|
||||
const owner = self.state.upstreams.?;
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
upstream_owner.reconcileReport(
|
||||
store,
|
||||
io,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
generation.report().notes,
|
||||
self.upstream_previous_keys,
|
||||
);
|
||||
}
|
||||
// A generation no reader held at the swap has no release left to
|
||||
// tear it down, so the publisher does — after the reconciliation,
|
||||
// which reads only the copies taken at prepare.
|
||||
if (self.retired_upstream) |old| old.retire(io);
|
||||
}
|
||||
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
fn certFailure(comptime endpoint: []const u8, err: cert_store.ReloadError) Failure {
|
||||
return switch (err) {
|
||||
error.OutOfMemory => .{ .unavailable = "out of memory loading the certificate" },
|
||||
error.CertUnreadable => .{ .invalid = endpoint ++ ".cert_path: no readable certificate there" },
|
||||
error.KeyUnreadable => .{ .invalid = endpoint ++ ".key_path: no readable key there" },
|
||||
error.KeyMismatch => .{ .invalid = endpoint ++ ": the key at that path does not match the certificate" },
|
||||
// The remaining variants are parse, size and library failures. They name
|
||||
// the pair rather than one of the two files, because which of them was
|
||||
// at fault is exactly what the loader could not decide.
|
||||
else => .{ .invalid = endpoint ++ ": the certificate and key at those paths could not be loaded" },
|
||||
};
|
||||
}
|
||||
|
||||
/// The row set an upstream create, update or delete would leave behind, built
|
||||
/// before the repository write so the candidate generation is the one the
|
||||
/// commit will make true.
|
||||
pub const RowMutation = union(enum) {
|
||||
add: model.UpstreamServer,
|
||||
replace: struct { id: i64, item: model.UpstreamServer },
|
||||
remove: i64,
|
||||
};
|
||||
|
||||
pub fn hypotheticalRows(
|
||||
arena: Allocator,
|
||||
rows: []const upstreams_repo.UpstreamRow,
|
||||
mutation: RowMutation,
|
||||
) Allocator.Error![]const model.UpstreamServer {
|
||||
var out: std.ArrayList(model.UpstreamServer) = .empty;
|
||||
for (rows) |row| {
|
||||
switch (mutation) {
|
||||
.add => {},
|
||||
.replace => |edit| if (row.id == edit.id) {
|
||||
try out.append(arena, edit.item);
|
||||
continue;
|
||||
},
|
||||
.remove => |id| if (row.id == id) continue,
|
||||
}
|
||||
try out.append(arena, .{
|
||||
.url = row.url,
|
||||
.priority = row.priority,
|
||||
.enabled = row.enabled,
|
||||
.tls_name = row.tls_name,
|
||||
});
|
||||
}
|
||||
if (mutation == .add) try out.append(arena, mutation.add);
|
||||
return out.items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
const fixtures = @import("test_fixtures");
|
||||
|
||||
test "a propagated error out of prepare abandons what earlier owners already built" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
|
||||
|
||||
var cert_buf: [128]u8 = undefined;
|
||||
var key_buf: [128]u8 = undefined;
|
||||
var log_buf: [128]u8 = undefined;
|
||||
const cert_path = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert.pem", .{tmp.sub_path});
|
||||
const key_path = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key.pem", .{tmp.sub_path});
|
||||
const log_path = try std.fmt.bufPrint(&log_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
|
||||
|
||||
var store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, null);
|
||||
defer store.deinit(io);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
// The cert candidate is built from the store's own allocator and succeeds;
|
||||
// the log-directory generation that follows it comes from the state's, and
|
||||
// that one is out of memory.
|
||||
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
|
||||
var state: server.WebState = .{ .gpa = failing.allocator(), .doh_certs = &store };
|
||||
|
||||
var cfg: model.Config = .{};
|
||||
cfg.doh_server.cert_path = cert_path;
|
||||
cfg.doh_server.key_path = key_path;
|
||||
cfg.logging.output = .file;
|
||||
cfg.logging.file_path = log_path;
|
||||
|
||||
var ops: Set = .initEmpty();
|
||||
ops.insert(.certs_doh);
|
||||
ops.insert(.log_sink);
|
||||
|
||||
var plan: Plan = .init(&state, arena_state.allocator(), cfg, ops);
|
||||
try testing.expectError(error.OutOfMemory, plan.prepare(io));
|
||||
|
||||
// `preparePathChange` holds `reload_mutex` until its publish or its abort.
|
||||
// A prepare that failed after it must have aborted, or every later cert
|
||||
// change and every reload would block here forever.
|
||||
const second = try store.preparePathChange(io, cert_path, key_path);
|
||||
store.abortPathChange(io, second);
|
||||
}
|
||||
|
||||
test "every scalar settings key appears in the table exactly once" {
|
||||
// The old comptime generator walked `model.Config` to produce the key list;
|
||||
// this walks it to prove the hand-written table covers it. A section added
|
||||
// to the model fails to compile here until its keys have owners.
|
||||
comptime {
|
||||
@setEvalBranchQuota(50_000);
|
||||
var counted = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
const key = section_field.name ++ "." ++ field.name;
|
||||
var hits = 0;
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.key, key)) hits += 1;
|
||||
}
|
||||
if (hits != 1) @compileError("the apply table does not list " ++ key ++ " exactly once");
|
||||
counted += 1;
|
||||
}
|
||||
}
|
||||
if (counted != table.len) @compileError("the apply table lists a key the model does not have");
|
||||
}
|
||||
}
|
||||
|
||||
test "the table names no secret" {
|
||||
for (table) |entry| {
|
||||
try testing.expect(!std.mem.eql(u8, entry.key, "web.password"));
|
||||
try testing.expect(!std.mem.eql(u8, entry.key, "web.password_hash"));
|
||||
}
|
||||
}
|
||||
|
||||
test "restart-required is bind and web lifecycle, and nothing else" {
|
||||
for (table) |entry| {
|
||||
var listed = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, entry.key, key)) listed = true;
|
||||
}
|
||||
try testing.expectEqual(entry.operation.needsRestart(), listed);
|
||||
}
|
||||
|
||||
// The full list, so a key silently joining or leaving it is a failure.
|
||||
const expected = [_][]const u8{
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"doh_server.bind",
|
||||
"doh_server.enabled",
|
||||
"doh_server.port",
|
||||
"dot_server.bind",
|
||||
"dot_server.enabled",
|
||||
"dot_server.port",
|
||||
};
|
||||
try testing.expectEqual(expected.len, restart_required_keys.len);
|
||||
for (expected) |key| {
|
||||
var found = false;
|
||||
for (restart_required_keys) |listed| {
|
||||
if (std.mem.eql(u8, listed, key)) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
}
|
||||
}
|
||||
|
||||
test "changed operations follow the values, not the keys a patch named" {
|
||||
const before: model.Config = .{};
|
||||
try testing.expect(changedOperations(before, before).count() == 0);
|
||||
|
||||
var after = before;
|
||||
after.dns.port = 5353;
|
||||
try testing.expect(changedOperations(before, after).eql(Set.initOne(.bind)));
|
||||
|
||||
// Two keys of one owner are one operation.
|
||||
var pair = before;
|
||||
pair.disk = .{ .min_free_mb = 1, .warn_free_mb = 2 };
|
||||
try testing.expect(changedOperations(before, pair).eql(Set.initOne(.disk_thresholds)));
|
||||
|
||||
// A string key compares by bytes.
|
||||
var proxies = before;
|
||||
proxies.web.trusted_proxies = "10.0.0.1";
|
||||
try testing.expect(changedOperations(before, proxies).eql(Set.initOne(.trusted_proxies)));
|
||||
|
||||
// Equal bytes at a different address are not a change.
|
||||
var same_bytes = before;
|
||||
var copy: [7]u8 = undefined;
|
||||
@memcpy(©, "0.0.0.0");
|
||||
same_bytes.dns.bind_ipv4 = ©
|
||||
try testing.expect(changedOperations(before, same_bytes).count() == 0);
|
||||
}
|
||||
|
||||
test "the hypothetical row set is the one the commit will make true" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const rows = [_]upstreams_repo.UpstreamRow{
|
||||
.{ .id = 1, .url = "https://a.example/dns-query", .priority = 1, .enabled = true, .tls_name = "" },
|
||||
.{ .id = 2, .url = "tls://b.example:853", .priority = 2, .enabled = true, .tls_name = "b.example" },
|
||||
};
|
||||
|
||||
const added = try hypotheticalRows(arena, &rows, .{ .add = .{ .url = "https://c.example/dns-query" } });
|
||||
try testing.expectEqual(@as(usize, 3), added.len);
|
||||
try testing.expectEqualStrings("https://c.example/dns-query", added[2].url);
|
||||
|
||||
const removed = try hypotheticalRows(arena, &rows, .{ .remove = 1 });
|
||||
try testing.expectEqual(@as(usize, 1), removed.len);
|
||||
try testing.expectEqualStrings("tls://b.example:853", removed[0].url);
|
||||
|
||||
const replaced = try hypotheticalRows(arena, &rows, .{
|
||||
.replace = .{ .id = 2, .item = .{ .url = "tls://z.example:853", .tls_name = "z.example" } },
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 2), replaced.len);
|
||||
try testing.expectEqualStrings("tls://z.example:853", replaced[1].url);
|
||||
}
|
||||
@@ -124,11 +124,17 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
|
||||
.cookie => |cookie| {
|
||||
log.info("web login accepted for {f}", .{request.client_addr});
|
||||
var buf: [cookie_buf_len]u8 = undefined;
|
||||
// The live table's TTL, not `state.web`'s boot snapshot: a
|
||||
// settings apply changes the TTL in place.
|
||||
const ttl_s = if (state.sessions) |sessions|
|
||||
sessions.ttlSeconds(io)
|
||||
else
|
||||
model.sessionTtlSeconds(state.web);
|
||||
const header = http_util.formatSetCookie(
|
||||
&buf,
|
||||
auth.cookie_name,
|
||||
&cookie,
|
||||
model.sessionTtlSeconds(state.web),
|
||||
ttl_s,
|
||||
) catch return error.OutOfMemory;
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.authenticated = true,
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
|
||||
const disk_monitor = @import("../../storage/disk_monitor.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const logger_controller = @import("../../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../../storage/logger.zig");
|
||||
const metrics = @import("../metrics.zig");
|
||||
const pause_mod = @import("../../server/pause.zig");
|
||||
@@ -229,7 +230,10 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
input.disk_free_bytes = monitor.gauges().free_bytes;
|
||||
}
|
||||
|
||||
if (state.pool) |pool| {
|
||||
if (state.upstreams) |owner| {
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
if (generation.pool) |pool| {
|
||||
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
|
||||
const count = metrics.poolSnapshot(pool, io, &raw);
|
||||
input.upstreams_total = @intCast(count);
|
||||
@@ -237,12 +241,14 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
if (entry.available) input.upstreams_available += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logger) |logger| {
|
||||
input.queries_dropped = logger.queries_dropped.load(.monotonic);
|
||||
input.last_drop_s = logger.lastDropSeconds();
|
||||
input.writer_failed = logger.writer_failed.load(.monotonic);
|
||||
input.gate_episode = logger.gateEpisode();
|
||||
if (state.logger) |controller| {
|
||||
const reading = controller.sample(io);
|
||||
input.queries_dropped = reading.queries_dropped;
|
||||
input.last_drop_s = reading.last_drop_s;
|
||||
input.writer_failed = reading.writer_failed;
|
||||
input.gate_episode = reading.gate_episode;
|
||||
}
|
||||
|
||||
if (state.pause) |paused| input.pause_until = paused.until.load(.monotonic);
|
||||
@@ -492,13 +498,14 @@ test "collect reads the logger's counters and the pause flag" {
|
||||
query_logger.queries_dropped.store(4, .monotonic);
|
||||
query_logger.last_drop_s.store(1_700_000_000, .monotonic);
|
||||
query_logger.writer_failed.store(true, .monotonic);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
|
||||
var paused: pause_mod.Pause = .{};
|
||||
paused.pauseFor(0, null);
|
||||
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.logger = &query_logger,
|
||||
.logger = log_owner.over(&query_logger),
|
||||
.pause = &paused,
|
||||
};
|
||||
const input = collect(&state, io);
|
||||
|
||||
@@ -163,7 +163,11 @@ pub fn Resource(comptime desc: anytype) type {
|
||||
if (!isNull(@TypeOf(desc.remove))) {
|
||||
const Remove = @TypeOf(desc.remove);
|
||||
if (removeTakesArena(Remove)) {
|
||||
if (removeIsFallible(Remove)) {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
|
||||
} else {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
|
||||
}
|
||||
} else {
|
||||
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
|
||||
}
|
||||
@@ -227,10 +231,12 @@ pub fn Resource(comptime desc: anytype) type {
|
||||
}
|
||||
|
||||
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.?)
|
||||
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
|
||||
desc.remove(state, io, request.id.?)
|
||||
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
|
||||
try desc.remove(state, io, request.arena, request.id.?)
|
||||
else
|
||||
desc.remove(state, io, request.id.?);
|
||||
desc.remove(state, io, request.arena, request.id.?);
|
||||
|
||||
if (failure) |value| return respondFailure(request, value, remove_what);
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
@@ -246,6 +252,14 @@ fn removeTakesArena(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
|
||||
}
|
||||
|
||||
/// A delete decision that builds a candidate for the running server allocates
|
||||
/// while it does so, so it may run out of memory. The two shapes are checked
|
||||
/// exactly, so a `remove` that returns some other error set is still a compile
|
||||
/// error naming what it should have been.
|
||||
fn removeIsFallible(comptime T: type) bool {
|
||||
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
+90
-104
@@ -1,11 +1,14 @@
|
||||
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
|
||||
//! `settings` table (ruling 16).
|
||||
//!
|
||||
//! Everything here is restart-required this milestone, and the response says so
|
||||
//! for every key: what changes live is the resource endpoints and the pause,
|
||||
//! not a setting. The list is generated from `model.Config` itself, so a
|
||||
//! section added to the model appears here without anyone remembering to add
|
||||
//! it.
|
||||
//! Every key applies live except the two that create or destroy a socket —
|
||||
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
|
||||
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
|
||||
//! `restart_required` in the response is that pair of key groups and nothing
|
||||
//! else. `apply.zig` holds the table that decides which owner a key belongs to
|
||||
//! and drives the prepare → commit → publish → retire the write contract asks
|
||||
//! for; a section added to the model fails to compile until its keys have
|
||||
//! owners there.
|
||||
//!
|
||||
//! `web.password` is write-only and `web.password_hash` is neither readable nor
|
||||
//! directly writable. A PUT carrying `web.password` hashes it with the import
|
||||
@@ -23,6 +26,7 @@ const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apply = @import("apply.zig");
|
||||
const auth = @import("../auth.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
@@ -42,58 +46,13 @@ const log = std.log.scoped(.web_api);
|
||||
/// always fits the copy `applyLogin` takes.
|
||||
const hash_buf_len = auth.LiveHash.max_len;
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash
|
||||
/// nxdns never computed.
|
||||
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
const isHidden = apply.isHidden;
|
||||
const isWriteOnly = apply.isWriteOnly;
|
||||
const isScalarSection = apply.isScalarSection;
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned.
|
||||
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the restart-required table (ruling 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
|
||||
/// them are restart-required this milestone, so the table is the key list and
|
||||
/// the flag is implied by membership.
|
||||
pub const restart_required_keys: []const []const u8 = &keys;
|
||||
|
||||
const keys = blk: {
|
||||
var list: [countKeys()][]const u8 = undefined;
|
||||
var index = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
list[index] = section_field.name ++ "." ++ field.name;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
break :blk list;
|
||||
};
|
||||
|
||||
fn countKeys() usize {
|
||||
var count = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
|
||||
/// two operations milestone 35 executes. The apply table is the authority.
|
||||
pub const restart_required_keys = apply.restart_required_keys;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the patch a PUT carries
|
||||
@@ -184,32 +143,6 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the patch names a key whose change waits for a restart. The key
|
||||
/// table above is the authority, so a key that stops being restart-required
|
||||
/// stops raising the flag without anyone remembering this function exists.
|
||||
/// A patch carrying `web.password` alone touches no such key: the new hash is
|
||||
/// installed live (ruling 17 of milestone 16).
|
||||
fn touchesRestartRequiredKey(patch: Patch) bool {
|
||||
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
|
||||
if (@field(patch, section_field.name)) |section| {
|
||||
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
|
||||
if (@field(section, field.name) != null) {
|
||||
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isRestartRequired(comptime key: []const u8) bool {
|
||||
@setEvalBranchQuota(10_000);
|
||||
for (keys) |listed| {
|
||||
if (std.mem.eql(u8, listed, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the patch carries a new password.
|
||||
fn newPassword(patch: Patch) ?[]const u8 {
|
||||
const web = patch.web orelse return null;
|
||||
@@ -339,10 +272,11 @@ fn applyPut(
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
var cfg = stored;
|
||||
|
||||
var bad_key: []const u8 = "";
|
||||
if (!merge(&cfg, patch, &bad_key)) {
|
||||
@@ -366,15 +300,38 @@ fn applyPut(
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
errdefer if (replacement) |hash| state.gpa.free(hash);
|
||||
|
||||
// Prepare: one candidate per owner whose value actually moved, built from
|
||||
// the merged configuration. Nothing is published and no row is written
|
||||
// until every one of them succeeded.
|
||||
const ops = apply.changedOperations(stored, cfg);
|
||||
var plan: apply.Plan = .init(state, arena, cfg, ops);
|
||||
if (try plan.prepare(io)) |failure| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = failure };
|
||||
}
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
|
||||
// After the commit, never before: a validation failure or a write that
|
||||
// rolled back changed nothing, so it owes nobody a restart.
|
||||
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
|
||||
// rolled back changed nothing, so it owes nobody a restart. Only the two
|
||||
// operations milestone 35 executes raise it; everything else is already
|
||||
// live by the time this line runs.
|
||||
var restart_owed = false;
|
||||
var it = ops.iterator();
|
||||
while (it.next()) |operation| {
|
||||
if (operation.needsRestart()) restart_owed = true;
|
||||
}
|
||||
if (restart_owed) state.restart_pending.store(true, .monotonic);
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
@@ -546,11 +503,10 @@ fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config
|
||||
const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
test "the restart-required list is a strict subset of the settings keys" {
|
||||
// `model.toSettings` is the key list this form writes. The restart list is
|
||||
// now the small part of it that milestone 35 owns, so it must be shorter
|
||||
// than the key list and every entry must still be a real key.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -558,17 +514,24 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
try testing.expect(restart_required_keys.len < pairs.items.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
var is_a_setting = false;
|
||||
for (pairs.items) |pair| {
|
||||
if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
|
||||
}
|
||||
try testing.expect(is_a_setting);
|
||||
}
|
||||
|
||||
var found_port = false;
|
||||
var found_ttl = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
|
||||
if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
|
||||
}
|
||||
try testing.expect(found_port);
|
||||
// A live key must not appear: the UI renders no restart affordance for it.
|
||||
try testing.expect(!found_ttl);
|
||||
}
|
||||
|
||||
test "the read shape spells every enum the way the database does" {
|
||||
@@ -806,20 +769,43 @@ test "a rejected patch raises no restart flag" {
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "only a patch naming a restart-required key raises the flag" {
|
||||
var password_only: Patch = .{};
|
||||
password_only.web = .{ .password = "correct horse battery staple" };
|
||||
try testing.expect(!touchesRestartRequiredKey(password_only));
|
||||
test "only a bind or web-lifecycle change raises the flag" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var mixed: Patch = .{};
|
||||
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
|
||||
try testing.expect(touchesRestartRequiredKey(mixed));
|
||||
// A live key, however many of them: nothing to restart for.
|
||||
var live: Patch = .{};
|
||||
live.blocking = .{ .ttl = 30 };
|
||||
live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
var elsewhere: Patch = .{};
|
||||
elsewhere.dns = .{ .port = 5353 };
|
||||
try testing.expect(touchesRestartRequiredKey(elsewhere));
|
||||
// `web.enabled` is the second of the two milestone-35 operations, and it
|
||||
// executes nothing: the row moves and the flag rises.
|
||||
var lifecycle: Patch = .{};
|
||||
lifecycle.web = .{ .enabled = false };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expect(!touchesRestartRequiredKey(.{}));
|
||||
test "a patch that changes nothing applies nothing" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
// The stored port is 53, and the admin form submits every field it read.
|
||||
// Rewriting a value to itself is not a change and must not raise the flag.
|
||||
var same: Patch = .{};
|
||||
same.dns = .{ .port = 53 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a password-only put leaves the restart flag alone" {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! `/api/upstreams` — the resolvers nxdns forwards to.
|
||||
//!
|
||||
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
|
||||
//! mutable resource that is NOT live. The pool builds its clients, its health
|
||||
//! state and its TLS material at startup, so an upstream added, edited or
|
||||
//! removed here takes effect at the next restart. The response says so through
|
||||
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
|
||||
//! has one banner and one meaning for it.
|
||||
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
|
||||
//! live like the rest of them: an upstream added, edited or removed here is
|
||||
//! applied in-process. The row set the write will leave behind is built into a
|
||||
//! candidate generation BEFORE the write, so a set that cannot produce clients
|
||||
//! is refused with nothing changed; the candidate is published after the commit
|
||||
//! and the displaced generation retires once the exchanges holding it finish.
|
||||
//! `restart_required` in the response is therefore `false`, and stays in the
|
||||
//! shape so the UI has one field with one meaning across every mutation.
|
||||
//!
|
||||
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
|
||||
//! empty for every other scheme, and the validator refuses it there.
|
||||
@@ -13,6 +15,8 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apply = @import("apply.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
@@ -38,6 +42,34 @@ const Created = union(enum) { id: i64, fail: Failure };
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Prepares the generation the row set after `mutation` would produce, on a
|
||||
/// caller that already holds `config_lock`. Returns the plan for the caller to
|
||||
/// publish or abandon.
|
||||
fn planFor(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
database: *db.Db,
|
||||
mutation: apply.RowMutation,
|
||||
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
|
||||
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
|
||||
|
||||
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
|
||||
if (try plan.prepareUpstreams(io, servers)) |failure| {
|
||||
plan.abandon(io);
|
||||
return .{ .fail = failure };
|
||||
}
|
||||
return .{ .plan = plan };
|
||||
}
|
||||
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
@@ -48,14 +80,20 @@ fn applyCreate(
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = upstreams_repo.insertUpstreamRow(database, item);
|
||||
state.config_lock.unlock(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
// Ruling 12: the pool is built at startup, so the row now stored governs
|
||||
// nothing until the next one. Stored after the insert, never before — a
|
||||
// rejected url or a conflict owes no restart.
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
|
||||
plan.abandon(io);
|
||||
return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
@@ -85,16 +123,27 @@ fn applyUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err|
|
||||
var plan = switch (try planFor(state, io, arena, database, .{
|
||||
.replace = .{ .id = id, .item = item },
|
||||
})) {
|
||||
.fail => |failure| return failure,
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err| {
|
||||
plan.abandon(io);
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||
/// startup — so allowing it here would only produce a box that will not boot.
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -109,19 +158,28 @@ fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?
|
||||
},
|
||||
}
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err|
|
||||
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
|
||||
.fail => |failure| return failure,
|
||||
.plan => |p| p,
|
||||
};
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err| {
|
||||
plan.abandon(io);
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
state.restart_pending.store(true, .monotonic);
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
return null;
|
||||
}
|
||||
|
||||
const Remaining = union(enum) { missing, count: usize };
|
||||
|
||||
fn countEnabledExcept(
|
||||
database: *@import("../../storage/db.zig").Db,
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
) @import("../../storage/db.zig").Error!Remaining {
|
||||
) db.Error!Remaining {
|
||||
const rows = try upstreams_repo.listUpstreamRows(database, arena);
|
||||
var found = false;
|
||||
var left: usize = 0;
|
||||
@@ -166,7 +224,7 @@ pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
.restart_required = false,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
@@ -186,7 +244,7 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
.restart_required = false,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -219,7 +277,7 @@ test "a created upstream is stored" {
|
||||
try testing.expectEqualStrings("", row.tls_name);
|
||||
}
|
||||
|
||||
test "an upstream change never announces a reload" {
|
||||
test "an upstream change never announces a reload or a restart" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
@@ -251,7 +309,7 @@ test "a url the validator refuses never reaches the database" {
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
|
||||
}
|
||||
|
||||
test "a refused upstream write owes no restart, and an accepted one does" {
|
||||
test "no upstream write owes a restart, refused or accepted" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
@@ -264,11 +322,14 @@ test "a refused upstream write owes no restart, and an accepted one does" {
|
||||
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
|
||||
try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
// Milestone 34: an accepted write is applied in-process, so it owes no
|
||||
// restart either. `restart_pending` now has exactly two sources, and
|
||||
// neither of them is here.
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a duplicate url is a conflict" {
|
||||
@@ -287,7 +348,7 @@ test "the last enabled upstream cannot be deleted" {
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
|
||||
|
||||
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
@@ -296,7 +357,7 @@ test "the last enabled upstream cannot be deleted" {
|
||||
});
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
@@ -357,6 +418,6 @@ test "an id no upstream holds is a 404 on both update and delete" {
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
|
||||
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
|
||||
);
|
||||
}
|
||||
|
||||
+35
-14
@@ -34,6 +34,7 @@ const events_mod = @import("../storage/events.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const logging = @import("../platform/logging.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
@@ -187,28 +188,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
if (state.handler) |handler| {
|
||||
sample.dns = dnsCounters(&handler.stats);
|
||||
|
||||
if (handler.cache) |cache| {
|
||||
// The pointer is loaded under the same mutex a query loads it under,
|
||||
// never before it: a live `cache.size` or `dns.rate_limit` change
|
||||
// frees the object it displaced as soon as the swap returns.
|
||||
{
|
||||
handler.cache_mutex.lockUncancelable(io);
|
||||
defer handler.cache_mutex.unlock(io);
|
||||
sample.cache = .{
|
||||
if (handler.cache) |cache| sample.cache = .{
|
||||
.stats = cache.stats,
|
||||
.entries = cache.len(),
|
||||
.memory_bytes = cache.memoryBytes(),
|
||||
};
|
||||
}
|
||||
|
||||
if (handler.limiter) |limiter| {
|
||||
{
|
||||
handler.limiter_mutex.lockUncancelable(io);
|
||||
defer handler.limiter_mutex.unlock(io);
|
||||
if (handler.limiter) |limiter| {
|
||||
sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logger) |logger| sample.logger = .{
|
||||
.queries_dropped = logger.queries_dropped.load(.monotonic),
|
||||
.rows_written = logger.rows_written.load(.monotonic),
|
||||
.batches_gated = logger.batches_gated.load(.monotonic),
|
||||
if (state.logger) |controller| {
|
||||
const reading = controller.sample(io);
|
||||
sample.logger = .{
|
||||
.queries_dropped = reading.queries_dropped,
|
||||
.rows_written = reading.rows_written,
|
||||
.batches_gated = reading.batches_gated,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.tracker) |tracker| sample.tracker = .{
|
||||
.stats = tracker.snapshotStats(io),
|
||||
@@ -259,7 +268,11 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
|
||||
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
|
||||
|
||||
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
if (state.upstreams) |owner| {
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
if (generation.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
}
|
||||
|
||||
return sample;
|
||||
}
|
||||
@@ -700,18 +713,23 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const local_tables = @import("../server/local_tables.zig");
|
||||
const logger_controller = @import("../storage/logger_controller.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
/// Backs `testHandler`'s owner. File-scope because the handler is returned by
|
||||
/// value and its `*Owner` has to outlive the return; nothing here exchanges, so
|
||||
/// the generation behind it is never acquired.
|
||||
var unreachable_upstream: upstream_owner.Borrowed = .{};
|
||||
|
||||
/// A handler with no upstream reachable: every test here reads counters and
|
||||
/// never runs a query.
|
||||
fn testHandler() dns_handler.Handler {
|
||||
return .{
|
||||
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.blocking = .{ .mode = .zero, .ttl = 5 },
|
||||
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
|
||||
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1600,9 +1618,12 @@ test "collect reads the live counters of the components it is given" {
|
||||
var queue_buf: [4]logger_mod.Entry = undefined;
|
||||
var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
query_logger.rows_written.store(90, .monotonic);
|
||||
var log_owner: logger_controller.Borrowed = .{};
|
||||
|
||||
var tracker: clients.Tracker = .init(30);
|
||||
var retention: retention_mod.Retention = .init(.{});
|
||||
var tracker_days: retention_mod.RetentionDays = .init(30);
|
||||
var tracker: clients.Tracker = .init(&tracker_days);
|
||||
var retention_days: retention_mod.RetentionDays = .init(30);
|
||||
var retention: retention_mod.Retention = .init(&retention_days);
|
||||
var tables: local_tables.LocalTables = .empty;
|
||||
var names: client_names.Resolver = .init(&tables);
|
||||
names.stats.no_zone = 4;
|
||||
@@ -1611,7 +1632,7 @@ test "collect reads the live counters of the components it is given" {
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.handler = &handler,
|
||||
.logger = &query_logger,
|
||||
.logger = log_owner.over(&query_logger),
|
||||
.tracker = &tracker,
|
||||
.client_names = &names,
|
||||
.retention = &retention,
|
||||
|
||||
+30
-19
@@ -26,9 +26,11 @@ info:
|
||||
Prometheus scrape can never be throttled; `/api/queries/live` is
|
||||
exempt because one long-lived stream is bounded by
|
||||
`web.sse_max_connections_per_ip` instead.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones,
|
||||
clients and client prefixes take effect live. Upstreams and
|
||||
`/api/settings` are restart-required.
|
||||
- Mutations take effect live, including upstreams and `/api/settings`:
|
||||
the owner of every changed setting is told in-process by the write
|
||||
that changes it. The exceptions are the keys that create or destroy a
|
||||
socket — the listener addresses and ports, and `web.enabled` — which
|
||||
`/api/settings` reports in its `restart_required` list.
|
||||
- nxdns runs under one of two configuration authorities. Started with
|
||||
`--config=<file>`, that file is the sole declarative source, and every
|
||||
operation that writes configuration answers 403 with the same error
|
||||
@@ -1544,7 +1546,7 @@ paths:
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
post:
|
||||
summary: Add an upstream
|
||||
description: Restart-required; the running pool is not changed.
|
||||
description: Applies live; the resolver pool is rebuilt in-process.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1553,7 +1555,7 @@ paths:
|
||||
$ref: "#/components/schemas/UpstreamInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Created; takes effect on restart.
|
||||
description: Created; live at once.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1599,7 +1601,7 @@ paths:
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
put:
|
||||
summary: Update an upstream
|
||||
description: Restart-required; the running pool is not changed.
|
||||
description: Applies live; the resolver pool is rebuilt in-process.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1608,7 +1610,7 @@ paths:
|
||||
$ref: "#/components/schemas/UpstreamInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated; takes effect on restart.
|
||||
description: Updated; live at once.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1636,7 +1638,7 @@ paths:
|
||||
description: The last enabled upstream cannot be removed (409).
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted; takes effect on restart.
|
||||
description: Deleted; live at once.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
@@ -1701,10 +1703,10 @@ paths:
|
||||
summary: Read the scalar settings
|
||||
description: |
|
||||
Every stored settings key, plus the derived `web.auth_enabled`.
|
||||
`restart_required` lists every key, because all scalar settings are
|
||||
restart-required this milestone; live behavior comes from the
|
||||
resource endpoints and `/api/pause`. Passwords and hashes are never
|
||||
serialized.
|
||||
`restart_required` lists only the keys that create or destroy a
|
||||
socket — the DNS, web, DoH and DoT bind addresses, ports and
|
||||
enabled flags. Every other setting is applied in-process by the
|
||||
write that changes it. Passwords and hashes are never serialized.
|
||||
responses:
|
||||
"200":
|
||||
description: The settings and the restart-required key list.
|
||||
@@ -2756,8 +2758,12 @@ components:
|
||||
enabled: { type: boolean }
|
||||
tls_name: { type: string }
|
||||
restart_required:
|
||||
description: |
|
||||
Always false: an upstream write rebuilds the resolver pool
|
||||
in-process. The field stays in the shape so every mutation
|
||||
response answers the same question the same way.
|
||||
type: boolean
|
||||
enum: [true]
|
||||
enum: [false]
|
||||
|
||||
Pause:
|
||||
type: object
|
||||
@@ -2922,9 +2928,12 @@ components:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
Every `section.field` key that needs a restart to take effect —
|
||||
currently all of them. Whether a restart is *owed* right now is
|
||||
process state, and lives on `/api/config/status`.
|
||||
The `section.field` keys that need a restart to take effect: the
|
||||
DNS, web, DoH and DoT bind addresses, ports and enabled flags,
|
||||
and nothing else. Every key absent from this list is applied
|
||||
in-process by the write that changes it. Whether a restart is
|
||||
*owed* right now is process state, and lives on
|
||||
`/api/config/status`.
|
||||
|
||||
ConfigStatus:
|
||||
type: object
|
||||
@@ -2963,9 +2972,11 @@ components:
|
||||
type: boolean
|
||||
description: |
|
||||
True once this process has committed a configuration change that
|
||||
takes effect only at the next start — an upstream write or a
|
||||
settings key. Nothing clears it but process exit, and it is
|
||||
never persisted, so a false after a restart is the truth.
|
||||
takes effect only at the next start: a DNS, web, DoH or DoT bind
|
||||
address, port or enabled flag. No other write raises it —
|
||||
everything else, upstreams included, is applied in-process.
|
||||
Nothing clears it but process exit, and it is never persisted,
|
||||
so a false after a restart is the truth.
|
||||
|
||||
SettingsPatch:
|
||||
type: object
|
||||
|
||||
+158
-5
@@ -36,11 +36,11 @@ const events_mod = @import("../storage/events.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 logger_controller = @import("../storage/logger_controller.zig");
|
||||
const manager_mod = @import("../filter/manager.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const pause_mod = @import("../server/pause.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const upstream_owner = @import("../upstream/owner.zig");
|
||||
const query_sink = @import("../server/query_sink.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const router = @import("router.zig");
|
||||
@@ -115,9 +115,75 @@ pub const Authority = union(enum) {
|
||||
managed_file: []const u8,
|
||||
};
|
||||
|
||||
/// `web.trusted_proxies`, live. The boot text is borrowed from the loaded
|
||||
/// configuration; every replacement is an owned, immutable generation that a
|
||||
/// reader holds a shared lock on for as long as it borrows the text.
|
||||
///
|
||||
/// Taking the exclusive lock is what drains the readers: once `install`
|
||||
/// returns, nothing holds the generation it hands back, so the caller can free
|
||||
/// it. The free itself belongs to the caller and not to this type, because a
|
||||
/// publish must not do work that a retire owns.
|
||||
pub const LiveProxies = struct {
|
||||
lock: std.Io.RwLock = .init,
|
||||
text: []const u8 = "",
|
||||
owned: bool = false,
|
||||
|
||||
pub fn init(boot_text: []const u8) LiveProxies {
|
||||
return .{ .text = boot_text };
|
||||
}
|
||||
|
||||
/// The reader's hold. Release it, and do not retain `text` afterwards.
|
||||
pub const Handle = struct {
|
||||
text: []const u8,
|
||||
live: *LiveProxies,
|
||||
|
||||
pub fn release(self: Handle, io: std.Io) void {
|
||||
self.live.lock.unlockShared(io);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn acquire(self: *LiveProxies, io: std.Io) Handle {
|
||||
self.lock.lockSharedUncancelable(io);
|
||||
return .{ .text = self.text, .live = self };
|
||||
}
|
||||
|
||||
/// Takes ownership of `prepared`, which must be a `gpa` allocation, and
|
||||
/// returns the generation it replaced for the caller to free — or null
|
||||
/// when what it replaced was the borrowed boot text.
|
||||
pub fn install(self: *LiveProxies, io: std.Io, prepared: []const u8) ?[]const u8 {
|
||||
self.lock.lockUncancelable(io);
|
||||
defer self.lock.unlock(io);
|
||||
|
||||
const retired: ?[]const u8 = if (self.owned) self.text else null;
|
||||
self.text = prepared;
|
||||
self.owned = true;
|
||||
return retired;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LiveProxies, gpa: Allocator) void {
|
||||
if (self.owned) gpa.free(self.text);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// The long-lived collaborators `upstream_owner.build` needs, which are the
|
||||
/// composition root's and not the request's: the shared HTTP client every DoH
|
||||
/// leaf borrows and the one certificate bundle every DoT leaf verifies against.
|
||||
pub const UpstreamBuild = struct {
|
||||
http: *std.http.Client,
|
||||
bundle: *std.crypto.Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
};
|
||||
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
/// The live `web.trusted_proxies`. `web` above is the boot configuration
|
||||
/// and goes stale the moment `PUT /api/settings` changes the list, exactly
|
||||
/// as it does for the password: the request path reads this holder and
|
||||
/// never `web.trusted_proxies`. The composition root seeds it from the boot
|
||||
/// value, and whoever owns the `WebState` calls `proxies.deinit`.
|
||||
proxies: LiveProxies = .{},
|
||||
|
||||
/// Defaults to `.database`: a `WebState` nobody told about a managed file
|
||||
/// governs nothing declaratively, which is the safe reading — the mutation
|
||||
@@ -141,14 +207,28 @@ pub const WebState = struct {
|
||||
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
|
||||
client_names: ?*client_names.Resolver = null,
|
||||
manager: ?*manager_mod.Manager = null,
|
||||
pool: ?*pool_mod.Pool = null,
|
||||
/// The published upstream generation. A metrics or health scrape pins one
|
||||
/// for the length of its read, so a `replace` cannot free the pool it is
|
||||
/// copying out of.
|
||||
upstreams: ?*upstream_owner.Owner = null,
|
||||
/// What building a replacement upstream generation needs beyond the rows
|
||||
/// themselves. Null in a state whose upstream owner is a test's borrowed
|
||||
/// one: there is then nothing to build, and an upstream mutation applies to
|
||||
/// the database alone.
|
||||
upstream_build: ?UpstreamBuild = null,
|
||||
monitor: ?*disk_monitor.Monitor = null,
|
||||
/// The local records and forward zones the DNS path reads. The
|
||||
/// local-records and forward-zones handlers rebuild and swap them
|
||||
/// (ruling 12).
|
||||
local_tables: ?*local_tables_mod.LocalTables = null,
|
||||
logger: ?*logger_mod.Logger = null,
|
||||
/// The query logger's controller, not a `Logger`: a resize replaces the
|
||||
/// generation, and the counters a scrape reads are the controller's.
|
||||
logger: ?*logger_controller.Controller = null,
|
||||
retention: ?*retention_mod.Retention = null,
|
||||
/// The one `logging.retention_days` cell both prune passes read. Separate
|
||||
/// from `retention` above, which is one of the two readers: a settings
|
||||
/// apply stores here and moves both of them together.
|
||||
retention_days: ?*retention_mod.RetentionDays = null,
|
||||
sessions: ?*auth.Sessions = null,
|
||||
/// The password hash every auth decision reads. `web` above is the boot
|
||||
/// configuration and goes stale the moment `PUT /api/settings` changes the
|
||||
@@ -530,7 +610,15 @@ pub const Server = struct {
|
||||
|
||||
const peer = address.NetAddress.fromIp(conn.peer);
|
||||
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)) {
|
||||
// The hold ends with the verdict, before dispatch: a settings PUT takes
|
||||
// the exclusive lock from inside its own request, so a hold that lasted
|
||||
// the request would be that request waiting on itself.
|
||||
const verdict = blk: {
|
||||
const proxies = self.state.proxies.acquire(io);
|
||||
defer proxies.release(io);
|
||||
break :blk clientAddr(proxies.text, peer, forwarded_for);
|
||||
};
|
||||
const client_addr = switch (verdict) {
|
||||
.addr => |addr| addr,
|
||||
.bad_forwarded_for => {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
@@ -882,3 +970,68 @@ test "a trusted-proxy element that is not an IP literal trusts nobody" {
|
||||
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
|
||||
try testing.expect(!trustsPeer("", peer));
|
||||
}
|
||||
|
||||
/// Every generation this test installs trusts `10.0.0.1` and nothing else, so
|
||||
/// a reader that ever disagrees read a generation that was already freed.
|
||||
const proxy_generations = [_][]const u8{
|
||||
"10.0.0.1",
|
||||
"10.0.0.1, 10.0.0.2",
|
||||
"10.0.0.1,fd00::1,10.0.0.3",
|
||||
" 10.0.0.1 ",
|
||||
};
|
||||
|
||||
fn readProxiesRepeatedly(live: *LiveProxies, io: std.Io, rounds: usize, disagreed: *bool) void {
|
||||
const peer = ip("10.0.0.1");
|
||||
const stranger = ip("198.51.100.7");
|
||||
for (0..rounds) |_| {
|
||||
const held = live.acquire(io);
|
||||
defer held.release(io);
|
||||
if (!trustsPeer(held.text, peer)) disagreed.* = true;
|
||||
if (trustsPeer(held.text, stranger)) disagreed.* = true;
|
||||
}
|
||||
}
|
||||
|
||||
test "trusted proxies are replaced under concurrent request-path reads" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var live: LiveProxies = .init(proxy_generations[0]);
|
||||
defer live.deinit(testing.allocator);
|
||||
|
||||
var disagreed = false;
|
||||
var reader = try io.concurrent(readProxiesRepeatedly, .{ &live, io, 2_000, &disagreed });
|
||||
|
||||
for (0..2_000) |i| {
|
||||
const prepared = try testing.allocator.dupe(u8, proxy_generations[i % proxy_generations.len]);
|
||||
// Publish, then retire: `install` returns only once no reader holds
|
||||
// what it replaced, which is what makes this free safe.
|
||||
if (live.install(io, prepared)) |retired| testing.allocator.free(retired);
|
||||
}
|
||||
|
||||
reader.await(io);
|
||||
try testing.expect(!disagreed);
|
||||
}
|
||||
|
||||
test "the boot text is borrowed and the first install is what starts owning" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var boot_text: [8]u8 = "10.0.0.1".*;
|
||||
var live: LiveProxies = .init(&boot_text);
|
||||
defer live.deinit(testing.allocator);
|
||||
|
||||
// Nothing to retire: the boot text belongs to the loaded configuration.
|
||||
const first = try testing.allocator.dupe(u8, "10.0.0.2");
|
||||
try testing.expect(live.install(io, first) == null);
|
||||
|
||||
const second = try testing.allocator.dupe(u8, "10.0.0.3");
|
||||
const retired = live.install(io, second).?;
|
||||
try testing.expectEqualStrings("10.0.0.2", retired);
|
||||
testing.allocator.free(retired);
|
||||
|
||||
const held = live.acquire(io);
|
||||
defer held.release(io);
|
||||
try testing.expectEqualStrings("10.0.0.3", held.text);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+407
-16
@@ -52,6 +52,11 @@ const Allocator = std.mem.Allocator;
|
||||
const Io = std.Io;
|
||||
const http = std.http;
|
||||
|
||||
/// Imported for two decls only — `fingerprint` and `fingerprintOf` — so the
|
||||
/// gate below reads the number the server will compute rather than a copy of
|
||||
/// the expression that computes it.
|
||||
const querylog_schema = @import("querylog_schema");
|
||||
|
||||
const max_input_bytes = 1 << 30;
|
||||
|
||||
/// The only repository this program can ever act on. There is no flag for it:
|
||||
@@ -493,28 +498,172 @@ const ChangelogCheck = enum {
|
||||
/// today: a section written the evening before a morning cut is correct, and a
|
||||
/// tool that demanded today would make the operator lie in the file.
|
||||
fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck {
|
||||
var state: ChangelogCheck = .missing;
|
||||
var in_section = false;
|
||||
var body_seen = false;
|
||||
const heading = changelogHeadingRest(source, version) orelse return .missing;
|
||||
if (!isDateSuffix(heading)) return .undated;
|
||||
|
||||
const body = changelogSection(source, version) orelse return .missing;
|
||||
var lines = std.mem.splitScalar(u8, body, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (!isBlank(line)) return .ok;
|
||||
}
|
||||
return .empty;
|
||||
}
|
||||
|
||||
/// The `## [<version>]` heading's trailing part, from the FIRST such heading.
|
||||
/// A file with two headings for one version is a file whose first section is
|
||||
/// the one every reader — this program, `release.zig` and a human — takes.
|
||||
fn changelogHeadingRest(source: []const u8, version: []const u8) ?[]const u8 {
|
||||
var lines = std.mem.splitScalar(u8, source, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trimEnd(u8, raw, "\r");
|
||||
if (versionHeadingRest(line, version)) |rest| return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_section) {
|
||||
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) break;
|
||||
if (!isBlank(line)) body_seen = true;
|
||||
/// Everything below the `## [<version>]` heading and above whatever ends the
|
||||
/// section: the next `## ` heading, or the Keep a Changelog link-reference
|
||||
/// block at the foot of the file. Null when there is no such heading.
|
||||
///
|
||||
/// `checkChangelog` reads it for emptiness and the schema gate reads it for one
|
||||
/// disclosure phrase. Both must be looking at the same bytes, which is why
|
||||
/// there is one extractor and not two loops.
|
||||
fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 {
|
||||
var offset: usize = 0;
|
||||
var start: ?usize = null;
|
||||
|
||||
var lines = std.mem.splitScalar(u8, source, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const line_start = offset;
|
||||
offset += raw.len + 1;
|
||||
const line = std.mem.trimEnd(u8, raw, "\r");
|
||||
|
||||
if (start) |from| {
|
||||
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) {
|
||||
return source[from..line_start];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (versionHeadingRest(line, version) != null) start = @min(offset, source.len);
|
||||
}
|
||||
|
||||
const from = start orelse return null;
|
||||
return source[@min(from, source.len)..];
|
||||
}
|
||||
|
||||
/// The phrase a changelog section must carry to release a querylog schema
|
||||
/// change. It is the operator-facing consequence, not the mechanism: what a
|
||||
/// reader of the release notes needs to know is that upgrading throws their
|
||||
/// query history away.
|
||||
const history_reset_phrase = "resets your query history";
|
||||
|
||||
fn disclosesHistoryReset(section: []const u8) bool {
|
||||
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
|
||||
}
|
||||
|
||||
/// The file whose DDL decides whether `querylog.db` survives an upgrade.
|
||||
const querylog_schema_path = "src/storage/querylog_schema.zig";
|
||||
|
||||
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
|
||||
/// file can be mistaken for it.
|
||||
const ddl_declaration = "pub const ddl: [:0]const u8 =";
|
||||
|
||||
/// The bytes of the `ddl` constant, recovered from the SOURCE of any revision of
|
||||
/// `querylog_schema.zig`.
|
||||
///
|
||||
/// The old release's DDL only exists as text — `git show <tag>:<path>` — so the
|
||||
/// gate has to read a Zig multiline string the way the compiler does: every
|
||||
/// line after the declaration begins with optional indentation and `\\`, each
|
||||
/// carries the rest of the line verbatim, and the lines join with a newline
|
||||
/// between them and none after the last. The terminating `;` ends the literal.
|
||||
///
|
||||
/// Null when the declaration, the literal or the terminator is not where this
|
||||
/// expects it. That is a refusal, never an empty DDL: an empty string has a
|
||||
/// perfectly good fingerprint that would compare unequal and turn a
|
||||
/// parse failure into a false schema change — or, worse, equal by accident.
|
||||
fn extractDdl(arena: Allocator, file_text: []const u8) ?[]const u8 {
|
||||
var parts: std.ArrayList([]const u8) = .empty;
|
||||
var found_declaration = false;
|
||||
|
||||
var lines = std.mem.splitScalar(u8, file_text, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trimEnd(u8, raw, "\r");
|
||||
if (!found_declaration) {
|
||||
if (std.mem.eql(u8, std.mem.trim(u8, line, " \t"), ddl_declaration)) found_declaration = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rest = versionHeadingRest(line, version) orelse continue;
|
||||
state = if (isDateSuffix(rest)) .ok else .undated;
|
||||
if (state == .undated) return .undated;
|
||||
in_section = true;
|
||||
const body = std.mem.trimStart(u8, line, " \t");
|
||||
if (std.mem.startsWith(u8, body, "\\\\")) {
|
||||
parts.append(arena, body["\\\\".len..]) catch @panic("OOM");
|
||||
continue;
|
||||
}
|
||||
// Zig allows blank lines and `//` comments before, between and after the
|
||||
// `\\` lines of one literal, and none of them contribute a byte to the
|
||||
// compiled string. Treating them as a parse failure would wedge every
|
||||
// cut from the moment such a source shipped in a tag.
|
||||
if (isBlank(body) or std.mem.startsWith(u8, body, "//")) continue;
|
||||
if (std.mem.eql(u8, std.mem.trimEnd(u8, body, " \t"), ";")) {
|
||||
if (parts.items.len == 0) return null;
|
||||
return std.mem.join(arena, "\n", parts.items) catch @panic("OOM");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state != .ok) return state;
|
||||
return if (body_seen) .ok else .empty;
|
||||
/// A release tag as origin reports it: the version, and the object id to read
|
||||
/// the old source out of.
|
||||
const PreviousRelease = struct {
|
||||
version: Semver,
|
||||
/// The id ORIGIN published for that tag, never the local ref of the same
|
||||
/// name. A local tag can be stale or have been replaced, and reading its
|
||||
/// tree would compare this release against a schema origin never shipped —
|
||||
/// which, if that schema happened to match this one, is a silent pass.
|
||||
object: []const u8,
|
||||
/// Whether `object` came from the peeled `refs/tags/v…^{}` line. The peeled
|
||||
/// line is the commit an annotated tag points at, which is what `git show
|
||||
/// <id>:<path>` needs; the unpeeled id of an annotated tag is the tag
|
||||
/// object, and `git show` on that resolves to the same commit, so either
|
||||
/// works and the peeled one is preferred as the direct answer.
|
||||
peeled: bool,
|
||||
};
|
||||
|
||||
/// The highest `vMAJOR.MINOR.PATCH` tag in `git ls-remote --tags` output that is
|
||||
/// strictly below `target`, or null when there is none.
|
||||
///
|
||||
/// Strictly below, because the tag being cut may already be listed on a rerun,
|
||||
/// and a range that ended at the version being cut would compare the tree
|
||||
/// against itself and pass every time.
|
||||
fn previousReleaseTag(ls_remote_stdout: []const u8, target: Semver) ?PreviousRelease {
|
||||
var best: ?PreviousRelease = null;
|
||||
var lines = std.mem.splitScalar(u8, ls_remote_stdout, '\n');
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trimEnd(u8, raw, " \t\r");
|
||||
const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
|
||||
const object = std.mem.trim(u8, line[0..tab], " \t");
|
||||
if (object.len == 0) continue;
|
||||
const name = std.mem.trim(u8, line[tab + 1 ..], " \t");
|
||||
const peeled = std.mem.endsWith(u8, name, "^{}");
|
||||
const bare = if (peeled) name[0 .. name.len - 3] else name;
|
||||
if (!std.mem.startsWith(u8, bare, "refs/tags/v")) continue;
|
||||
const found = parseSemver(bare["refs/tags/v".len..]) orelse continue;
|
||||
if (!semverLess(found, target)) continue;
|
||||
|
||||
if (best) |current| {
|
||||
if (semverLess(found, current.version)) continue;
|
||||
// The same tag appears twice, unpeeled and peeled, in either order.
|
||||
if (!semverLess(current.version, found) and (current.peeled or !peeled)) continue;
|
||||
}
|
||||
best = .{ .version = found, .object = object, .peeled = peeled };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
fn semverLess(a: Semver, b: Semver) bool {
|
||||
if (a.major != b.major) return a.major < b.major;
|
||||
if (a.minor != b.minor) return a.minor < b.minor;
|
||||
return a.patch < b.patch;
|
||||
}
|
||||
|
||||
/// The part of a `## [<version>]…` heading after the closing bracket, or null
|
||||
@@ -1342,17 +1491,21 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
|
||||
ctx.pass("branch", "master", .{});
|
||||
}
|
||||
|
||||
if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |changelog| {
|
||||
switch (checkChangelog(changelog, version)) {
|
||||
const changelog: ?[]const u8 = if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |source| source else |err| blk: {
|
||||
ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err});
|
||||
break :blk null;
|
||||
};
|
||||
if (changelog) |source| {
|
||||
switch (checkChangelog(source, version)) {
|
||||
.ok => ctx.pass("changelog", "## [{s}] has a dated heading and a section body", .{version}),
|
||||
.missing => ctx.soft("changelog", "CHANGELOG.md has no `## [{s}] - YYYY-MM-DD` heading", .{version}),
|
||||
.undated => ctx.soft("changelog", "the `## [{s}]` heading carries no ` - YYYY-MM-DD` date", .{version}),
|
||||
.empty => ctx.soft("changelog", "the `## [{s}]` section is empty; the release tool refuses a blank section, and finding that out after the tag is pushed burns the tag", .{version}),
|
||||
}
|
||||
} else |err| {
|
||||
ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err});
|
||||
}
|
||||
|
||||
try schemaGate(ctx, version, plan.semver(), changelog);
|
||||
|
||||
const tag = ctx.fmt("v{s}", .{version});
|
||||
const tag_ref = ctx.fmt("refs/tags/{s}", .{tag});
|
||||
|
||||
@@ -1408,6 +1561,85 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Refuses a release that changes the querylog schema without saying so.
|
||||
///
|
||||
/// `querylog.db` is never migrated: the server compares the file's stamped
|
||||
/// fingerprint against this build's and, on a mismatch, sets the file aside and
|
||||
/// creates an empty one. Every query the operator ever logged is gone on the
|
||||
/// first start after the upgrade. v0.0.9 shipped exactly that while its
|
||||
/// announcement claimed no such change, which is what this check exists to stop.
|
||||
///
|
||||
/// The comparison is between the DDL of the previous release tag and this
|
||||
/// tree's, so it measures the release, not the last commit. Every step that can
|
||||
/// fail — listing the tags, reading the old file, parsing it — is a refusal
|
||||
/// naming the step: a gate that cannot tell whether the schema moved must not
|
||||
/// report that it did not.
|
||||
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
|
||||
const current = querylog_schema.fingerprint;
|
||||
|
||||
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
|
||||
if (!tags.ok()) {
|
||||
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
|
||||
tags.code, std.mem.trimEnd(u8, tags.combined(ctx.arena), "\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previous = previousReleaseTag(tags.stdout, target) orelse {
|
||||
ctx.pass("schema-gate", "no release tag precedes {s}, so there is no schema to compare against", .{version});
|
||||
return;
|
||||
};
|
||||
const previous_tag = ctx.fmt("v{d}.{d}.{d}", .{
|
||||
previous.version.major, previous.version.minor, previous.version.patch,
|
||||
});
|
||||
|
||||
// The object id origin published, not the tag name: a local tag of that
|
||||
// name can be stale or replaced, and reading it would compare against a
|
||||
// schema origin never shipped.
|
||||
const show = try gitCapture(ctx, &.{
|
||||
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_schema_path }),
|
||||
}, git_local_timeout_s);
|
||||
if (!show.ok()) {
|
||||
ctx.soft("schema-gate", "`git show {s}:{s}` for {s} exited {d}: {s}; fetch the object with `git fetch --tags origin`", .{
|
||||
previous.object, querylog_schema_path, previous_tag, show.code,
|
||||
std.mem.trimEnd(u8, show.combined(ctx.arena), "\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const old_ddl = extractDdl(ctx.arena, show.stdout) orelse {
|
||||
ctx.soft("schema-gate", "cannot find the `{s}` literal in {s}:{s} ({s})", .{
|
||||
ddl_declaration, previous.object, querylog_schema_path, previous_tag,
|
||||
});
|
||||
return;
|
||||
};
|
||||
const old = querylog_schema.fingerprintOf(old_ddl);
|
||||
|
||||
if (old == current) {
|
||||
ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{ previous_tag, current });
|
||||
return;
|
||||
}
|
||||
|
||||
const source = changelog orelse {
|
||||
// The changelog check already reported why it could not be read; this
|
||||
// reports what that costs, because the gate has no way to clear itself.
|
||||
ctx.soft("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and CHANGELOG.md could not be read to check the disclosure", .{
|
||||
previous_tag, old, current,
|
||||
});
|
||||
return;
|
||||
};
|
||||
const section = changelogSection(source, version) orelse "";
|
||||
if (!disclosesHistoryReset(section)) {
|
||||
ctx.soft(
|
||||
"schema-gate",
|
||||
"the querylog schema changed since {s} ({d} to {d}), so the first start after this release sets querylog.db aside and creates an empty one; say so in the `## [{s}]` section, which must contain the phrase '{s}'",
|
||||
.{ previous_tag, old, current, version, history_reset_phrase },
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and the `## [{s}]` section discloses it", .{
|
||||
previous_tag, old, current, version,
|
||||
});
|
||||
}
|
||||
|
||||
/// What to do about a `v<version>` tag that exists locally.
|
||||
///
|
||||
/// A failed cut can leave one behind: created, then the push failed. That tag is
|
||||
@@ -1998,6 +2230,165 @@ test "the changelog section must exist, be dated and say something" {
|
||||
));
|
||||
}
|
||||
|
||||
test "the ddl literal is recovered from the source of any revision" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const source =
|
||||
\\const std = @import("std");
|
||||
\\
|
||||
\\/// A doc comment mentioning ddl, which is not the declaration.
|
||||
\\pub const ddl: [:0]const u8 =
|
||||
\\ \\CREATE TABLE domains (
|
||||
\\ \\ id INTEGER PRIMARY KEY
|
||||
\\ \\);
|
||||
\\ \\
|
||||
\\ \\CREATE INDEX idx ON domains(id);
|
||||
\\;
|
||||
\\
|
||||
\\pub const fingerprint: i32 = 0;
|
||||
;
|
||||
// Exactly the bytes the compiler builds: no indentation, no trailing
|
||||
// newline, and the blank `\\` line is an empty line in the middle.
|
||||
try testing.expectEqualStrings(
|
||||
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
|
||||
extractDdl(arena, source).?,
|
||||
);
|
||||
|
||||
// Zig allows blank lines and `//` comments before, between and after the
|
||||
// `\\` lines. None of them is a byte of the compiled string, and none of
|
||||
// them may stop the extraction: a tag that shipped one would wedge every
|
||||
// later cut.
|
||||
const with_trivia =
|
||||
\\pub const ddl: [:0]const u8 =
|
||||
\\ // The tables the query log is made of.
|
||||
\\
|
||||
\\ \\CREATE TABLE domains (
|
||||
\\ \\ id INTEGER PRIMARY KEY
|
||||
\\ \\);
|
||||
\\
|
||||
\\ // Milestone 28 added the watermark below.
|
||||
\\ \\
|
||||
\\ \\CREATE INDEX idx ON domains(id);
|
||||
\\
|
||||
\\ // Nothing follows.
|
||||
\\;
|
||||
;
|
||||
try testing.expectEqualStrings(
|
||||
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
|
||||
extractDdl(arena, with_trivia).?,
|
||||
);
|
||||
// Byte-for-byte what the same schema without the trivia produces.
|
||||
try testing.expectEqualStrings(extractDdl(arena, source).?, extractDdl(arena, with_trivia).?);
|
||||
|
||||
// Every shape this must refuse rather than fingerprint an empty string.
|
||||
try testing.expect(extractDdl(arena, "pub const other = 1;\n") == null);
|
||||
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n") == null);
|
||||
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \\\\CREATE TABLE x;\n") == null);
|
||||
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n ;\n") == null);
|
||||
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \"one line\";\n") == null);
|
||||
}
|
||||
|
||||
test "the extracted ddl of the file on disk reproduces the compiled fingerprint" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// The whole gate rests on this: the text scan must give the same bytes the
|
||||
// compiler gave the constant, or the fingerprints it compares are not the
|
||||
// fingerprints the server computes. Read from disk rather than embedded,
|
||||
// because reading the file is exactly what `git show` will hand it.
|
||||
const source = try Io.Dir.cwd().readFileAlloc(
|
||||
threaded.io(),
|
||||
querylog_schema_path,
|
||||
arena,
|
||||
.limited(max_input_bytes),
|
||||
);
|
||||
const extracted = extractDdl(arena, source) orelse return error.DdlNotFound;
|
||||
try testing.expectEqual(querylog_schema.fingerprint, querylog_schema.fingerprintOf(extracted));
|
||||
}
|
||||
|
||||
test "the previous release tag is the highest one below the version being cut" {
|
||||
const tags =
|
||||
"aaa\trefs/tags/v0.0.7\n" ++
|
||||
"bbb\trefs/tags/v0.0.7^{}\n" ++
|
||||
"ccc\trefs/tags/v0.0.10\n" ++
|
||||
"ddd\trefs/tags/v0.0.9\n" ++
|
||||
"eee\trefs/tags/v0.1.0\n" ++
|
||||
"fff\trefs/heads/master\n" ++
|
||||
"ggg\trefs/tags/nightly\n";
|
||||
|
||||
// Decimal ordering, so 0.0.10 beats 0.0.9.
|
||||
const before_minor = previousReleaseTag(tags, parseSemver("0.1.0").?).?;
|
||||
try testing.expectEqual(parseSemver("0.0.10").?, before_minor.version);
|
||||
try testing.expectEqualStrings("ccc", before_minor.object);
|
||||
// Strictly below: the tag being cut may already be listed on a rerun, and
|
||||
// comparing the tree against itself would pass every time.
|
||||
const before_patch = previousReleaseTag(tags, parseSemver("0.0.10").?).?;
|
||||
try testing.expectEqual(parseSemver("0.0.9").?, before_patch.version);
|
||||
try testing.expectEqualStrings("ddd", before_patch.object);
|
||||
try testing.expectEqual(parseSemver("0.1.0").?, previousReleaseTag(tags, parseSemver("1.0.0").?).?.version);
|
||||
|
||||
// The object id is what the gate reads the old source out of, so an
|
||||
// annotated tag yields its PEELED commit rather than the tag object, in
|
||||
// whichever order the two lines arrive.
|
||||
const annotated = previousReleaseTag(tags, parseSemver("0.0.8").?).?;
|
||||
try testing.expectEqual(parseSemver("0.0.7").?, annotated.version);
|
||||
try testing.expectEqualStrings("bbb", annotated.object);
|
||||
try testing.expect(annotated.peeled);
|
||||
const reversed = previousReleaseTag(
|
||||
"bbb\trefs/tags/v0.0.7^{}\naaa\trefs/tags/v0.0.7\n",
|
||||
parseSemver("0.0.8").?,
|
||||
).?;
|
||||
try testing.expectEqualStrings("bbb", reversed.object);
|
||||
// A lightweight tag has no peeled line, and its own id is the commit.
|
||||
const lightweight = previousReleaseTag("ddd\trefs/tags/v0.0.9\n", parseSemver("1.0.0").?).?;
|
||||
try testing.expectEqualStrings("ddd", lightweight.object);
|
||||
try testing.expect(!lightweight.peeled);
|
||||
|
||||
// A first release has nothing to compare against.
|
||||
try testing.expect(previousReleaseTag(tags, parseSemver("0.0.7").?) == null);
|
||||
try testing.expect(previousReleaseTag("", parseSemver("1.0.0").?) == null);
|
||||
// Non-release tags are not releases.
|
||||
try testing.expect(previousReleaseTag("ggg\trefs/tags/nightly\n", parseSemver("1.0.0").?) == null);
|
||||
try testing.expect(previousReleaseTag("ggg\trefs/tags/v0.0.8-rc1\n", parseSemver("1.0.0").?) == null);
|
||||
}
|
||||
|
||||
test "a schema change is disclosed by a phrase in this version's own section" {
|
||||
const source =
|
||||
\\# Changelog
|
||||
\\
|
||||
\\## [0.0.10] - 2026-08-23
|
||||
\\
|
||||
\\- Upgrading resets your query history.
|
||||
\\
|
||||
\\## [0.0.9] - 2026-08-22
|
||||
\\
|
||||
\\- Something else.
|
||||
\\
|
||||
\\[0.0.10]: https://example.invalid/compare
|
||||
;
|
||||
try testing.expect(disclosesHistoryReset(changelogSection(source, "0.0.10").?));
|
||||
// The disclosure belongs to the version that carries the change; another
|
||||
// section's copy of the phrase is not this release's note.
|
||||
try testing.expect(!disclosesHistoryReset(changelogSection(source, "0.0.9").?));
|
||||
try testing.expect(changelogSection(source, "0.0.8") == null);
|
||||
|
||||
// The section stops at the link-reference block, not at the end of file.
|
||||
try testing.expect(!disclosesHistoryReset(changelogSection(
|
||||
"## [0.0.10] - 2026-08-23\n\n- A thing.\n\n[x]: resets your query history\n",
|
||||
"0.0.10",
|
||||
).?));
|
||||
|
||||
try testing.expect(!disclosesHistoryReset(""));
|
||||
// The phrase is literal: a paraphrase does not clear the gate.
|
||||
try testing.expect(!disclosesHistoryReset("- This wipes the query log."));
|
||||
}
|
||||
|
||||
test "the runs listing decides appear, run, succeed and fail" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
Reference in New Issue
Block a user