Fix DNS cache invalidation race in tracker attribution - #777
Merged
Conversation
blockKnownTracker() reads DNS evidence from DatabaseHelper under its read lock, then writes ipToHost/ipToTracker outside any lock. A packet thread that read the DB before a concurrent dnsResolved() insert can still write its now-stale verdict after dnsResolved() has cleared the cache entry for that IP, pinning pre-insert attribution. Fix with a generation counter: dnsResolved() bumps it after clearing the cache (inside the same insertDns()-succeeded, numeric-address block as the removes), and blockKnownTracker() snapshots it before the DB read and skips both puts if it changed during the read. Skipping is free: the next packet to that IP just re-reads the DB. A single global counter can cause the skip on any concurrent DNS answer, not just ones for the same IP, but the window per answer is a DB read (microseconds) against a DNS answer rate that is low even on noisy apps, so the false-skip rate is negligible and costs one extra DB read on the rare hit. A per-IP scheme would avoid that but adds new unbounded state (a map that must itself be cleaned up), which is worse than the cost it removes. Not adding a unit test: the guard is a two-line long comparison with no independent behaviour to verify; the actual race lives in the interleaving of two threads through DatabaseHelper's real lock, which would need a much larger extraction than this fix to test in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
clearTrackerCaches() (called from BlockingMode.applyMode() on every blocking-mode change) and householding()'s 12-hourly wholesale clear both wipe ipToHost/ipToTracker without bumping trackerCacheGeneration, leaving the same unlocked-put race dnsResolved() was fixed for: an in-flight blockKnownTracker() put can still land right after either clear and re-pin a stale entry. clearTrackerCaches() is the one that matters: blockKnownTracker() reads blockAmbiguousTrackers from the current blocking mode at the top of the method, before any cache or DB read, and uses it to resolve mixed tracker/non-tracker DNS evidence. If the mode changes mid-call, the clear wipes the caches but an in-flight put can still pin a verdict computed under the old mode. householding() has the same gap but much lower impact, since the next 12h cycle clears it again regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's fixed
Closes #757.
dnsResolved()(ServiceSinkhole.java) does, in order:insertDns(rr)→prepareUidIPFilters(rr.QName)→ipToHost.remove(rr.Resource)/ipToTracker.remove(rr.Resource).blockKnownTracker()reads DNS evidence viadh.getQAName(uid, daddr, ...)underDatabaseHelper's read lock, then doesipToHost.put(...)/ipToTracker.put(...)outside any lock.insertDns/getQANameserialise onDatabaseHelper'sReentrantReadWriteLock, but that lock does nothing for the cache write. A packet thread that reads the DB before a concurrent insert can still write its now-stale answer after the clear, pinning pre-insert attribution in the cache.prepareUidIPFilters()sitting between the insert and the removes widens the window further. This is reachable at the worst possible moment: a DNS answer arrives and the app connects to that IP microseconds later, so the racing threads run concurrently by construction on the first connection after resolution.Corrected impact (vs. the issue's claims)
The issue frames this as multi-day stale attribution. Both TTL claims don't hold up:
dname == NO_DNAME, which usesNEGATIVE_TRACKER_CACHE_TTL_MS = 60_000specifically so an unconfident miss re-checks soon.householding()runs every 12h and does a wholesaleipToHost.clear()/ipToTracker.clear(), sochosenTime + chosenTtlis never the real bound.What actually bites is narrower: the IP already has a live DNS row, and the new row would have flipped the verdict — either gaining tracker evidence where the cache locked in non-tracker (missed blocks), or gaining non-tracker evidence that makes it ambiguous (
sawTrackerEvidence && sawNonTrackerEvidence && !blockAmbiguousTrackers→NO_TRACKER, i.e. over-blocking persists). Bounded at ≤12h. This is the shared-CDN-IP case, so it's not exotic, but it's a good deal rarer and shorter-lived than the issue implies.The fix
A
static final AtomicLong trackerCacheGeneration, bumped at all three sites that invalidateipToHost/ipToTracker, and checked in the one place that writes to them after an unlocked DB read:dnsResolved()bumps it after bothipToHost.remove/ipToTracker.removecalls, inside the sameUtil.isNumericAddress(rr.Resource)block, itself inside theinsertDns(rr)succeeded branch. Bumping before the removes would reopen the identical window (a racing writer could still land after the bump but before the actual clear). IfinsertDnsreturnsfalse, no new row was inserted and no cache entry became stale, so no bump is needed. If the resource isn't a numeric address, it was never a cache key, so again nothing to invalidate.clearTrackerCaches()— called fromBlockingMode.applyMode()(net/kollnig/missioncontrol/data/BlockingMode.java) whenever the user changes blocking mode — bumps it right after the twoclear()calls. This one matters:blockKnownTracker()readsblockAmbiguousTrackersfrom the current mode at the very top of the method, before any cache or DB read, and uses it to decide whether mixed tracker/non-tracker evidence resolves toNO_TRACKERor stays blocked. If the mode changes mid-computation,clearTrackerCaches()wipes the caches, and without this bump an in-flightputwould re-pin a verdict computed under the old mode — e.g. the user switches to Strict and one IP silently keeps its Standard-mode answer until that entry's TTL (or the next 12hhouseholding()) catches up.householding()— the 12-hourly wholesale clear — bumps it right after itsipToHost.clear()/ipToTracker.clear(), for the same reason and same fix shape, though the impact here is much smaller since the next 12h cycle would clear it again regardless.blockKnownTracker()snapshots the counter before thegetQANameDB read, and performs the twoputs only if the counter is unchanged after the read. Skipping is correct and cheap: the next packet to that IP simply re-reads the DB.Why a global counter, not per-IP (or per-invalidation-site)
A single global counter means any cache invalidation — a DNS answer for any IP, a mode change, or the 12h housekeeping sweep — can cause a spurious skip on an unrelated in-flight
blockKnownTracker()read. The cost of that false-skip is one extra DB read on the next packet to the affected IP; it's not a correctness cost. The window an in-flight read is exposed for is onegetQANamecursor scan (sub-millisecond to low-millisecond), and the trigger is any other invalidation arriving in that window — not exotic on a busy device, but the miss just means the read repeats, so the amortised cost of the false-positive rate stays small relative to the packet path. A per-IP scheme (e.g. aConcurrentHashMap<String, Long>of per-address generations) would tighten the false-skip rate to true positives only, but adds new unbounded state that itself needs eviction/cleanup — worse than the cost it removes, for a bounded, self-correcting cost.Rejected alternatives (per the issue)
DatabaseHelperlock: would hold the lock acrossTrackerList.findTracker()for every DNS row candidate, on the packet-processing hot path — turns a read lock scope into something that blocks concurrent DB writers for tracker-list lookup time.Testing
./gradlew :app:compileGithubDebugJavaWithJavac— passes../gradlew :app:testGithubDebugUnitTest— 279 tests, 0 failures (existing suite, unaffected by this change).blockKnownTracker()is private and needs a liveVpnService, so it isn't directly unit-testable, matching the precedent noted forHostsBlocklistLogicinAGENTS.md. I looked at extracting the generation-check guard the wayHostsBlocklistLogicextracts hosts-file parsing, but the guard itself is a two-linelongcomparison with no independent behaviour — the actual race is in the interleaving of two threads throughDatabaseHelper's real read/write lock and SQLite, and testing that would require pulling apart far more ofblockKnownTracker()(the DB loop,TrackerList,SharedPreferences,PackageManager) than this ~30-line fix touches. Ship without a test rather than force a disproportionate extraction; this change is verified by code reading and the ordering argument above, not by a regression test.Heads-up: rebase with #776
PR #776 (
fix/shared-ip-ui-alive-rows) also editsServiceSinkhole.javaand changes bothgetQAName(uid, daddr, true/false)call sites togetQAName(uid, daddr)(dropping thealiveparameter). One of those call sites is the samegetQANamecall inblockKnownTracker()this PR adds agenerationBeforesnapshot next to. Neither PR touches the same lines as the other, so a merge either way should only need a trivial rebase — flagging for merge sequencing.🤖 Generated with Claude Code