Fix(raster): cancel in-flight raster tile requests when source URL changes - issue #7149#7214
Fix(raster): cancel in-flight raster tile requests when source URL changes - issue #7149#7214pcardinal wants to merge 17 commits intomaplibre:mainfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #7214 +/- ##
==========================================
+ Coverage 92.45% 92.73% +0.28%
==========================================
Files 288 288
Lines 24043 24095 +52
Branches 5093 5107 +14
==========================================
+ Hits 22228 22344 +116
+ Misses 1815 1751 -64 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Vector and raster tiles are adressed |
|
Thanks for the time spent on this! |
|
I haven't reviewed the tests yet as I want to make sure the business logic is correct first. |
e3f9e81 to
d2703a8
Compare
src/source/vector_tile_source.ts
Outdated
| } | ||
|
|
||
| // Keep all callers waiting for a reload while this tile is already loading. | ||
| // Resolve/reject cascades so every queued caller settles when the queued reload completes. |
There was a problem hiding this comment.
This is like the 3-5 time I'm reading this and it still isn't clear what's happening here.
I think the comment is explaining it , but I still find this code combersome.
Is there a better way to manage a queue instead of this obscure promise inside promise thing?
It's also might be better to change the name of tile.reloadPromise to something like reloadResolveReject as it's not really promise but something that can resolve or reject a promise, and I think it adds to the confusion (to be fair I think I gave it that "bad" name).
There was a problem hiding this comment.
Thanks for calling this out. I agree the previous implementation was hard to read.
I refactored the reload-while-loading flow to use an explicit queue of waiters instead of chaining resolve/reject callbacks through nested promises. This makes the behavior easier to follow:
- when a tile is already loading, each caller is added to a queued waiters list
- when loading finishes, we run at most one queued reload
- then we resolve or reject all queued callers together
I also renamed the tile field from reloadPromise to queuedReloadWaiters, since it stores resolver pairs rather than an actual Promise.
Behavior is unchanged, but the control flow and naming are now much clearer. I validated this with the targeted unit tests for both:
- single reload while loading
- multiple queued reload callers resolved by one queued worker reload
| } | ||
|
|
||
| async abortTile(tile: Tile): Promise<void> { | ||
| tile.aborted = true; |
There was a problem hiding this comment.
I wonder why aborted is not a state of the tile and instead it's a boolean field, but that is probably outside the scope of this PR...
There was a problem hiding this comment.
aborted is a boolean (not a TileState) because it represents a different axis of information than state:
-
state (state: TileState;) models data/render lifecycle, not request cancellation
It answers: “what data status does this tile currently have?” (loading, loaded, reloading, unloaded, errored, expired) in tile.ts:47. -
aborted (aborted: boolean;) is a transient control flag for in-flight async work
It answers: “should the current async load result be ignored/cancelled?” It is checked right after worker response/error handling in vector_tile_source.ts:248 and vector_tile_source.ts:264, and set in abort path at vector_tile_source.ts:347. -
A tile can keep a meaningful render state while a request is aborted
For example, when reloading an already renderable tile, aborting should not necessarily force a new lifecycle state; it may keep prior usable data and just stop/ignore the current fetch. The code explicitly only forces unloaded in some cases (e.g. when it had no renderable data before), see vector_tile_source.ts:249 and vector_tile_source.ts:265. -
This pattern is used across the manager as request-level metadata
(method) TileManager.abortAllRequests(): voidmarkstile.aborted = trueindependently of lifecycle state in tile_manager.ts:136, which reinforces that cancellation is orthogonal to the main tile state machine.
So in short: state is “what the tile is,” aborted is “what to do with the current request.” Keeping them separate avoids state explosion and preserves correct rendering behavior during cancellations.
| async abortTile(tile: Tile): Promise<void> { | ||
| tile.aborted = true; | ||
| if (!tile.hasData()) { | ||
| tile.state = 'unloaded'; |
There was a problem hiding this comment.
Wouldn't this be a reaction to the below abort call from the abort controller? Is this mandatory to set this?
There was a problem hiding this comment.
Setting tile.state = 'unloaded' here is intentional and complementary to AbortController.abort(). The controller only cancels the in-flight async work; it does not guarantee the tile lifecycle state is updated consistently. Marking non-renderable tiles as unloaded ensures we do not keep a loading/aborted tile in an inconsistent state, while preserving renderable tiles (hasData()) to avoid flicker.
src/tile/tile_manager.test.ts
Outdated
|
|
||
| }); | ||
|
|
||
| test('falls back to cache miss after out-of-view cache reset', () => { |
There was a problem hiding this comment.
I think this is another test not related to this PR, right?
There was a problem hiding this comment.
Removed. This test seems unrelated to the PR scope since it exercises TileManager cache reset behavior on reload, not the abortPendingTileRequests path.
|
|
||
| test('calls abortTile before unloadTile for unfinished tile', () => { | ||
| const tileID = new OverscaledTileID(0, 0, 0, 0, 0); | ||
| const calls: string[] = []; |
There was a problem hiding this comment.
Same as previous comment with the spies.
There was a problem hiding this comment.
which comment ?
src/tile/tile_manager.test.ts
Outdated
| tileManager._paused = false; | ||
| tileManager.transform = new MercatorTransform() as any; | ||
|
|
||
| (tileManager as any)._dataHandler({dataType: 'source', abortPendingTileRequests: true}); |
There was a problem hiding this comment.
Please use public APIs only, this as any is a sign of a bad test.
There was a problem hiding this comment.
I cleaned up the two neighboring unit tests to use public APIs only and removed the private/internal access patterns introduced in this thread.
What was changed:
-
Refactored the test at tile_manager.test.ts:537 to stop calling private handlers and stop using internal state mutation.
-
Refactored the adjacent test at tile_manager.test.ts:571 with the same approach.
-
Replaced direct private calls and state poking with public behavior via source events:
- onAdd(...)
- update(transform)
- getSource().fire(new Event('data', ...))
Validation:
- Targeted test run for “ignores content events after abortPendingTileRequests until metadata arrives” passed.
- Targeted test run for “forwards sourceDataChanged and shouldReloadTileOptions to reload” passed.
Scope note:
I only changed code authored in this conversation, as requested, and left other existing as any usages untouched.
src/tile/tile_manager.test.ts
Outdated
|
|
||
| test('ignores content events after abortPendingTileRequests until metadata arrives', () => { | ||
| const tileManager = createTileManager({}); | ||
| const abortAllSpy = vi.spyOn(tileManager, 'abortAllRequests'); |
There was a problem hiding this comment.
I'm not sure that's the right way to test how this class behaves. It's best to either mock other classes this class uses or check events and other public APIs.
There was a problem hiding this comment.
Thanks for the feedback. I updated the two latest tests to avoid asserting TileManager internals and to focus on observable behavior.
- test('ignores content events after abortPendingTileRequests until metadata arrives'
- test('forwards sourceDataChanged and shouldReloadTileOptions to reload
What changed:
-
Removed spies on TileManager methods (abortAllRequests, reload, update).
-
Kept event-driven setup through public source data events.
-
Asserted behavior through public/collaborator effects instead:
- loaded() transitions after abortPendingTileRequests
- reload behavior inferred from Source.loadTile call count changes
- forwarding of shouldReloadTileOptions verified via Source.shouldReloadTile arguments
I reran the two updated tests, and both pass.
| expect(updateSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| test('forwards sourceDataChanged and shouldReloadTileOptions to reload', () => { |
There was a problem hiding this comment.
See my previous comment, this test mocks too much of the class under test.
There was a problem hiding this comment.
see previous comments R568 and R576
src/tile/tile_manager.test.ts
Outdated
| const nonLoadingTile = tileManager.addTile(new OverscaledTileID(1, 0, 1, 1, 1)); | ||
| nonLoadingTile.state = 'loaded'; | ||
|
|
||
| const abortTileSpy = vi.spyOn(tileManager._source, 'abortTile'); |
There was a problem hiding this comment.
I would feel better to simply check the state of the tiles and avoid making sure some internal methods are called, as those can change in the future.
There was a problem hiding this comment.
Done! I've refactored both tests to remove the internal spy assertions and focus only on observable behavior—the tile states.
The tests now:
- First test: Directly assert that loadingTile.aborted is true and nonLoadingTile.aborted is not—checking only the public state changes
- Second test: Assert that tile.aborted is true after calling abortAllRequests()—verifying the end result without coupling to internal method calls
This approach follows the project guidelines : "Prefer assertions on public behavior (emitted events, public state, return values) over private/internal method calls." The tests are now more resilient to future implementation refactors.
src/tile/tile_manager.test.ts
Outdated
| tile.state = 'loaded'; | ||
| tile.abortController = {abort: vi.fn()} as unknown as AbortController; | ||
|
|
||
| const abortTileSpy = vi.spyOn(tileManager._source, 'abortTile'); |
There was a problem hiding this comment.
If the outcome is that the tile is aborted by calling abortAllRequests I think that's a good enough test, no need to drill down to the exact method that was called.
There was a problem hiding this comment.
see previous comment "Comment on line R2711"
Launch Checklist
CHANGELOG.mdunder the## mainsection.Summary
Root cause
When a raster source URL was updated, the source reload path did not consistently cancel all active requests tied to the previous URL/state.
Changes included
Tests added/updated
How to validate locally
npm run test-unit -- src/source/raster_tile_source.test.ts
npm run test-unit -- src/tile/tile_manager.test.ts
npm run test-unit -- src/tile/tile_cache.test.ts
Impact
Issue
Fixes #7149