I'm posting this with profiler and syscall data, because every existing thread I can find on
BDLDaemon CPU usage gets answered with some version of "background scanning is expected." This is not that. The product's own on-access threads sit idle more than 99% of the time while a
separate rescan thread holds 83% of a core, sustained, for 18 days.
Environment
- Netgear Antivirus for Mac 10.3.3 (10.3.3.18), Bitdefender CoreSecurity 10.3.3 (10.3.3.127)
- macOS 26.6.2 (25G83), Apple M4, 10 cores, 24 GB
- BDLDaemon: 18 days uptime on one never-restarted PID, 131 hours accumulated CPU, 260-580 MB
RSS, 2.3 GB peak footprint - CPU, measured two independent ways on 2026-09-22 and agreeing to within a percent:
six 10-second samples ran 77.7, 81.8, 80.4, 74.5, 92.5, 91.9%, mean 83.1%. Over the same
17-minute window accumulated CPU time rose 14.1 minutes, 83.0% of a core. An earlier sample set
on the same machine ran 7.6, 39.0, 88.3, 104.0, 168.3, 171.3% (mean 96.4%), and spot readings
across sessions reached 171% - The machine is asleep for most of the 18 days elapsed, so dividing 131 CPU-hours by wall-clock
gives 29% and badly understates the load. 83% of a core, sustained, is what the machine
actually carries whenever it is awake Settings/settings.plist: continuous_run = true, oas_scan = true, scan_access = true
(EC_Settings.plist carries the same three)- "Scan only new and modified files" is ON in the UI
- 21 exclusion entries configured, all with both
Avod and Oas set true
1. The CPU is a rescan, not on-access work
sample at 1 ms, ~4 second window, 3970 samples per thread:
| Thread | On-CPU samples | Blocked | Blocking primitive |
|---|---|---|---|
| shield.rescan | 3970 / 3970 (100%) | 0 | — never waits |
| shield.scan | 31 (0.8%) | 3939 | BDEvent_Wait_Base → __psynch_cvwait |
| shield.notification-dispatcher | 1 (0.03%) | 3969 | -[FSEventsNotifier HandleNotifications] |
The on-access path is healthy and idle. The rescan thread never yields.
2. What that thread is doing
ScanThread(void*) (libShield.dylib)
void ScanThreadT<false>(ScanData*) (libShield.dylib)
EnumerateFolder::Scanning(IScanFolder*, IExclusions*, bool, unsigned long long&)
EnumerateFolder::Scanning(...) [self-recursive, 9 levels, all 3970 samples]
ScanCallbackInterfaceImpl::ScanFile(char const*)
CThreatScannerImpl_Mac::ScanFile(char const*, bool)
CThreatScannerImpl_Mac::ScanFile_Base(char const*, bool)
CThreatScannerImpl::ScanPath(int, char const*)
??? (unsymbolized engine frames)
Nine levels of self-recursive EnumerateFolder::Scanning is a directory-tree descent, not an
event callback. MacExclusions::Excluded(char const*) const appears inside the recursion,
which means the walk descends into excluded trees and tests each entry, rather than pruning at
the root.
Side note for whoever picks this up: below CThreatScannerImpl::ScanPath the stack drops
entirely into unsymbolized frames with no Binary Images entry. And libShield.dylib,
libCommonTools.dylib, bdcore.so.macos-arm64 and libbdnc.so all report version (0). No
embedded version string, so there's no way to correlate a build from a customer's sample.
3. The syscall profile
Four fs_usage -f filesys captures, taken across four days as exclusions were added:
| Capture | Span | BDLDaemon ops | Ops/sec | Metadata | Content reads |
|---|---|---|---|---|---|
| 1 | 15.8 s | 329,884 | 20,944 | 75.2% | 5.3% |
| 2 | 8.4 s | 193,947 | 23,084 | 51.7% | 10.4% |
| 3 | 8.4 s | 124,272 | 14,806 | 48.9% | 13.3% |
| 4 | 19.3 s | 276,313 | 14,322 | 58.3% | 11.4% |
getattrlist, stat64, fstat64 and lstat64 dominate every capture. The daemon is
stat-walking, not reading. That is exactly the work "scan only new and modified files" should
suppress.
4. Unchanged files are rescanned
In capture 4, seven nested ancestor directories were each hit exactly 4,408 times in 19.3
seconds, spanning the entire capture window:
count=4408 span=19.272s /usr
count=4408 span=19.272s /usr/local
count=4408 span=19.272s /usr/local/texlive
count=4408 span=19.272s /usr/local/texlive/2026
count=4408 span=19.272s /usr/local/texlive/2026/texmf-dist
count=4408 span=19.272s /usr/local/texlive/2026/texmf-dist/fonts
count=4408 span=19.272s /usr/local/texlive/2026/texmf-dist/fonts/vf
Identical counts down seven levels means every single file check re-walks and re-stats its whole
ancestor chain. /usr is a read-only system path that does not change. There is no reading of
"scan only new and modified files" under which /usr gets re-checked 4,408 times in 19 seconds.
Caching a directory's result for the duration of a walk would presumably remove a large share of
this, though I have no visibility into why it isn't already done.
Beyond that, within a single capture, tight-loop repetition on files that cannot have changed:
- A directory of help artwork inside a trashed application bundle: 623 operations in 0.244
seconds (~2,550/sec). Nothing in the Trash is new or modified. - One immutable cache blob: 12 syscalls in 10.6 ms — five
stat64, four getattrlist, one
lstat64, one openat, one read. - A cache root directory: 8,422 opens/stats in 15.75 seconds, ~535/sec, same unchanged directory.
- A Python
__pycache__ directory: 586 hits in 1.05 seconds; one __init__.cpython-312.pyc
referenced 325 times across a 7-second capture.
5. Exclusions are not matched against APFS firmlink paths
This one looks like a straightforward bug.
Exclusions for a cloud-sync app's group container and for several source trees are honored
cleanly — one went from 95,221 operations before the exclusion to 0 after, in both later
captures. But the exclusion for ~/miniconda3 is ignored. A capture taken after it was added
shows 58,492 operations against that tree, spanning nearly the whole window.
Of those, 30,518 resolve as /System/Volumes/Data/Users/<user>/miniconda3/... — the real APFS
data-volume path — rather than the /Users/<user>/miniconda3 form the exclusion entry holds.
A prefix-string comparison against the /Users/... form will never match the path the daemon is
actually walking.
To be precise about what I have and have not shown: this is indicated, not confirmed. The
control test (adding the /System/Volumes/Data/... form of the exclusion and re-capturing) has
not been run. A later capture shows zero operations against that tree, but by then the
rescan had moved on to other directories on its own, so that proves nothing either way. The
supporting evidence is the MacExclusions::Excluded frame appearing inside the recursion in
section 2, and the path forms above. Someone with the source can settle it in a minute.
6. Exclusions relocate the work; they do not reduce it
This is the part that makes the whole thing unwinnable from the user side. Each exclusion I added
did suppress scanning of that tree — and the rescan simply moved to the next large directory on
the disk. By capture 4 it had settled on a 9.7 GB TeX Live install (236,722 files) and a local
Gmail "All Mail" store, neither of which had appeared in any earlier capture. The operation rate
stayed in the same band throughout, 14,000 to 23,000 calls per second across all four captures.
On this machine no exclusion list solves the problem. Enough static data (a package manager, a
TeX distribution, a language toolchain, a mail archive) gives the rescan thread another target
each time. The fix has to be on the rescan side, not the exclusion side.
Questions
- Is there any supported way to disable continuous rescanning?
continuous_run is true in two
plists and exposed nowhere in the UI. - What is
shield.rescan supposed to do when "Scan only new and modified files" is enabled? On
this machine the setting appears to have no effect on it at all. - Should exclusion matching canonicalize
/System/Volumes/Data/... firmlink paths? If so, this
is a live defect in 10.3.3.
Related existing reports of the same ignored setting and the same daemon CPU behavior:
discussion 98255, 77468, 82170, 84344, 76027, 98267.