Summary
DiskScannerEngine.DirectorySizeCache gains one entry per folder walked and has no upper bound on
entry count. Each entry additionally retains a full file path per distinct extension. On a large
drive this leaves roughly 600 MB of live, reachable objects after a scan has finished — memory
that no garbage collector can reclaim, because it is not garbage.
This is the half of the memory problem that #141 does not address. #141 removes the transient spike
during a scan; this issue is about what is still held after it.
Symptom
After a completed scan of a large drive, the backend's working set does not fall back to its idle
baseline. It stays elevated until the cache entries are actually removed — which happens only when
the TTL expires (ScanCacheTtlMinutes, default 15) or when a newer scan pushes this one out of the
5-most-recent-roots window (MaxCacheScans, default 5).
No amount of GC tuning changes this. The objects are reachable from a live singleton.
Measurements
Synthetic DirectorySizeCache built at realistic Windows path lengths and varied file sizes, each
scale point run in its own process so Process.PeakWorkingSet64 is uncontaminated. Retained figures
are GC.GetTotalMemory(forceFullCollection: true) deltas measured after a full blocking collection,
so they count live objects only.
Retained by cache shape — 400,000 folders, 4 extensions per folder
| Cache shape |
Retained |
vs current |
Current (Extensions with full LargestFilePath) |
598 MB |
— |
Extensions kept, LargestFilePath dropped |
279 MB |
−53% |
No Extensions at all |
81 MB |
−86% |
Retained per entry — linearity check across an 80× range
| Folders |
Bytes per entry |
| 5,000 |
1,533 |
| 20,000 |
1,542 |
| 50,000 |
1,548 |
| 100,000 |
1,551 |
| 200,000 |
1,562 |
| 400,000 |
1,568 |
Linear within +2.2%, so the cost scales predictably with folder count.
Extension density — 400,000 folders
| Distinct extensions per folder |
Retained |
| 2 |
359 MB |
| 4 |
598 MB |
| 8 |
1,097 MB |
At 8 distinct extensions per folder the retained cache alone exceeds 1 GB.
Caveat on these figures. The probe allocates a fresh Extensions dictionary per folder, which
models a first scan. In production the same dictionary instance is carried forward by reference
when an unchanged folder is re-scanned — both DiskScannerEngine.cs:230 and
DiskOperationsService.cs:106 do Extensions = oldEntry?.Extensions — so a re-scan retains less
than a naive per-entry multiplication suggests. Treat 598 MB as the first-scan figure.
Cause A — the cache has no bound on entry count
core/backend/Engine/DiskScannerEngine.cs:26
public ConcurrentDictionary<string, CacheEntry> DirectorySizeCache = new(StringComparer.OrdinalIgnoreCase);
A plain dictionary, one entry per folder walked, with nothing capping the count. Only two things
remove entries, and neither is a size limit:
PruneStaleCacheEntries (DiskScannerEngine.cs:619-636) — drops entries whose CachedAtUtc
is older than ScanCacheTtlMinutes.
EnforceMaxCacheScans (DiskScannerEngine.cs:643-673) — keeps the N most-recent scan
roots and evicts whole older scans. Its own comment at :640 says so: // This is "5 most-recent scans", not "5 folders".
So one scan of one large drive is a single scan root, inside the TTL, and is retained in full
however many folders it contains.
Three things that look like they might already bound this, and do not:
MaxCachedNodes does not apply here. It changes runtime behaviour in exactly two places,
core/backend/Services/ScanCacheService.cs:57 and :569, both passing it to CreateMemoryCache
(ScanCacheService.cs:62-69) where it becomes SizeLimit on ScanCacheService's own private
MemoryCache. It never reaches DirectorySizeCache. (It is also read at
core/backend/Models/SettingDtos/AppSettingDto.cs:33 for range validation.) Worth noting
separately: that SizeLimit is denominated in the per-entry Size unit set at
ScanCacheService.cs:146 (Size = Math.Max(1, 1 + node.Files.Count)), not in nodes — so
"MaxCachedNodes = 50000" does not mean 50,000 cached nodes.
EnforceMaxCacheScans silently exempts entries with a null or empty ScanRoot. The filter at
DiskScannerEngine.cs:649 drops them from the grouping, so the scan cap can never evict them.
core/backend/Services/DiskOperationsService.cs:102 writes entries of exactly that shape.
- The restore path is unbounded.
DiskScannerEngine.cs:67 assigns the entire deserialized file
in one statement, before PruneStaleCacheEntries() (:70) and EnforceMaxCacheScans() (:71)
run. Startup peak is therefore whatever the file holds, regardless of settings.
Separately and possibly out of scope: the DI AddMemoryCache() at core/backend/Program.cs:25 is
registered with default options and has no SizeLimit at all. It is shared by
FileTypeScanner.cs:14, AgeHeatmapEngine.cs:13 and ScanDiffService.cs:22. That is a second
unbounded cache.
Cause B — a full file path is retained per (folder, extension)
core/backend/Models/FileTypeScanResult.cs:8
public string LargestFilePath { get; set; } = string.Empty;
Written per folder at core/backend/Engine/DiskScannerEngine.cs:360 (fte.LargestFilePath = f.FullName;). Measured at 53% of retained cache size.
No consumer anywhere reads a per-folder value. It is read in exactly two places, both inside a
single aggregation in FileTypeScanner.BuildFromMemory, and both exist only to compute a maximum:
core/backend/Engine/FileTypeScanner.cs:218-222
if (ext.Value.LargestFileBytes > prev.LargestFileBytes)
{
prev.LargestFileBytes = ext.Value.LargestFileBytes;
prev.LargestFilePath = ext.Value.LargestFilePath;
}
The winner is one path per extension per scan root. Every losing folder's path is discarded and
never reaches any output. The surviving value leaves the backend once, at FileTypeScanner.cs:118,
as ExtensionBreakdownItem.LargestFilePath (Models/FileTypeScanResult.cs:48) — which is a
different property on a different class from the cache field above.
Prior art — and where this collides with it
DirectorySizeCache, backed by scanner_memory.json, is the project's designated durable source
of truth for scanned sessions. File Type Analytics reads from it by design — an arrangement put in
place to fix a bug where analytics reported roughly 3.5 GB out of the 27.2 GB actually scanned. That
is why BuildFromMemory reads CacheEntry.Extensions first and only falls through to
IScanCacheService when the resulting map is empty:
core/backend/Engine/FileTypeScanner.cs:229-232
if (extMap.Count > 0)
{
return extMap;
}
The precedence is pinned by FileTypeScannerTests.cs:347
(Analyze_PrioritizesDirectorySizeCache_WhenBothCachesExist).
This is settled architecture, not an accident, which has a direct consequence for Step 2 below.
Evicting cache entries to save memory removes exactly the data that arrangement depends on: fewer
entries means a partial Extensions map, which means File Types under-reports again — the original
bug, re-introduced as a side effect of a memory fix. Worse, it fails quietly: the fallback at
FileTypeScanner.cs:234-272 returns a smaller result, not an error, so nothing surfaces the loss.
Two corollaries for whoever picks this up:
- Step 2 cannot be treated as a pure memory optimisation. It changes the completeness guarantee.
- "Move File Types onto
IScanCacheService, which already has a working SizeLimit" is not the
intended direction, even though it looks like the obvious one. The source of truth stays where it
is.
Step 1 does not have this problem. It reduces bytes per entry while keeping every entry.
Proposed direction
Not yet planned in detail. Two steps, in order — the first is cheap and self-contained, the second
needs design work and carries the risk described above.
Step 1 — store the file's name, rebuild the path during aggregation
Replace the retained full path with the file's name, and reconstruct the full path at aggregation
time from the dictionary key, which already is the owning folder's absolute path (written as
dir.FullName at DiskScannerEngine.cs:224 and :407). Expected saving: −53% retained, with no
loss of accuracy, no loss of entries, and no change to the wire contract.
The codebase already performs exactly this reconstruction 40 lines further down the same method:
core/backend/Engine/FileTypeScanner.cs:245
var filePath = Path.Combine(node.Path, file.Name);
Two things this is not:
- It is not a rename.
FileTypeEntry has no name field today, so one has to be added.
- The rebuild must use the key of the entry that won the comparison, not whichever key is in scope
after the merge — otherwise the winning file is attributed to the wrong folder. No existing test
would catch that mistake: the multi-directory aggregation tests (FileTypeScannerTests.cs:98-119
and :347) seed no LargestFilePath at all.
Step 2 — bound DirectorySizeCache by entry count
The obvious lever, and the dangerous one — see Prior art above. Any eviction policy has to preserve
File Type completeness and the >80% re-scan hit rate at the same time, so it needs its own design
and its own regression test before it can be scoped. Directions worth evaluating, none evaluated
yet: evict whole scan roots rather than individual folders, spill cold entries to disk, or aggregate
leaf folders beyond a depth threshold.
Recommend treating this as unscoped until Step 1 has landed and the remaining footprint has been
re-measured.
Constraints any fix must respect
docs/notion/prd.md:81 — "Cache hit on re-scan | > 80% | Subsequent reads served from cache".
Bounding the cache trades directly against this. An eviction policy has to be measured against the
hit rate, not only against megabytes.
FR-D3 (prd.md:137) "Cache scan results to heavily reduce CPU load on subsequent reads" and
NFR-P4 (prd.md:356) "Subsequent re-scans must serve cached results where unchanged".
docs/notion/techspec-v2.md:2145 — "percentOfDisk values are accurate relative to
totalScannedBytes". File Types and Extension Breakdown accuracy is not tradeable for footprint.
- Reshaping
CacheEntry is an on-disk format break, and it fails silently. The type is
serialized whole to %APPDATA%/GSAnalyzer/scanner_memory.json (DiskScannerEngine.cs:428-429,
path built at :36-38) and read back at :63. On a shape mismatch the catch at
DiskScannerEngine.cs:79-83 logs "Cache file corrupted, starting fresh" at Warning level and
then deletes the file. So an unversioned shape change degrades to total silent cache loss on
upgrade — which in turn silently violates the 80% hit-rate target above. Version the file or
migrate it.
- Blast radius of a
CacheEntry change: 3 production construction sites
(DiskScannerEngine.cs:224, DiskScannerEngine.cs:407, DiskOperationsService.cs:102), plus one
deserialization site with no new token that a grep for new CacheEntry misses
(DiskScannerEngine.cs:63), plus 11 test sites across 6 files.
- Step 1 does not touch the wire contract. The single backend assertion on the DTO field is
core/backend/GSSystemAnalyzer.Tests/Services/FileExtensionBreakdownTest.cs:90, and it asserts
ExtensionBreakdownItem.LargestFilePath — not the cache field — so with the rebuild in place it
passes unchanged, as do the five Flutter consumers (extension_breakdown_model.dart:56,
extension_breakdown_screen.dart:496, :506, :533, :562, and csv_exporter.dart:52). Note
that two of those are behavioural rather than cosmetic: :533 copies the path to the clipboard and
:562 strips it to a parent directory and triggers a rescan.
Scope note
The cache itself is required by FR-D3 and NFR-P4. The 130–170 MB idle target is not in the
PRD. Searches of docs/notion/prd.md for memory|footprint|idle|MB|NFR|non-functional|performance
and of docs/notion/techspec-v2.md for working set|footprint|idle memory return no requirement
bounding the application's own memory footprint — techspec-v2.md:3012-3061 covers alerting on the
machine's RAM, not the app's. The target originates from the user report behind #141. Worth
resolving before this is scheduled.
Proposed acceptance criteria
Relationship to #141
#141 fixes the spike: peak working set 2,328 MB → 665 MB and Large Object Heap residue
1,049 MB → 12.5 MB at 400,000 folders, by replacing the write path at
DiskScannerEngine.cs:428-435. That path currently materializes two full copies of the cache
before touching disk — a complete Dictionary snapshot (new Dictionary<string, CacheEntry>(DirectorySizeCache)) and then the entire JSON string — writes that
string to a temp file, and atomically moves it into place.
#141 does not touch retained memory. This issue is that half.
Summary
DiskScannerEngine.DirectorySizeCachegains one entry per folder walked and has no upper bound onentry count. Each entry additionally retains a full file path per distinct extension. On a large
drive this leaves roughly 600 MB of live, reachable objects after a scan has finished — memory
that no garbage collector can reclaim, because it is not garbage.
This is the half of the memory problem that #141 does not address. #141 removes the transient spike
during a scan; this issue is about what is still held after it.
Symptom
After a completed scan of a large drive, the backend's working set does not fall back to its idle
baseline. It stays elevated until the cache entries are actually removed — which happens only when
the TTL expires (
ScanCacheTtlMinutes, default 15) or when a newer scan pushes this one out of the5-most-recent-roots window (
MaxCacheScans, default 5).No amount of GC tuning changes this. The objects are reachable from a live singleton.
Measurements
Synthetic
DirectorySizeCachebuilt at realistic Windows path lengths and varied file sizes, eachscale point run in its own process so
Process.PeakWorkingSet64is uncontaminated. Retained figuresare
GC.GetTotalMemory(forceFullCollection: true)deltas measured after a full blocking collection,so they count live objects only.
Retained by cache shape — 400,000 folders, 4 extensions per folder
Extensionswith fullLargestFilePath)Extensionskept,LargestFilePathdroppedExtensionsat allRetained per entry — linearity check across an 80× range
Linear within +2.2%, so the cost scales predictably with folder count.
Extension density — 400,000 folders
At 8 distinct extensions per folder the retained cache alone exceeds 1 GB.
Caveat on these figures. The probe allocates a fresh
Extensionsdictionary per folder, whichmodels a first scan. In production the same dictionary instance is carried forward by reference
when an unchanged folder is re-scanned — both
DiskScannerEngine.cs:230andDiskOperationsService.cs:106doExtensions = oldEntry?.Extensions— so a re-scan retains lessthan a naive per-entry multiplication suggests. Treat 598 MB as the first-scan figure.
Cause A — the cache has no bound on entry count
core/backend/Engine/DiskScannerEngine.cs:26A plain dictionary, one entry per folder walked, with nothing capping the count. Only two things
remove entries, and neither is a size limit:
PruneStaleCacheEntries(DiskScannerEngine.cs:619-636) — drops entries whoseCachedAtUtcis older than
ScanCacheTtlMinutes.EnforceMaxCacheScans(DiskScannerEngine.cs:643-673) — keeps the N most-recent scanroots and evicts whole older scans. Its own comment at
:640says so:// This is "5 most-recent scans", not "5 folders".So one scan of one large drive is a single scan root, inside the TTL, and is retained in full
however many folders it contains.
Three things that look like they might already bound this, and do not:
MaxCachedNodesdoes not apply here. It changes runtime behaviour in exactly two places,core/backend/Services/ScanCacheService.cs:57and:569, both passing it toCreateMemoryCache(
ScanCacheService.cs:62-69) where it becomesSizeLimitonScanCacheService's own privateMemoryCache. It never reachesDirectorySizeCache. (It is also read atcore/backend/Models/SettingDtos/AppSettingDto.cs:33for range validation.) Worth notingseparately: that
SizeLimitis denominated in the per-entrySizeunit set atScanCacheService.cs:146(Size = Math.Max(1, 1 + node.Files.Count)), not in nodes — so"
MaxCachedNodes= 50000" does not mean 50,000 cached nodes.EnforceMaxCacheScanssilently exempts entries with a null or emptyScanRoot. The filter atDiskScannerEngine.cs:649drops them from the grouping, so the scan cap can never evict them.core/backend/Services/DiskOperationsService.cs:102writes entries of exactly that shape.DiskScannerEngine.cs:67assigns the entire deserialized filein one statement, before
PruneStaleCacheEntries()(:70) andEnforceMaxCacheScans()(:71)run. Startup peak is therefore whatever the file holds, regardless of settings.
Separately and possibly out of scope: the DI
AddMemoryCache()atcore/backend/Program.cs:25isregistered with default options and has no
SizeLimitat all. It is shared byFileTypeScanner.cs:14,AgeHeatmapEngine.cs:13andScanDiffService.cs:22. That is a secondunbounded cache.
Cause B — a full file path is retained per (folder, extension)
core/backend/Models/FileTypeScanResult.cs:8Written per folder at
core/backend/Engine/DiskScannerEngine.cs:360(fte.LargestFilePath = f.FullName;). Measured at 53% of retained cache size.No consumer anywhere reads a per-folder value. It is read in exactly two places, both inside a
single aggregation in
FileTypeScanner.BuildFromMemory, and both exist only to compute a maximum:core/backend/Engine/FileTypeScanner.cs:218-222The winner is one path per extension per scan root. Every losing folder's path is discarded and
never reaches any output. The surviving value leaves the backend once, at
FileTypeScanner.cs:118,as
ExtensionBreakdownItem.LargestFilePath(Models/FileTypeScanResult.cs:48) — which is adifferent property on a different class from the cache field above.
Prior art — and where this collides with it
DirectorySizeCache, backed byscanner_memory.json, is the project's designated durable sourceof truth for scanned sessions. File Type Analytics reads from it by design — an arrangement put in
place to fix a bug where analytics reported roughly 3.5 GB out of the 27.2 GB actually scanned. That
is why
BuildFromMemoryreadsCacheEntry.Extensionsfirst and only falls through toIScanCacheServicewhen the resulting map is empty:core/backend/Engine/FileTypeScanner.cs:229-232The precedence is pinned by
FileTypeScannerTests.cs:347(
Analyze_PrioritizesDirectorySizeCache_WhenBothCachesExist).This is settled architecture, not an accident, which has a direct consequence for Step 2 below.
Evicting cache entries to save memory removes exactly the data that arrangement depends on: fewer
entries means a partial
Extensionsmap, which means File Types under-reports again — the originalbug, re-introduced as a side effect of a memory fix. Worse, it fails quietly: the fallback at
FileTypeScanner.cs:234-272returns a smaller result, not an error, so nothing surfaces the loss.Two corollaries for whoever picks this up:
IScanCacheService, which already has a workingSizeLimit" is not theintended direction, even though it looks like the obvious one. The source of truth stays where it
is.
Step 1 does not have this problem. It reduces bytes per entry while keeping every entry.
Proposed direction
Not yet planned in detail. Two steps, in order — the first is cheap and self-contained, the second
needs design work and carries the risk described above.
Step 1 — store the file's name, rebuild the path during aggregation
Replace the retained full path with the file's name, and reconstruct the full path at aggregation
time from the dictionary key, which already is the owning folder's absolute path (written as
dir.FullNameatDiskScannerEngine.cs:224and:407). Expected saving: −53% retained, with noloss of accuracy, no loss of entries, and no change to the wire contract.
The codebase already performs exactly this reconstruction 40 lines further down the same method:
core/backend/Engine/FileTypeScanner.cs:245Two things this is not:
FileTypeEntryhas no name field today, so one has to be added.after the merge — otherwise the winning file is attributed to the wrong folder. No existing test
would catch that mistake: the multi-directory aggregation tests (
FileTypeScannerTests.cs:98-119and
:347) seed noLargestFilePathat all.Step 2 — bound
DirectorySizeCacheby entry countThe obvious lever, and the dangerous one — see Prior art above. Any eviction policy has to preserve
File Type completeness and the >80% re-scan hit rate at the same time, so it needs its own design
and its own regression test before it can be scoped. Directions worth evaluating, none evaluated
yet: evict whole scan roots rather than individual folders, spill cold entries to disk, or aggregate
leaf folders beyond a depth threshold.
Recommend treating this as unscoped until Step 1 has landed and the remaining footprint has been
re-measured.
Constraints any fix must respect
docs/notion/prd.md:81— "Cache hit on re-scan | > 80% | Subsequent reads served from cache".Bounding the cache trades directly against this. An eviction policy has to be measured against the
hit rate, not only against megabytes.
FR-D3(prd.md:137) "Cache scan results to heavily reduce CPU load on subsequent reads" andNFR-P4(prd.md:356) "Subsequent re-scans must serve cached results where unchanged".docs/notion/techspec-v2.md:2145— "percentOfDiskvalues are accurate relative tototalScannedBytes". File Types and Extension Breakdown accuracy is not tradeable for footprint.CacheEntryis an on-disk format break, and it fails silently. The type isserialized whole to
%APPDATA%/GSAnalyzer/scanner_memory.json(DiskScannerEngine.cs:428-429,path built at
:36-38) and read back at:63. On a shape mismatch the catch atDiskScannerEngine.cs:79-83logs"Cache file corrupted, starting fresh"at Warning level andthen deletes the file. So an unversioned shape change degrades to total silent cache loss on
upgrade — which in turn silently violates the 80% hit-rate target above. Version the file or
migrate it.
CacheEntrychange: 3 production construction sites(
DiskScannerEngine.cs:224,DiskScannerEngine.cs:407,DiskOperationsService.cs:102), plus onedeserialization site with no
newtoken that a grep fornew CacheEntrymisses(
DiskScannerEngine.cs:63), plus 11 test sites across 6 files.core/backend/GSSystemAnalyzer.Tests/Services/FileExtensionBreakdownTest.cs:90, and it assertsExtensionBreakdownItem.LargestFilePath— not the cache field — so with the rebuild in place itpasses unchanged, as do the five Flutter consumers (
extension_breakdown_model.dart:56,extension_breakdown_screen.dart:496,:506,:533,:562, andcsv_exporter.dart:52). Notethat two of those are behavioural rather than cosmetic:
:533copies the path to the clipboard and:562strips it to a parent directory and triggers a rescan.Scope note
The cache itself is required by
FR-D3andNFR-P4. The 130–170 MB idle target is not in thePRD. Searches of
docs/notion/prd.mdformemory|footprint|idle|MB|NFR|non-functional|performanceand of
docs/notion/techspec-v2.mdforworking set|footprint|idle memoryreturn no requirementbounding the application's own memory footprint —
techspec-v2.md:3012-3061covers alerting on themachine's RAM, not the app's. The target originates from the user report behind #141. Worth
resolving before this is scheduled.
Proposed acceptance criteria
drive, and stays there.
DirectorySizeCachesize at 400,000 folders is reduced by at least 50%.completeness fix that made
DirectorySizeCachethe source of truth. This needs an explicittest; nothing today would fail if the number quietly shrank.
prd.md:81(> 80%), measured, not assumed.techspec-v2.md:2145.scanner_memory.jsoneither keeps its current shape or is versioned/migrated — no silentcache deletion on upgrade.
FileTypeScannerTests,AgeHeatMapEngineTest,FileExtensionBreakdownTest,ScanCacheServiceTests.Relationship to #141
#141 fixes the spike: peak working set 2,328 MB → 665 MB and Large Object Heap residue
1,049 MB → 12.5 MB at 400,000 folders, by replacing the write path at
DiskScannerEngine.cs:428-435. That path currently materializes two full copies of the cachebefore touching disk — a complete
Dictionarysnapshot (new Dictionary<string, CacheEntry>(DirectorySizeCache)) and then the entire JSON string — writes thatstring to a temp file, and atomically moves it into place.
#141 does not touch retained memory. This issue is that half.