Skip to content

fix: dispose the image handed to a tile pruned during dispatch - #2239

Open
dinin92-del wants to merge 5 commits into
fleaflet:masterfrom
dinin92-del:fix/dispose-image-of-tile-pruned-during-dispatch
Open

dinin92-del wants to merge 5 commits into
fleaflet:masterfrom
dinin92-del:fix/dispose-image-of-tile-pruned-during-dispatch

Conversation

@dinin92-del

Copy link
Copy Markdown

TileImage._onImageLoadSuccess stores the ImageInfo even when the tile is already disposed. ImageStreamCompleter.setImage gives every listener its own handle and the listener owns it: a displayed tile passes ownership to RenderImage, which disposes it — but a disposed tile never builds one, so the decoded image stays alive for the lifetime of the process.

Why dispose() removing the listener isn't enough

This is not reachable by simply disposing a tile and then completing its image — dispose() removes the listener and setImage early-returns on an empty listener list.

It is reachable because setImage dispatches over a copy of the listener list:

// Make a copy to allow for concurrent modification.
final localListeners = List<ImageStreamListener>.of(_listeners);
for (final listener in localListeners) {
  listener.onImage(image.clone(), false);
}

A listener removed from inside that loop is still called. Two tiles resolving equal keys share one completer, and onLoadComplete is where tiles get pruned — as TileImageManager.reloadImages already notes:

If a TileImage's imageInfo is already available when load() is called it will call its onLoadComplete callback synchronously which can trigger pruning.

So the first tile's completion can dispose the second tile mid-dispatch, and the second tile is handed an image anyway.

Impact

Unnoticeable with 256×256 tiles (256 KB). With 768×768 RGBA tiles (2.25 MB each) it killed an app on iOS while browsing the map — the process was terminated at its memory limit after a few minutes of panning.

Test

Adds test/layer/tile_layer/tile_image_test.dart, which reproduces the dispatch race with two tiles sharing one completer and asserts both that the disposed tile keeps no ImageInfo and that the handle it was given is released (debugGetOpenHandleStackTraces).

On the current code it fails with:

Expected: null
  Actual: ImageInfo:<[8x8] @ 1.0x>

Full suite passes with the change (118/118).


AI usage disclosure (per CONTRIBUTING): this patch and its test were written with AI assistance (Claude). I reviewed the change and the reasoning, ran the test suite, and verified that the added test fails on unpatched master and passes with the fix. I take responsibility for the code.

jozek added 4 commits August 5, 2026 12:02
…patch

`TileImage._onImageLoadSuccess` stored the `ImageInfo` even when the tile was
already disposed. `ImageStreamCompleter.setImage` gives every listener its own
handle and the listener owns it: a displayed tile passes ownership to
`RenderImage`, which disposes it, but a disposed tile never builds one — so the
decoded image stays alive for the lifetime of the process.

`dispose()` does remove the listener, which is why this is not reachable by
simply disposing a tile and then completing its image. It is reachable because
`setImage` dispatches over a copy of the listener list ("Make a copy to allow
for concurrent modification"): a listener removed from inside that loop is
still called. Two tiles resolving equal keys share one completer, and
`onLoadComplete` is where tiles get pruned — as already noted in
`TileImageManager.reloadImages` — so a tile can be disposed mid-dispatch and
handed an image regardless.

Unnoticeable with 256x256 tiles (256 KB). With 768x768 RGBA tiles (2.25 MB
each) it killed an app on iOS while browsing the map.

Adds a regression test that reproduces the dispatch race; it fails on the
current code with `Expected: null / Actual: ImageInfo:<[8x8] @ 1.0x>`.
Ownership of a tile's decoded image transfers at BUILD time: `RawImage` passes
the raw `ui.Image` to `RenderImage`, which disposes it when it is replaced or
unmounted. That is why `TileImage.dispose()` never freed `imageInfo` — by then
the render object owns it.

The model assumes ONE frame per tile. That holds for static tiles, but a
completer emitting a second frame (progressive tiles: a base frame, then a
composed one) overwrites `imageInfo` before any build has to happen. When both
frames land within the same frame budget — the common case on a fast device —
the first handle never reaches a `RenderImage` and nothing frees it.

Measured on an iPhone with 768x768 composed tiles: ~0.9 leaked handles per
tile, growing linearly with tiles browsed, and invisible to `ImageCache`, which
reported ~25 live images while 3336 `ui.Image` objects were alive. Two app
kills within four minutes.

The tile now tracks whether its handle was passed on (`Tile` marks it while
building `RawImage`) and frees it only while it is still its own — on frame
replacement and on dispose. Freeing a handle the render object owns would be a
double free, so the flag is what makes this safe rather than lucky.

Two tests, both counting real open handles rather than asserting on code:
one for the leaking path, one for the mirror case where the widget already
took the handle and the tile must keep its hands off.
…ever takes over

The flag-based fix (823e8a8) rested on a false premise. It assumed ownership
of the decoded image transfers to `RenderImage` at build time, so it freed the
handle only when NO build had taken it. Verified against Flutter sources:
`RawImage` CLONES the image for its render object — both `createRenderObject`
and `updateRenderObject` pass `image?.clone()`. The render object frees its
own clone; the tile's handle never stops being the tile's.

Consequence: the flag exempted exactly the PAINTED frames, so every painted
frame still leaked one handle. Measured on device after 823e8a8: ~0.3 leaked
handles per tile — matching the fraction of tiles that emit two frames —
linear, no plateau, invisible to ImageCache. GC finalizers reclaim such
handles eventually, which is why 256 KB default tiles get away with the
upstream model and 2.25 MB tiles do not.

The correct model is simpler than the flag: the tile frees its own handle,
always — on frame replacement and on dispose. Dispose also nulls the field,
because dispose can race the layer rebuild and a straggler build must see
null (paint nothing) rather than clone a disposed image.

Caught by a new full-cycle test (TileImage → Tile → RawImage → RenderImage)
counting open handles after complete teardown — the pure-TileImage tests
could not see it, because the bug lived in what the widget integration does
NOT do with the handle. Sensitivity: reverting both files to 823e8a8 fails
all three cases of that test.

⚠️ Test hygiene that mattered: `createTestImage` caches by size and returns
clones of ONE shared image, making two "independent" handle counters move in
lockstep. `cache: false` + distinct sizes are load-bearing in these tests.
…ed once

Simplify pass over the leak fix, four angles (reuse / simplification /
efficiency / altitude), behavior untouched — the sensitivity check (both lib
files reverted to the flag model) still fails all three full-cycle cases.

- test_utils/test_frame_driver.dart: ONE DrivenCompleter/DrivenProvider pair
  and one testTileImage factory replace three byte-identical class pairs and
  two TileImage factories spread over two files. A constructor change now
  touches one place.
- _SharedManualImageProvider deleted: OneFrame-wrapping a Completer was a
  strict subset of the driven provider.
- The ownership doctrine lives ONCE, on the imageInfo field doc; the
  replacement and dispose sites state the rule in one line and point there.
  The story already changed once (the flag model) and needed every copy
  edited — that class of drift is what this fork patch exists to kill.
- The _disposed branch comment no longer speaks the dead model's language
  ("never hands its image to a RenderImage") — it argues from ownership:
  after dispose() the owner that would free the handle is gone.
- Whitespace orphans of the removed flag mechanism dropped (tile.dart is now
  untouched by this branch, as it should be — the fix lives entirely in the
  owner).
@JaffaKetchup JaffaKetchup changed the title fix(tile_layer): dispose the image handed to a tile pruned during dispatch fix: dispose the image handed to a tile pruned during dispatch Aug 27, 2026
@JaffaKetchup

Copy link
Copy Markdown
Member

Thanks for the contribution! I'll try to take a look at this soon.

@JaffaKetchup
JaffaKetchup requested a review from a team August 27, 2026 21:51
@JaffaKetchup

JaffaKetchup commented Sep 1, 2026

Copy link
Copy Markdown
Member

Hey, could you please open a bug report and move the bug report from the PR description into it, and create an MRE so I can manually confirm that this fix fixes the memory leak? Thanks!

bandy87 added a commit to salonichu/flutter_map that referenced this pull request Sep 14, 2026
Fixes two issues on top of v8.3.2. Both were reproduced on a physical
device (Samsung SM S938B, Android 16, release build) with rapid pinch
in/out zooming, and both fixes are verified by measurement.

1. TileImage never released its decoded image
   (lib/src/layer/tile_layer/tile_image.dart)

   `ImageStreamCompleter.setImage` hands every listener its own
   `image.clone()`, and `RawImage` clones again for its `RenderImage`.
   Handing the image to the widget tree therefore never transfers
   ownership: the handle belongs to the TileImage. It was released
   neither on frame replacement nor in `dispose()`, so every tile that
   was loaded and later pruned leaked its decoded bytes (~4 MiB for a
   512px tile at @2x) for the lifetime of the process.

   Three cases are handled: an image delivered to an already-disposed
   tile is released in place (`setImage` iterates over a copy of the
   listener list, so a listener removed in `dispose()` can still receive
   a frame), the previous frame is released on replacement, and the
   handle is released in `dispose()`.

   Measured on the device, during continuous rapid zooming:
   Private Other memory peaked at 6025 MB before the fix and the app was
   killed after ~50 s; after the fix it stays at 43-65 MB and the app is
   stable for 150 s with no growth trend.

   See fleaflet#2239

2. NaN propagating from the pinch scale into the camera
   (lib/src/gestures/map_interactive_viewer.dart,
    lib/src/map/controller/map_controller_impl.dart)

   Lifting one finger mid-gesture while zoomed in makes `_scaleCorrector`
   drive the corrected scale to zero or below. `math.log()` then returns
   NaN, which `clamp()` does not filter out, and the tile layer throws on
   every frame. The pinch guard now checks the corrected scale, and
   `moveRaw()` rejects non-finite centre and zoom values as defence in
   depth.

   See fleaflet#2244
@bandy87

bandy87 commented Sep 14, 2026

Copy link
Copy Markdown

Opened #2265 with a full reproduction and before/after measurements on a physical device (Samsung SM S938B, Android 16, release build), as requested.

I can confirm this PR's fix works: with these changes applied on top of v8.3.2, memory stays flat through 130+ seconds of continuous rapid pinch zooming (Private Other 34-77 MB), where the unpatched build was killed by the OS after ~50 seconds.

The issue also lists five hypotheses I ruled out by measurement before arriving here (smaller tiles, disabling the built-in disk cache, forcing a layer reset, upgrading to 8.3.2, and reducing tile creation), in case that saves anyone repeating them.

One note on CI: the only red check is "Analyse Code", which fails at dart format --set-exit-if-changed — the test jobs pass on both Flutter channels.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants