Skip to content

feat(adblock): drop cache when source disabled, refetch on re-enable (#199, sub-task C) - #218

Merged
flyhigher139 merged 2 commits into
masterfrom
codex/issue-199-c-disabled-cache-cleanup
Sep 17, 2026
Merged

flyhigher139 merged 2 commits into
masterfrom
codex/issue-199-c-disabled-cache-cleanup

Conversation

@flyhigher139

Copy link
Copy Markdown
Contributor

Summary

Sub-task C of the AdBlock perf/observability follow-up (#199, option A).

transition before after
enabled → disabled cache file lingered forever on disk delete_cache on disable — parked source leaves no on-disk residue
disabled → enabled (no transition observed; cache was already gone) inline fetch_and_cache_source before persist_and_reload, so the engine actually sees rules to load
enabled → enabled (no-op) (no-op) (no-op, with test guarding against a future regression)

Issue #199 option A was picked over option B (LRU prune at N sources) because the behaviour is more predictable — a disabled source never holds a cache.

What changed

Single file: src-tauri/src/commands/adblock.rs (+273 / −4).

  • set_ad_block_source_enabled is refactored into the existing _impl(state, ...) pattern (matches add_ad_block_source_impl and set_ad_block_source_rules_limit_override_impl) so the disable / re-enable / no-op edges are unit-testable without a Tauri State. The public IPC command is a one-line shim.
  • The disable path calls adblock_store::delete_cache, which is already idempotent (NotFound is success). IO errors are swallowed — a missed delete is a hygiene issue, not a correctness issue, and sweep_orphan_caches covers it on next startup.
  • The enable path calls fetch_and_cache_source(... force=false) so RFC 7232 conditional GET (#193) saves bandwidth when the upstream still has the prior ETag. The 304-with-missing-cache downgrade (#206 finding 2) handles the case where the disable path's delete_cache left no file on disk — exactly the disable→enable scenario this PR introduces.
  • Fetch errors are logged but not propagated: the user's toggle succeeded, only the network leg failed, and record_fetch_error populates last_error for the existing UI badge.

Tests

Three new tokio::tests under commands::adblock::tests:

test covers
set_ad_block_source_enabled_impl_disable_drops_cache disable a source with a populated cache; assert the file is gone and bookkeeping (rule_count, etag) is preserved (matches the 304 contract from #193)
set_ad_block_source_enabled_impl_re_enable_refetches_cache start from a disabled state whose cache is already absent, enable, assert the cache file reappears with the freshly fetched body and bookkeeping is rewritten
set_ad_block_source_enabled_impl_same_value_is_noop toggle to the current value, assert cache mtime is unchanged — guards against a future refactor that swaps prev_enabled capture for an unconditional delete-or-fetch

cargo test --all-features reports 187 passed, 0 failed (184 existing + 3 new).

CI gates

  • cargo fmt --all -- --check ✓
  • cargo clippy --all-targets --all-features -- -D warnings ✓
  • cargo test --all-features ✓
  • cargo build ✓

Known limitation (acknowledged, not fixed here)

If a user clicks Refresh and then immediately toggles Disabled, the in-flight fetch is serialized behind the per-source gate (#206 finding 1) but does not block on ad_block_state. The disable's delete_cache can therefore run, and then the in-flight fetch can write the cache behind it — leaving a fresh cache file lingering for a now-disabled source until the next toggle or sweep_orphan_caches on restart.

This is a transient hygiene issue, not a correctness issue — the engine classifies by s.enabled, so the lingering cache stays unloaded. Fixing it would mean teaching fetch_and_cache_source to re-check s.enabled after the per-source gate is released; out of scope for this PR (a cache-hygiene follow-up). Documented in the function doc comment so future reviewers know it's intentional.

Follow-ups (separate PRs from this one)

This PR only addresses issue #199 sub-task C. Sub-tasks A (trie) and B (hit-rate / refresh-duration metrics) are tracked separately and intentionally not bundled — see #199 for the full breakdown.

Frontend UX note (not addressed here)

The setAdBlockSourceEnabledAtom in src/stores/profiles/actions.ts does not currently toggle isAdBlockLoadingAtom, so the toggle UI does not show a spinner during the post-enable fetch (which can take up to FETCH_TIMEOUT_SECS on network failure). The behaviour matches the existing refresh_ad_block_source button at the atom level (await inline, no UI spinner for the toggle path). Adding a per-source spinner is a UX-only follow-up that can ship independently of this backend change.

🤖 Generated with Codex

mHost Developer added 2 commits September 17, 2026 09:59
…(issue #199)

Sub-task C of the AdBlock perf/observability follow-up (issue #199,
option A). When a source flips from enabled -> disabled, drop its
`adblock-cache/<id>.txt` so a parked source leaves no on-disk residue.
When it flips back to enabled, kick a re-fetch off the per-source gate
*before* `persist_and_reload` so the upcoming `classify_rules` actually
sees rules to push into the engine — otherwise the user would see an
enabled source with zero rules until the next auto-refresh tick.

Implementation notes:

* `set_ad_block_source_enabled` is refactored into the existing
  `_impl(state, ...)` pattern (matches `add_ad_block_source_impl` and
  `set_ad_block_source_rules_limit_override_impl`) so the disable /
  re-enable / no-op edges are unit-testable without a Tauri `State`.
* `delete_cache` is already idempotent (treats `NotFound` as success),
  so a concurrent sweep or prior disable is harmless.
* The inline fetch uses `force=false`, so RFC 7232 conditional GET
  (issue #193) saves bandwidth if the upstream still has the prior
  ETag. The 304-with-missing-cache downgrade (issue #206 finding 2)
  handles the case where the disable path's `delete_cache` left no
  file.
* Fetch errors are logged, not propagated: the user's toggle
  succeeded, only the network leg failed, and `record_fetch_error`
  populates `last_error` for the UI badge.

Race acknowledged (not fixed): if a user clicks Refresh then
immediately toggles Disabled, the in-flight fetch is serialized
behind the per-source gate (issue #206) but does not block on
`ad_block_state`. The disable's `delete_cache` can therefore run and
then be overwritten by the in-flight fetch's cache write — a transient
cache file lingering for a now-disabled source until the next toggle
or `sweep_orphan_caches` on restart. Hygiene-only, not correctness
(the engine classifies by `s.enabled` so the lingering cache stays
unloaded). Fixing it would mean teaching `fetch_and_cache_source` to
re-check `s.enabled` post-gate, out of scope for the cache-cleanup
follow-up.

Tests added (all `tokio::test`):

* `set_ad_block_source_enabled_impl_disable_drops_cache` — disable a
  source with a populated cache; assert the file is gone and bookkeeping
  (rule_count, etag) is preserved (matches the 304 contract from
  issue #193).
* `set_ad_block_source_enabled_impl_re_enable_refetches_cache` —
  start from a disabled state whose cache is already absent, enable,
  assert the cache file reappears with the freshly fetched body and
  bookkeeping is rewritten.
* `set_ad_block_source_enabled_impl_same_value_is_noop` — toggle to
  the current value, assert cache mtime is unchanged. Guards against
  a future refactor that swaps `prev_enabled` capture for an
  unconditional delete-or-fetch.

cargo fmt + cargo clippy -D warnings + cargo test all green.
Three small correctness/robustness fixes surfaced by re-reading the
diff against the code it relies on. None of these change behaviour;
they sharpen claims and assertions so the next reviewer (and future
me) doesn't have to take them on faith.

1. Doc comment was overstated.
   The function's doc comment claimed "this call always ends with a
   populated cache", but issue #211-1 already established that the
   pathological double-304 (a server returning 304 on BOTH the
   conditional AND the unconditional downgrade retry) is the one
   case where `fetch_and_cache_source` fails and the cache stays
   empty. The doc now spells out that edge explicitly and points at
   the test that proves it
   (`fetch_and_cache_source_errors_when_downgrade_retry_also_304`).

2. `set_ad_block_source_enabled_impl_re_enable_refetches_cache` had
   a trivially-true assertion.
   The setup pinned `last_error: None`, then asserted
   `stored.last_error.is_none()` after the fetch — tautological, not
   a test. Pinned `last_error: Some("prior offline failure")` in the
   setup and added a comment explaining why the assertion now
   actually exercises the "successful fetch clears prior error" path.

   Also tightened the cache-content check from
   `cache.contains("fresh.example.com")` (which would pass even if
   the body was re-canonicalised with a trailing newline dropped) to
   a verbatim `assert_eq!(cache.as_str(),
   str::from_utf8(body).unwrap(), ...)`.

3. `set_ad_block_source_enabled_impl_same_value_is_noop` relied on
   mtime alone.
   mtime is a 1-second-resolution field on some filesystems, so a
   no-op toggle that overwrites the cache with byte-identical content
   could false-pass. Added a content check as defence in depth:
   re-read the file and `assert_eq!` against the original bytes.

No CI gate changes:
- cargo fmt --all -- --check ✓
- cargo clippy --all-targets --all-features -- -D warnings ✓
- cargo test --all-features ✓ (187 passed, 0 failed)
@flyhigher139

Copy link
Copy Markdown
Contributor Author

Self-review (post-merge_request 自查)

cef4018 是初始 commit;8231da1 是这次自审发现的 follow-up。我盯着 diff 又读了一遍,发现了 3 个真实问题,全部已经修了。下面是诚实复盘。

找到的问题与处理

# 问题 严重度 处理
1 doc comment 写 "this call always ends with a populated cache" — 但 issue #211-1 已经证明 double-304 的病态上游会让 fetch 报错、cache 留空。这是过度声明。 低(doc) 在 8231da1 改写:明确指出该边缘情形 + 指向 fetch_and_cache_source_errors_when_downgrade_retry_also_304 测试
2 测试 2 setup 里 last_error: None,然后断言 stored.last_error.is_none() — 恒真,没在测任何东西。 中(测试可信度) 在 8231da1 把 setup 改成 last_error: Some("prior offline failure"),断言现在真的在测"成功 fetch 清掉旧的 error"。顺带把 cache.contains(...) 收紧成 assert_eq!(cache.as_str(), str::from_utf8(body).unwrap(), ...),避免尾部空格下落丢了误判
3 测试 3 只断言 mtime 不变。在 mtime 1 秒精度的文件系统上,no-op toggle 写入字节相同内容可能 false-pass。 低(罕见) 加 assert_eq! 对内容做 verbatim 比对,做 defense in depth

没找到问题的地方(但本以为是风险点的)

为了以后 reviewer 不必担心,这里也列一下我特意看过但确认没问题的边界:

  • 并发 toggle race:ad_block_state 写锁序列化了两端的 toggle;同值场景 prev_enabled == 新值,删除/获取都不触发,已用 test 3 守护。
  • DNS 没开时的 fetch 流程:persist_and_reload 里 if dns_enabled && !cancel.is_cancelled() 守护着,DNS off 时 classify_rules 不跑,但 fetch 仍然写 cache + 更新 in-memory bookkeeping。测试用的 state 默认 dns_enabled=false,等于验证的就是这条路径。OK。
  • fetch 失败的错误传播:故意不传,design 已经在 description 里交代(用户意图"启用"成功,仅网络腿失败,last_error 由 record_fetch_error 写入)。不是回归到旧行为。
  • _impl 重构正确性:add_ad_block_source_impl / set_ad_block_source_rules_limit_override_impl 已经是这个套路,IPC 公共命令走 &state → State 解引用为 &AppState。同一文件、同一种 idiom。
  • fetch-then-persist 顺序:故意 fetch 在 persist 前面,让后续的 classify_rules 看到新 cache。persist_and_reload 内部 classify + reload 在 spawn_blocking 里串行,原子。

我决定不修的两件事(PR description 已经声明)

  1. Refresh-then-Disable race:in-flight fetch 在 per-source gate 上串行(issue Ad-block refresh hardening + test harness fixes (from PR #204 review P3s) #206 finding 1),但不阻塞 ad_block_state。Disable 的 delete_cache 可能跑完,in-flight fetch 又把 cache 写回来。引擎按 s.enabled 分类,所以 cache 文件残留不影响正确性,仅是卫生问题。修这个要改 fetch_and_cache_source 本身(gate 释放后再 check s.enabled),超出 sub-task C 的范围。留在 doc comment 里声明,issue [Perf+Enhancement] AdBlock 性能与可观测性 follow-up(trie + 指标 + cache 清理) #199 后续讨论。
  2. 前端 spinner 缺失:setAdBlockSourceEnabledAtom 没设 isAdBlockLoadingAtom,re-enable 最多卡 FETCH_TIMEOUT_SECS 没视觉反馈。这是 UX gap,跟 backend 改动正交,留作单独 UX PR。

CI gates

8231da1 提交前再跑了一遍:

  • cargo fmt --all -- --check ✓
  • cargo clippy --all-targets --all-features -- -D warnings ✓
  • cargo test --all-features ✓(187 passed, 0 failed)

🤖 Generated with Codex

@flyhigher139
flyhigher139 merged commit 80a053c into master Sep 17, 2026
4 checks passed
@flyhigher139
flyhigher139 deleted the codex/issue-199-c-disabled-cache-cleanup branch September 17, 2026 06:24
flyhigher139 pushed a commit that referenced this pull request Sep 17, 2026
…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/)
flyhigher139 added a commit that referenced this pull request Sep 17, 2026
…, sub-task B)

Sub-task B of the AdBlock perf/observability follow-up (#199). Adds the observability piece that #218 (sub-task C — cache cleanup) was missing.

Engine (`mhost-dns`):

| addition | why |
|---|---|
| `AdBlockEngine::enabled: AtomicBool` | Mirror of the master switch; `check()` short-circuits when off so the user isn't seeing phantom counters when they've parked ad blocking. Mirrored at every reload by `reload_ad_block_rules(enabled, ...)`. |
| `hits_zero_addr` / `hits_nxdomain` / `hits_whitelist` / `misses` (`AtomicU64`) | Lock-free counters on the DNS hot path. `Relaxed` ordering — each counter is independently monotonic, no cross-counter consistency required. |
| `AdBlockEngine::stats() -> AdBlockStats` | Single struct snapshot of the four counters. |
| `AdBlockEngine::is_enabled() -> bool` | Engine-side getter for the master switch mirror. Closes the race where `state.enabled` had been written but the engine's AtomicBool hadn't been mirrored yet (PR #219 review-followup). |
| `AdBlockStats` view type | `Debug + Clone + Copy + PartialEq + Eq`. Cumulative since process start; the engine has no reset path (consumer computes deltas). |

### Counter semantics (`check()`)

- **Master switch on, whitelist match** → `hits_whitelist += 1`, return `None`.
- **Master switch on, NXDOMAIN match** → `hits_nxdomain += 1`, return `Some(NxDomain)`.
- **Master switch on, zero-addr match** → `hits_zero_addr += 1`, return `Some(ZeroAddress(ip))`.
- **Master switch on, no rule, no whitelist** → `misses += 1`, return `None`.
- **Master switch off** → no counter advances, return `None`. The whitelist walk is skipped because `classify_rules` only 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".)
- **Master switch on, no block rules loaded, whitelist match** → `hits_whitelist += 1`. Without block rules, no `misses` is counted for "no match" — there's nothing to miss against.

### Reload plumbing

`reload_ad_block_rules` gains `enabled: bool` as the first parameter:

\`\`\`rust
pub fn reload_ad_block_rules(
    &self,
    enabled: bool,
    zero_addr_rules: HashMap<...>,
    nxdomain_rules: HashSet<...>,
    whitelist: HashSet<...>,
) {
    self.ad_block_engine.set_enabled(enabled);
    self.ad_block_engine.rebuild(zero_addr_rules, nxdomain_rules, whitelist);
    self.cache.lock().clear();
}
\`\`\`

All four call sites updated:
- `commands::adblock::persist_and_reload` (the steady-state reload path).
- `commands::dns` cold-start hot reload (post-`AppState::new`).
- `commands::dns` auto-refresh tick (per periodic reload).
- `state::AppState::new` cold-start (`spawn_blocking` boundary).

### Model (`mhost-core`)

`AdBlockSource` gains two fields, both following the `#[serde(default)]` convention from #202 (always serialized as `null`, never `undefined`):

\`\`\`rust
pub last_refresh_duration_ms: Option<u64>,   // wall-clock ms of the last fetch
pub last_refresh_failed_at: Option<DateTime<Utc>>,  // cleared on success
\`\`\`

> Deliberately NOT added: `last_refresh_success_at`. The existing `last_fetched_at` already covers this.

### 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 u64` captured once, reused for all three branches.
- **200 OK path** → write `last_refresh_duration_ms`, clear `last_refresh_failed_at`.
- **304 path** → write `last_refresh_duration_ms`, clear `last_refresh_failed_at` (issue #193 contract preserved).
- **Err path** → new `record_fetch_error_with_timing` helper writes `last_error` + `last_refresh_duration_ms` + `last_refresh_failed_at = Some(now)`.

### IPC

New command:

\`\`\`rust
#[tauri::command]
pub async fn get_ad_block_stats(state: State<'_, AppState>)
    -> Result<AdBlockStatsView, MhostError>
\`\`\`

Reads both counters AND enabled from the engine in a single lock acquisition (closes the race that an earlier draft had with `state.ad_block_state.read().await.enabled`). `AdBlockStatsView` mirrors the engine struct plus `enabled: bool` so the UI can label the panel.

### Frontend

- `AdBlockSource` interface gains `last_refresh_duration_ms: number | null` and `last_refresh_failed_at: string | null`.
- New `AdBlockStats` interface.
- New `getAdBlockStats()` binding in `src/lib/tauri.ts`.
- New `adBlockStatsAtom` + `fetchAdBlockStatsAtom` action in `src/stores/profiles/{state,actions}.ts`, re-exported from `index.ts`.
- Stats panel in `src/pages/AdBlock.tsx` as a collapsed `<details>` card immediately after the Auto-refresh banner:
  - 4 counter tiles (zero-addr hits / nxdomain hits / whitelist hits / misses)
  - Per-source refresh timing table (`name`, `last_refresh_duration_ms`, `last_refresh_failed_at`)
  - Master-switch-off explanatory placeholder (so users don't think counters are broken when they've parked ad blocking)
- CSS additions in `AdBlock.module.css` for the stat grid / table layout.

### Tests

10 new tests in this PR (4 from initial commit, 6 from the review follow-up):

**Initial (PR #219):**
- `counter_accounting_for_mixed_traffic`
- `whitelist_hit_is_not_a_miss`
- `master_switch_off_does_not_advance_any_counter`
- `stats_can_be_read_concurrently_with_check`

**Review follow-up (this commit):**
- `record_fetch_error_with_timing_writes_all_three_fields` — direct helper test
- `fetch_and_cache_source_200_writes_duration_and_clears_failure` — 200 branch
- `fetch_and_cache_source_304_writes_duration_and_clears_failure` — 304 branch
- `fetch_and_cache_source_err_writes_duration_and_failure_timestamp` — Err branch
- `adblock_state_legacy_doc_back_compat_for_199b_fields` — serde back-compat
- `get_ad_block_stats_returns_engine_counters_and_enabled` — IPC integration + race-fix regression guard

Existing mhost-dns tests were updated to call `engine.set_enabled(true)` after `rebuild(...)` (master switch is now an explicit input).

## CI gates (this PR)

- `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)

## Behaviour changes to flag (reviewers)

1. `check()` master-switch short-circuit now runs before the whitelist walk (the fast-path was re-ordered). Master switch off → no counters, no work. Master on + no block rules + whitelist match → `hits_whitelist` increments (NOT counted as miss).

2. `reload_ad_block_rules` gained an `enabled: bool` first parameter. Any caller that wasn't updated would fail to compile.

3. `get_ad_block_stats` reads the engine's AtomicBool mirror for the `enabled` field instead of `state.enabled`. Closes the race where `set_ad_block_enabled` had updated state but `persist_and_reload` hadn't yet mirrored onto the engine.

## Follow-ups (separate PRs)

- Sub-task A (trie replacement): #199 / sub-task A — the last big ticket for this issue.
- Frontend UX: the stats panel doesn't auto-refresh — it pulls once on mount. Periodic refresh is a separate UX choice that can ship independently.

🤖 Generated with [Codex](https://codex.openai.com/)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant