Skip to content

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

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

fix(tile_layer): dispose the image handed to a tile pruned during dispatch#2239
dinin92-del wants to merge 1 commit 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.

…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>`.
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.

1 participant