Skip to content

Bound MapTiles tile accumulation with deferred LRU eviction — fix zoom/pan OOM (#98)#115

Merged
rolker merged 8 commits into
jazzyfrom
feature/issue-98
Jun 22, 2026
Merged

Bound MapTiles tile accumulation with deferred LRU eviction — fix zoom/pan OOM (#98)#115
rolker merged 8 commits into
jazzyfrom
feature/issue-98

Conversation

@rolker

@rolker rolker commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Closes #98

Summary

CAMP silently died (OOM-kill) mid-survey while the operator zoomed/panned the map (recurring across 06-15/16/17). Corrected root cause (the issue's "#96 GDAL leak" premise was refuted during investigation): MapTiles::paint() creates a new Tile per newly-visible tile and only setVisible(false) for off-screen ones — the only deletion was in setLayout(). The radar overlay self-bounds (5-min refresh → setLayout), but the OSM/WMTS basemap has no refresh, so tiles_ grew unbounded across a pan/zoom session → RSS climbed → OOM-kill (silent, no stacktrace). (RasterLayer has no viewport handler, so zoom/pan never re-invokes the #96 path — #96 was a real but separate leak, fixed in #114.)

Fix — deferred LRU eviction bounds tiles_

  • Cap = max(256, 4×visible) (kTileEvictionMinCap=256, kTileEvictionMultiplier=4). OSM tiles decode to ~256×256 ARGB ≈ 256 KB, so the 256 floor ≈ ~64 MB tile buffer — documented at the definition site.
  • Deferred, never synchronous (deleting a QGraphicsObject scene child inside paint() is a use-after-free risk). paint() records a per-tile last-visible generation and, when over cap, queues evictIfNeeded() via Qt::QueuedConnection (debounced by an eviction_pending_ flag so only one is queued).
  • evictIfNeeded() runs in the event loop: deletes oldest-by-generation non-visible tiles (guarded on isVisible() at eviction time, so a currently-visible tile is never evicted) until tiles_.size() ≤ cap. cap ≥ visible_count guarantees enough candidates.
  • setLayout() clears the generation map; paint_generation_ stays monotonic.

Test

test_map_tiles_eviction.cpp — a multi-zoom layout (1×1 front + 20×20 deep; single-zoom wouldn't reproduce the bug since setLayout() pre-seeds the front grid). Pans a 1-tile viewport across all 400 deep positions with processEvents() after each paint (eviction is deferred), asserting tileChildCount() stays ≤ cap throughout. Non-vacuity verified empirically: with eviction stubbed out, the sweep drives tiles_ to 401 and the test fails (401 vs 256). A second test confirms the deep tiles are actually minted by paint().

105 tests, 0 failures (2 pre-existing GL-gated skips). Deep review-code, 0 must-fix.

Consequences

Updated the now-stale onRefreshTimer comment ("paint() only hides, never deletes") and the test_map_tiles_refresh.cpp scope comment to point at the new eviction coverage.


Authored-By: Claude Code Agent
Model: Claude Opus 4.8

Claude Code Agent and others added 8 commits June 22, 2026 04:56
LRU eviction cap in MapTiles::paint() to bound tiles_ growth on OSM/WMTS basemap pan
MapTiles::paint() minted a Tile per newly-visible address and only hid
off-screen tiles; the sole deletion path was setLayout(), reached only by
the radar overlay's ~5-min refresh. The OSM/WMTS basemap never refreshes,
so tiles_ grew for every tile area visited during a pan/zoom session until
the process was OOM-killed — the silent crash reported in #98.

Cap tiles_ at max(256, 4 * visible) and evict the least-recently-visible
off-screen tiles. Eviction is DEFERRED off paint(): deleting a Tile (a
QGraphicsObject scene child) mid-paint is a use-after-free risk, so paint()
only records a per-tile last-visible generation and, when over the cap,
queues evictIfNeeded() onto the event loop (debounced via eviction_pending_).
evictIfNeeded() recomputes the cap from the live visible count, guards on
tile->isVisible() so a re-shown tile is never evicted, and deletes oldest
last-visible-generation first until tiles_ <= cap. setLayout() clears the
bookkeeping; paint_generation_ stays monotonic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add test_map_tiles_eviction.cpp: a multi-zoom-level layout (1x1 front +
20x20 deep) so paint() mints deep tiles on demand — a single-zoom layout
would not, since setLayout() pre-seeds the whole front grid at construction.
paint() is driven directly with a hand-built world transform (uniform scale
with a web-mercator y-flip) chosen so every index lands on exact integers;
a one-tile viewport is panned across the whole grid and processEvents() is
called after each paint so the deferred evictIfNeeded() runs. Asserts
tileChildCount() stays <= the 256 cap. A second test confirms the deep
tiles really are minted by paint(). Verified non-vacuous: with eviction
stubbed out, tiles_ reaches 401 and the bound assertion fails.

Register the test in CMakeLists.txt mirroring test_map_tiles_refresh, update
that test's SCOPE comment to point at this new within-cycle coverage, and
sync plan.md to the as-built deferred-eviction design and resolved questions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 22, 2026 05:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@rolker rolker merged commit 4390a6a into jazzy Jun 22, 2026
1 check failed
@rolker rolker deleted the feature/issue-98 branch June 22, 2026 05:53
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.

CAMP silently crashes on map zoom/pan during live ops (recurring) — likely OOM, prime suspect #96 GDAL leak

2 participants