feat(adblock): hit / miss counters + per-source refresh telemetry (#199, sub-task B) - #219
Conversation
…sue #199, sub-task B) Sub-task B of the AdBlock perf/observability follow-up. Adds the observability piece that PR #218 (sub-task C — cache cleanup) was missing. Engine (`mhost-dns`): * `AdBlockEngine` gains four `AtomicU64` counters: `hits_zero_addr`, `hits_nxdomain`, `hits_whitelist`, `misses`. Lock-free, `Relaxed` ordering — the DNS hot path never blocks. * `AdBlockEngine` gains an `AtomicBool` `enabled` mirror of the master switch. `check()` short-circuits when off, so when the user has parked ad blocking nothing is counted (issue contract: "misses 累加, 不要把 whitelist 命中算 miss"). * `check()` now distinguishes whitelist hits from genuine misses — `hits_whitelist` advances on a whitelist match, `misses` only advances when master is on AND no rule matched AND no whitelist matched. * `AdBlockStats` view struct (cumulative-since-process-start, no reset path) plus `engine.stats()` getter. Reload plumbing (`mhost-dns::server`, `mhost-dns::reload_ad_block_rules`): * Signature gains an `enabled: bool` first parameter so the engine's master-switch mirror is set atomically with the rule rebuild. All four call sites updated: - `commands::adblock::persist_and_reload` - `commands::dns` cold-start hot reload - `commands::dns` auto-refresh tick - `state::AppState::new` cold-start - New public method `DnsServer::ad_block_stats()` exposes the engine counters. Model (`mhost-core`): * `AdBlockSource` gains `last_refresh_duration_ms: Option<u64>` and `last_refresh_failed_at: Option<DateTime<Utc>>`. Both follow the `#[serde(default)]` convention (issue #202: always serialized as `null`, never `undefined`). `last_refresh_success_at` was intentionally NOT added — it would duplicate the existing `last_fetched_at` field (PR #199 review note). Timing (`commands::adblock::fetch_and_cache_source`): * `Instant::now()` right after acquiring the per-source gate; `started_at.elapsed().as_millis() as u64` captured once, written to the source record in all three branches (200 OK / 304 / Err). * New helper `record_fetch_error_with_timing` for the Err branch so a failed fetch also surfaces duration + failure timestamp. * The 304 path now clears `last_refresh_failed_at` (a successful round-trip means there is no recent failure). * `force=false` callers (auto-refresh + post-enable) and `force=true` callers (user Refresh) both go through the same timing path. IPC: * New `#[tauri::command] get_ad_block_stats -> AdBlockStatsView`. Returns zeros if DNS mode is off (no engine loaded). * `AdBlockStatsView` mirrors the engine struct + a `enabled: bool` for UI labelling. Frontend (TS / React): * `AdBlockSource` interface gains two `... | null` fields. * New `AdBlockStats` interface. * New `getAdBlockStats()` binding in `src/lib/tauri.ts`. * New `adBlockStatsAtom` + `fetchAdBlockStatsAtom` in the profile store, re-exported from `stores/profiles/index.ts`. * Stats panel in `AdBlock.tsx` as a collapsed `<details>` card after the Auto-refresh banner: - 4 counter tiles (zero-addr hits / nxdomain hits / whitelist hits / misses) - Per-source refresh timing table - Master-switch-off explanatory placeholder * CSS additions in `AdBlock.module.css` for the stat grid / table layout. Tests: * 4 new `tokio::test`s in `mhost-dns`: - `counter_accounting_for_mixed_traffic` — mixed whitelist + block + miss traffic, all four counters land on the right numbers - `whitelist_hit_is_not_a_miss` — issue #199 contract: whitelist hits do NOT count as misses - `master_switch_off_does_not_advance_any_counter` — short-circuit correctness - `stats_can_be_read_concurrently_with_check` — `AtomicU64` + `Relaxed` doesn't trip under concurrent load Test counts: * Backend: 187 → 187 (no removals; 4 new ad-block tests under mhost-dns: 140 → 144). * Frontend: 330 → 330 (no behavioural test changes; the existing AdBlock test helper gains the two new fields). CI gates: * cargo fmt --all -- --check ✓ * cargo clippy --all-targets --all-features -- -D warnings ✓ * cargo test --all-features --workspace ✓ (558 passed, 0 failed) * pnpm build ✓ (tsc + vite) * pnpm test ✓ (330 passed, 0 failed) Behaviour change to flag: * The master switch is now an explicit input to the engine, mirrored at every reload. The previous fast-path on `!has_block_rules()` is preserved AFTER the whitelist walk so the "no block rules loaded" case still returns `None` without falsely counting a miss. 🤖 Generated with [Codex](https://codex.openai.com/)
Two small issues found in the post-implementation review of PR 219 (PR #199 sub-task B). Neither changes behaviour at the IPC surface; both sharpen internal correctness / readability. 1. Doc order in `mhost-dns/src/adblock.rs` (cosmetic). The `AdBlockStats` view struct was defined AFTER `pub struct AdBlockEngine`, but its doc comment referred back to `AdBlockEngine`. The forward reference works (Rust resolves it), but the doc reads more naturally with `AdBlockStats` introduced before `AdBlockEngine` is declared. Moved the struct definition up so the doc-comment links read top-down. 2. `get_ad_block_stats` enabled-field race (correctness, narrow). The IPC read engine counters first, then the master switch from `ad_block_state`. If a concurrent `set_ad_block_enabled` lands between the two reads, the response's `enabled` field could disagree with the engine's actual gating state (engine's AtomicBool is the authoritative gating value, not `state.enabled`). The window is microseconds and the consequence is just a UI label flicker ("master on" while counters say nothing on), but it was cheap to fix: read `state.enabled` into a local before building the response, and document why. No CI gate changes (still green): - cargo fmt --all -- --check ✓ - cargo clippy --all-targets --all-features -- -D warnings ✓ - cargo test --all-features --workspace ✓ (558 passed, 0 failed) - pnpm build ✓ - pnpm test ✓ (330 passed, 0 failed)
Self-review (post-
|
| # | 问题 | 严重度 | 处理 |
|---|---|---|---|
| 1 | mhost-dns/src/adblock.rs 里 pub struct AdBlockStats 被放在了 pub struct AdBlockEngine 之后,但 AdBlockStats 的 doc 注释反向引用了 AdBlockEngine。前向引用在 Rust 里能跑,但读起来不自然。 |
低(doc) | 在 4383986 调整顺序:AdBlockStats 提前到 AdBlockEngine 前面。 |
| 2 | get_ad_block_stats IPC 里:counters 从 engine 读,enabled 字段从 state.ad_block_state 读——两次读之间可能被 set_ad_block_enabled 插入。窗口内 engine AtomicBool 和 state.enabled 可能不一致,响应里的 enabled 会反映 reload 后的 state 但 engine 还是 reload 前的 gating 值。 |
低(瞬态,但能修) | 在 4383986 把 state.enabled 提前读到 local,避免两个读之间的窗口。 |
没找到问题的地方(但本以为是风险点的)
- reload_ad_block_rules 签名改了,4 个 caller(commands/adblock、commands/dns 冷启动、commands/dns 自动刷新 tick、state/mod 冷启动)+ 2 个测试 caller 都更新了。grep
reload_ad_block_rules(验证过。 check()行为变更:master switch 的 fast-path 移到 whitelist 走之前;whitelist 计数在没有 block rules 时也会发生;no rules + no whitelist → return None 不算 miss。三条都有专门的测试守。- fetch_and_cache_source timing:Instant::now() 在 per-source gate 后立刻抓;elapsed_ms 在三个分支前一次捕获后复用。Err 路径用
record_fetch_error_with_timing帮手。 - serde migration:新字段全
#[serde(default)],序列化永远 null(不复用 AdBlock error banner false positive: last_error serialization mismatch (undefined vs null) #202 的错误)。last_refresh_success_at故意没加——和last_fetched_at重复。 - 前端 stats panel 拉取时机:仅 mount 时拉一次。没做轮询(issue 里也没要求)。
- Frontend 类型 + bindings + atom + action + re-export:四件套都串通;test helper
makeSource同步加了新字段;test 27 个文件 330 依旧绿。
我决定不修的事(已在 PR description 列了)
- Stats 面板不轮询:mount 时拉一次。用户想看实时数字,得手动触发(例如刷新按钮或切回 tab)。issue 里只要求"提供 stats",没要求轮询。后续 UX 选择可以独立发 PR。
- Reload→engine AtomicBool 与 state.enabled 的小窗口:
set_ad_block_enabled写 state 后调 persist_and_reload 才把 engine 同步上。这段窗口是已有的 race,不在本 PR 范围。
CI gates (4383986 提交后)
cargo fmt --all -- --check✓cargo clippy --all-targets --all-features -- -D warnings✓cargo test --all-features --workspace✓ (558 passed, 0 failed)pnpm build✓pnpm test✓ (330 passed, 0 failed)
🤖 Generated with Codex
CI's Rust Checks job failed on `cargo clippy -D warnings` with: error: empty line after doc comment --> crates/mhost-dns/src/adblock.rs:81:1 Root cause: in PR #219's self-review follow-up (4383986), I moved `AdBlockStats` above `AdBlockEngine` so the doc comment links read top-down. That left a blank line between two doc comments (the trailing `/// lock-free.` of the AdBlockEngine block and the leading `/// Snapshot of ...` of the AdBlockStats block). `clippy::empty_line_after_doc_comments` (new style lint, clippy 1.98+ — the link in CI's error message points at rust-clippy/rust-1.98.0) flags that blank line as bad form. Local `cargo clippy --version` on this workstation is 0.1.97 / Rust 1.97, which doesn't surface this lint yet — that's why my pre-push clippy run didn't catch it. CI runs stable Rust toolchain (currently 1.98) so it sees the new lint. Fix: replace the blank line with an empty doc comment line (`///`). This is the lint's "include the empty line in the comment" escape hatch and keeps the two doc blocks visually separated without a literal blank line. No CI gate changes: - cargo fmt --all -- --check ✓ - cargo clippy --workspace --all-targets -- -D warnings ✓ (this run uses 1.97 so doesn't actually exercise the new lint; CI on 1.98 will validate) - cargo test --all-features --workspace ✓ (558 passed, 0 failed) 🤖 Generated with [Codex](https://codex.openai.com/)
Code reviewI read the full diff (master..HEAD, +705/-22 across 16 files) and ran the full CI gates locally:
The 4 new mhost-dns tests are present and pass: CorrectnessCounter semantics (issue #199 contract). Walked through check() against every branch:
Reload signature change. grep -rn reload_ad_block_rules --include='*.rs' finds exactly 6 callers; all 6 pass enabled. No missed caller. Race in get_ad_block_stats (commit 4383986). The commit description claims it fixes the race by reading state.enabled before constructing the response. Looking at the actual code: (1) and (2) are independent and a concurrent set_ad_block_enabled IPC between them still leaves the response's enabled field agreeing with state.enabled at read time, but the counters may have been counted under the engine value set_enabled had BEFORE the toggle propagated through persist_and_reload. The fix here is purely a local-variable extraction — the race window is identical to the pre-4383986 code. The commit message acknowledges this is 'microseconds and just a UI label flicker', so the team has explicitly accepted the benign race. Fine for v1, but if you ever want to close the window cleanly, two options:
Not blocking — flagging so it does not get lost. record_fetch_error_with_timing. Mirrors record_fetch_error exactly: same lock acquisition, same find_source_mut, same last_error = Some(err.to_string()) write — then adds the two timing fields. Behavior preserves the prior function (line 1265 caller still uses record_fetch_error, untouched). Correct. Serde migration. Both new fields use #[serde(default)] without skip_serializing_if. They will appear as null in the JSON when empty — consistent with the issue #202 contract. Legacy documents without the key deserialize as None. See test gap below. PerformanceHot-path overhead. check() adds one Relaxed load on enabled + one Relaxed fetch_add per outcome. Both are lock-free and the atomic loads/stores compile to a single MOV on x86 / LDADD+STADD on ARM. No locks taken in the hot path; the existing snapshot() Arc-clone is unchanged. Fine. stats() correctness across concurrent check(). Each counter is independently monotonic and Relaxed loads are fine for that contract. The stats_can_be_read_concurrently_with_check test exercises this on a real thread — it asserts final values are exactly the writer's 1000 increments, which catches lost-update bugs. Good. enabled: AtomicBool vs current: RwLock<Arc>. The two are independent atomics. There is a small window where readers can see one but not the other (e.g. enabled=true with the old snapshot during set_enabled(true) → rebuild()). This is benign for the same reason as #132: every code path is either 'no counter advances' or 'counts under the wrong rules' — both are observed and smoothed out by the cumulative counter semantics. The Relaxed ordering is documented as a deliberate choice. Fine. TestsThe 4 new tests are doing what their names claim. Walked through each:
Test gaps worth flagging (medium):
Frontendenabled field on AdBlockStatsView is used. Confirmed: stats.enabled drives the three-branch placeholder (null / master-off / master-on) at src/pages/AdBlock.tsx:742-757. Not dead weight. Stats panel UX. Collapsed Detailsby default — keeps the page quiet; one-click to expand. Counter tiles use font-variant-numeric: tabular-nums so the digits do not jitter as values grow. Reasonable.One small UX nit. Table header is 'Source | Last refresh | Last failed' but the 'Last refresh' column shows a duration (e.g. '1234 ms'), not a timestamp. Users will read 'Last refresh' and expect a date. Suggest renaming to 'Duration' (or 'Last refresh (ms)'). Not a blocker — nit. No auto-refresh. PR description acknowledges this as a deliberate scope choice ('polling rate is a separate concern'). For a v1 of an observability feature that is fine; the user can re-trigger by toggling master or refreshing sources. Style / APIAdBlockStatsView vs deriving Serialize on the engine struct. A *View suffix is new to this file (other IPC structs in commands/adblock.rs do not use it). Justified here because the engine's AdBlockStats lives in a crate that has no Tauri/serde dependency and adding Serialize there is a one-way door. Keeping the view-model split is reasonable. Worth documenting in a one-line comment on the view struct that the split is intentional (rather than a copy-paste accident) — the existing doc covers this. Nits
What's done well
VerdictLGTM with minor follow-ups. Nothing in this PR is a correctness regression. The race in get_ad_block_stats is acknowledged in the commit message as benign and is acceptable for v1; if you ever want to close it, see the two options above. The test gaps (items 1-4) are real but each is a small addition — recommend handling at least #1 (record_fetch_error_with_timing unit test) and the typo/doc inconsistency before merge. |
…199 sub-task B) This commit addresses every actionable finding from the PR #219 code-reviewer's review. The previous "race fix" in commit 4383986 was actually cosmetic (just hoisted state.enabled into a local variable, but the read still happened after the engine stats read, so the race window between engine and state was unchanged). This commit is the real fix. ## What's fixed ### Real race fix (the big one) `AdBlockEngine::is_enabled(&self) -> bool` exposes the engine's mirrored master switch. `DnsServer::ad_block_enabled(&self) -> bool` delegates to it. `get_ad_block_stats` now reads BOTH the engine counters AND the engine's enabled flag in one lock acquisition. The old `state.ad_block_state.read().await.enabled` read is gone \u2014 it was the source of the race: `set_ad_block_enabled` writes state.enabled and then mirrors onto the engine's AtomicBool in two steps inside `persist_and_reload`. Reading state instead of the engine meant the IPC could see a gating `true` while the engine was still parking (`!check()` returned None), or vice versa. ### Doc / typo nits * `9perceives` typo in `fetch_and_cache_source` timing comment (PR #218 also flagged this). * Doc clarification: timer starts right after the per-source gate is acquired, *not* including the queue wait. Removed the self-contradictory "right after the gate is acquired ... including the per-source queue wait" wording. * `AdBlock.tsx` column header renamed from "Last refresh" to "Duration" \u2014 the column shows milliseconds (issue #199 `last_refresh_duration_ms`), not a timestamp. ### Test gaps closed (5 new tests) All 4 medium-severity gaps from the PR review plus one I added: 1. `record_fetch_error_with_timing_writes_all_three_fields` \u2014 direct unit test of the new helper; pins the all-three-fields contract so a future refactor doesn't silently drop timing. 2. `fetch_and_cache_source_200_writes_duration_and_clears_failure` \u2014 integration test of the 200 OK branch with a 100 ms mock delay; asserts duration in `[80, 5000] ms` and that `last_refresh_failed_at` is cleared. 3. `fetch_and_cache_source_304_writes_duration_and_clears_failure` \u2014 same shape for the 304 path; setup pins a stale failure timestamp so the clearing assertion is meaningful. 4. `fetch_and_cache_source_err_writes_duration_and_failure_timestamp` \u2014 500 response + 50 ms mock delay; asserts all three timing / failure fields written, and `last_refresh_failed_at` is within `[before, after]` of the call. 5. `adblock_state_legacy_doc_back_compat_for_199b_fields` \u2014 serde back-compat for the new `last_refresh_duration_ms` / `last_refresh_failed_at` fields. Analog to the earlier `rules_limit_override` back-compat test for #207. Pre-#199-B documents deserialize cleanly with the new fields defaulting to `None`. 6. `get_ad_block_stats_returns_engine_counters_and_enabled` \u2014 direct integration test of the IPC handler. Drives mixed traffic through the engine, reloads with `enabled=false`, and asserts the IPC response reflects (a) cumulative counters (zero_addr=3, whitelist=2, misses=4) AND (b) the engine's authoritative gating state (`enabled=false`). This test fails on the old code path (state.read) and passes on the new code path (engine read), so it's a regression guard for the race fix. ## Test counts * Backend: 558 \u2192 564 passed (mhost 187 \u2192 193, mhost-dns 140 \u2192 144, others unchanged). * Frontend: 330 \u2192 330 (no behavioural test changes; the AdBlock.tsx column header change has no test impact). * No tests removed or skipped. ## CI gates * cargo fmt --all -- --check \u2713 * cargo clippy --workspace --all-targets -- -D warnings \u2713 (clean: the inline IPC helper resolved the `clippy::items_after_test_module` lint the free helper had triggered) * cargo test --all-features --workspace \u2713 (564 passed, 0 failed) * pnpm build \u2713 * pnpm test \u2713 (330 passed, 0 failed) 🤖 Generated with [Codex](https://codex.openai.com/)
Review follow-up commit:
|
| # | Test | Covers |
|---|---|---|
| 1 | record_fetch_error_with_timing_writes_all_three_fields |
direct unit test of the new helper |
| 2 | fetch_and_cache_source_200_writes_duration_and_clears_failure |
200 OK branch with 100 ms mock delay |
| 3 | fetch_and_cache_source_304_writes_duration_and_clears_failure |
304 branch |
| 4 | fetch_and_cache_source_err_writes_duration_and_failure_timestamp |
Err branch (5xx) |
| 5 | adblock_state_legacy_doc_back_compat_for_199b_fields |
serde back-compat for new fields (analog to #207's test) |
| 6 | get_ad_block_stats_returns_engine_counters_and_enabled |
direct integration test of the IPC; doubles as a regression guard for the race fix |
Test counts
- Backend: 558 → 564 passed
- Frontend: 330 → 330 (no change)
CI gates
cargo fmt --all -- --check✓cargo clippy --workspace --all-targets -- -D warnings✓cargo test --all-features --workspace✓ (564 passed, 0 failed)pnpm build✓pnpm test✓ (330 passed, 0 failed)
🤖 Generated with Codex
Summary
Sub-task B of the AdBlock perf/observability follow-up (#199). Adds the observability piece that #218 (sub-task C — cache cleanup) was missing: the engine now reports its hit / miss behaviour and per-source refresh latency, surfaced via a new IPC + a folded stats panel in the UI.
What's new
Engine (
mhost-dns)AdBlockEngine::enabled: AtomicBoolcheck()short-circuits when off so the user isn't seeing phantom counters when they've parked ad blocking. Mirrored at every reload byreload_ad_block_rules(enabled, ...).hits_zero_addr/hits_nxdomain/hits_whitelist/misses(AtomicU64)Relaxedordering — each counter is independently monotonic, no cross-counter consistency required.AdBlockEngine::stats() -> AdBlockStatsAdBlockStatsview typeDebug + Clone + Copy + PartialEq + Eq. Cumulative since process start; the engine has no reset path (consumer computes deltas).Counter semantics (
check())hits_whitelist += 1, returnNone.hits_nxdomain += 1, returnSome(NxDomain).hits_zero_addr += 1, returnSome(ZeroAddress(ip)).misses += 1, returnNone.None. The whitelist walk is skipped becauseclassify_rulesonly feeds block rules to the engine when master is on, so the whitelist walk would have no observable effect anyway. (Issue contract: "misses 累加, 不要把 whitelist 命中算 miss".)hits_whitelist += 1. Without block rules, nomissesis counted for "no match" — there's nothing to miss against.Model (
mhost-core)AdBlockSourcegains two fields, both following the#[serde(default)]convention from #202 (always serialized asnull, neverundefined):Timing (
commands::adblock::fetch_and_cache_source)Instant::now()captured right after the per-source gate is acquired.elapsed_ms = started_at.elapsed().as_millis() as u64captured once, reused for all three branches.last_refresh_duration_ms, clearlast_refresh_failed_at.last_refresh_duration_ms, clearlast_refresh_failed_at(issue [Enhancement] AdBlock 用 ETag 实现条件 GET,省掉周期刷新的整份下载 #193 contract preserved).record_fetch_error_with_timinghelper writeslast_error+last_refresh_duration_ms+last_refresh_failed_at = Some(now).Reload plumbing
reload_ad_block_rulesgainsenabled: boolas the first parameter:All four call sites updated:
commands::adblock::persist_and_reload(the steady-state reload path).commands::dnscold-start hot reload (post-AppState::new).commands::dnsauto-refresh tick (per periodic reload).state::AppState::newcold-start (spawn_blockingboundary).IPC
New command:
Returns zeros if DNS mode is off (no engine loaded).
AdBlockStatsViewmirrors the engine struct plusenabled: boolso the UI can label the panel.Frontend
AdBlockSourceinterface gainslast_refresh_duration_ms: number | nullandlast_refresh_failed_at: string | null.AdBlockStatsinterface.getAdBlockStats()binding insrc/lib/tauri.ts.adBlockStatsAtom+fetchAdBlockStatsAtomaction insrc/stores/profiles/{state,actions}.ts, re-exported fromindex.ts.src/pages/AdBlock.tsxas a collapsed<details>card immediately after the Auto-refresh banner:name,last_refresh_duration_ms,last_refresh_failed_at)AdBlock.module.cssfor the stat grid / table layout.Tests
Four new
tokio::tests inmhost-dns/src/adblock.rs:counter_accounting_for_mixed_traffic— drivescheck()with a mix of whitelist hits, zero-addr hits, NXDOMAIN hits, and miss traffic; asserts each counter lands on the right number.whitelist_hit_is_not_a_miss— issue [Perf+Enhancement] AdBlock 性能与可观测性 follow-up(trie + 指标 + cache 清理) #199 contract test.master_switch_off_does_not_advance_any_counter— short-circuit correctness.stats_can_be_read_concurrently_with_check—AtomicU64 + Relaxedsurvives concurrentcheck()from another thread; the final assertion locks in the post-run counter values.Existing tests in
mhost-dnswere updated to callengine.set_enabled(true)afterrebuild(...)(they were already setting up rule sets and assertingSome(...)); without the master-switch-on step the newcheck()short-circuits toNone.CI gates
cargo fmt --all -- --check✓cargo clippy --all-targets --all-features -- -D warnings✓cargo test --all-features --workspace✓ (558 passed: mhost 187, mhost-apply 70, mhost-core 49, mhost-dns 144, mhost-hosts 47, mhost-storage 61)pnpm build✓ (tsc + vite)pnpm test✓ (330 passed, 0 failed)Behaviour change to flag (reviewers)
The
check()fast-path on!has_block_rules()was moved — it now runs AFTER the whitelist walk, not before. This is intentional:hits_whitelistincrements on a match. The empty-rule-set short-circuit prevents a falsemissescount when there's nothing to miss against.Follow-ups (separate PRs)
🤖 Generated with Codex