Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix resource leak on Safari on map.remove() #12224

Merged
merged 3 commits into from
Sep 8, 2022

Conversation

joewoodhouse
Copy link
Contributor

@joewoodhouse joewoodhouse commented Sep 7, 2022

…ners and dereference canvas and other containers to prevent resource leak on Safari

Brief Description

I have been investigating an issue where our mobile application - which uses mapbox-gl-js - was crashing after repeatedly opening and closing a page containing a map.

In examining heap snapshots I noticed that event listeners and Map instances were being leaked.

The final line of the changes I believe is a resource leak on every platform - the container element is an existing element in the DOM passed to us by the library user (either by ID or as a direct HTMLElement) which the existing code then attaches an eventListener to for 'scroll' but never removes it. This results in the browser not being able to reclaim the resources for the mapboxgl.Map instance even after the map has been removed by calling map.remove().

The changes above that relate more specifically to iOS Safari where I was again seeing instances of mapboxgl.Map being leaked, and on inspection of the heapmap the retaining element was these contextLost/contextRestored event listeners. In theory this should not be required I think, but I know that iOS Safari has a lot of well-known issues around performance and memory particular in relation to canvas.

Testing/Reproduction

I have created a simple test site to show the problem.

To Recreate the issue use: https://joyful-granita-e529dc.netlify.app/existing.html. This recreates the problem using the latest version of mapbox-gl-js (2.10.0). The test page simply loads and then unloads a map in a continual loop until you hit Stop.

  • Run in Safari
  • Input a valid mapbox token into the token input at the top
  • Hit Start
  • Let it load and unload a map for 20+ iterations
  • Hit Stop
  • Open Web Inspector > Timelines > Javascript Allocations
  • Take a heap snapshot (camera icon)
  • Inspect the snapshot

On inspecting the snapshot you will see a large retained size, and inspecting the heap you will see all the leaked Map instances are still present. Safari devtool helpfully shows that _onMapScroll is the retaining reference here.

Screenshot 2022-09-07 at 17 02 16

To view the fixed version, repeat the above steps using this link: https://joyful-granita-e529dc.netlify.app/fixed.html

This is the same reproduction but pointing out a custom build of mapbox-gl-js that contains the fixes attached to this PR. If you repeat the steps above, on inspection of the heap snapshot you will see there are no leaked Map instances.

Screenshot 2022-09-07 at 17 06 06

The above can also be repeated in Chrome using the Performance Monitor to view JS Heap size. On the "existing" version the heap will increase indefinitely and manually triggering a GC will not bring memory down. On the "fixed" version heap size will remain stable.

Launch Checklist

  • briefly describe the changes in this PR
  • include before/after visuals or gifs if this PR includes visual changes
  • write tests for all new functionality
  • document any changes to public APIs
  • post benchmark scores
  • manually test the debug page
  • tagged @mapbox/map-design-team @mapbox/static-apis if this PR includes style spec API or visual changes
  • tagged @mapbox/gl-native if this PR includes shader changes or needs a native port
  • apply changelog label ('bug', 'feature', 'docs', etc) or use the label 'skip changelog'
  • add an entry inside this element for inclusion in the mapbox-gl-js changelog: <changelog>Fix memory leak when removing maps</changelog>

…ners and dereference canvas and other containers to prevent resource leak on Safari
@CLAassistant
Copy link

CLAassistant commented Sep 7, 2022

CLA assistant check
All committers have signed the CLA.

Copy link
Member

@mourner mourner left a comment

Choose a reason for hiding this comment

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

This looks great overall, thanks so much for investigating this and submitting a fix!

Aside from the inline comment, I'm also wondering if there's any way to write a unit test for this to make sure we don't accidentally reintroduce a similar leak? E.g. write a test that stubs _container.addEventListener and _container.addEventListener and then makes sure every call the former also corresponds to the latter after map.remove().

this._canvas = (undefined: any);
this._canvasContainer = (undefined: any);
this._controlContainer = (undefined: any);
this._missingCSSCanary = (undefined: any);
Copy link
Member

Choose a reason for hiding this comment

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

Is it important to remove the DOM element references here if the map object itself is discarded? And if so, should we remove more references, in particular those retained transitively — e.g. map.dragPan._el, or map._logoControl._container etc.?

Copy link
Contributor Author

@joewoodhouse joewoodhouse Sep 8, 2022

Choose a reason for hiding this comment

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

My thought was that suppose the map object is not discared. For example if you do

const container = document.createElement('div');
const map = new mapboxgl.Map({
   container,
   ...
});
....
map.remove();

After this code, without removing the DOM references then the browser is not able to discard those elements - if you look in Chrome dev tools heap map you'll end up with a bunch of "Discarded HTML*" elements, because the elements have been removed from the DOM but the retaining reference means they can't be garbage collected.

In terms of the other references, for example map._logoControl I think is OK because that control is properly removed and the ._controls array is emptied in the map.remove() - so the DOM elements are removed and the controls are discarded.

The dragPan I'm not too sure about, I can't see elements/resources being leaked after my fix so I assume those parts are fine.

Copy link
Member

Choose a reason for hiding this comment

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

map._logoControl reference is retained after map.remove() even if _controls array is emptied. You can see references to the canvas container element in the handler objects too. So I'm just wondering why some of those retained references leak (like those explicitly cleared in this PR) and some don't — maybe that's worth investigating.

@mourner mourner changed the title Unbind scroll event listener from the map container, and unbind liste… Fix resource leak on Safari on map.remove() Sep 8, 2022
@mourner mourner self-assigned this Sep 8, 2022
…e added as removed from the map container element
@joewoodhouse joewoodhouse requested a review from a team as a code owner September 8, 2022 12:34
@joewoodhouse
Copy link
Contributor Author

joewoodhouse commented Sep 8, 2022

This looks great overall, thanks so much for investigating this and submitting a fix!

Aside from the inline comment, I'm also wondering if there's any way to write a unit test for this to make sure we don't accidentally reintroduce a similar leak? E.g. write a test that stubs _container.addEventListener and _container.addEventListener and then makes sure every call the former also corresponds to the latter after map.remove().

@mourner I've added this as a unit test now.

Copy link
Member

@mourner mourner left a comment

Choose a reason for hiding this comment

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

Overall this is a clear improvement so we should merge it, but we might want to follow up to resolve the questions brought up above later. Thanks @joewoodhouse!

@mourner mourner merged commit a4339ab into mapbox:main Sep 8, 2022
1derful pushed a commit to 1derful/mapbox-gl-js that referenced this pull request Sep 12, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants