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 memory leaks and failed cleanups in destroyables Mutation Observer #17687

Merged
merged 6 commits into from
Dec 14, 2024

Conversation

Codas
Copy link
Contributor

@Codas Codas commented Dec 11, 2024

I really do like the Idea of using Mutation Observer for the otherwise errorprone task of cleanup up the tooltipster, Sortable and Tagify instances. I wish I had thought of that when authoring my PR.

However, I have identified some issues with the current implementation that this PR aims to address:

  1. Tooltipster cleanup timing
    Tooltipster instances normally to be destroyed before their associated DOM elements are removed. Tooltipster relies on jQuery’s .data attribute to store references to its instances, and these attributes are cleared when elements are removed via jQuery. If the destroy method is called after this happens, Tooltipster logs warnings or even throws errors.
    Additionally, the instance itself is not automatically destroyed, and it retains a reference to the original HTML element. This alone isn’t enough to cause a memory leak, but Tooltipster's internal setInterval that closes over the tooltipster instance creates one.

  2. Handling removed nodes in MutationObserver
    The removedNodes property in the MutationObserver callback does not include the node it was registered for (the target). While the current implementation addresses this by observing a parent element (the closest .window-content element), the mutation handler mistakenly tries to use removed nodes as keys to access destroyable items. This approach will never succeed since the observed target is not included in removedNodes.

  3. Memory Leaks from unmanaged MutationObservers
    MutationObservers themselves can lead to memory leaks if not explicitly disconnect()ed. They will continue observing their target nodes even after those nodes are removed from the DOM. An explicit call to disconnect is necessary to release connected nodes and prevent memory leaks.

Proposed Fixes

This PR resolves these issues by:

  • Ensuring Tooltipster instances are properly destroyed. That part uses some internal details like $(el).data('tooltipster-ns') to access the tooltipster instance, but is unavoidable.
  • Correctly managing the mapping and cleanup of destroyable objects.
  • Explicitly disconnecting MutationObservers to avoid residual references.

I have verified that after these changes, no memory leaks occur in NPC sheets, item/feat sheets, and character sheets. However, IWR tooltips in the chat log still exhibit memory leaks. This is because chat messages and their tooltips are rendered before being inserted into the popped-out chat log application, complicating the setup of MutationObservers. Addressing this specific issue will require a separate investigation.

@stwlam
Copy link
Collaborator

stwlam commented Dec 12, 2024

Do you have any documentation showing 3 is true? Everything I could find states they're cleaned up once the observation target is garbage-collected.

@stwlam
Copy link
Collaborator

stwlam commented Dec 12, 2024

I'm also not clear on what is meant by 2: using the observed nodes as keys was not a mistake.

@Codas Codas force-pushed the fix-observer-memory-leaks branch from 07fc81f to 764a045 Compare December 12, 2024 09:12
@Codas
Copy link
Contributor Author

Codas commented Dec 12, 2024

Do you have any documentation showing 3 is true? Everything I could find states they're cleaned up once the observation target is garbage-collected.

No real documentation, just a closed (because duplicate) issue on the whatwg github whatwg/dom#484 that then links to the issue of being able to disconnect single targets instead of all or nothing: whatwg/dom#126

There are no details or measurements available in these issues unfortunately.

This prompted me to do my own testing though, and from my testing in chrome at least, MutationObserver does indeed keep a strong reference to the html element it is observing.
This is the memory view of a profiling run with the code of this current PR:
Bildschirmfoto 2024-12-12 um 09 13 48

And this with the same code base, but just the following part removed:

context.observer.disconnect();
Bildschirmfoto 2024-12-12 um 09 11 28

The methodology is the same as in my previous PR: opening, then closing a sheet (character sheet in this example), then running manual garbage collection, repeating a few times.

@Codas
Copy link
Contributor Author

Codas commented Dec 12, 2024

I'm also not clear on what is meant by 2: using the observed nodes as keys was not a mistake.

The observed nodes will never appear in the removedNodes list. I will try to explain this as best I can with an example.

Example HTML structure: NPC sheet

Consider the following simplified HTML structure for an NPC sheet:

<body>
  <div class="window window-app npc">
    <section class="window-content">
      <form>
        <tagify-tags></tagify-tags>
      </form>
    </section>
  </div>
</body>

In this example, suppose the tagify-tags element is used as the destroyable parameter when calling the observe method. The contentEl variable will point to the <section class="window-content"> element.

For simplicity, let's assume this is the first and only observe call for this character sheet.

Adding to the #destroyables map

The following code (taken from the current implementation) adds the tagify destroyable to the #destroyables map with .window-content as the key (stored as an element, not a string):

const destroyables = this.#destroyables.get(contentEl) ?? [];
destroyables.push(destroyable);
this.#destroyables.set(contentEl, destroyables);

After this, the #destroyables map looks like this:

{ .window-content -> [tagify] }

Observers in use

The system employs two observers:

  1. Content-specific mutation observers:
observer.observe(contentEl, DestroyableManager.#OBSERVE_OPTIONS);

This observes the .window-content node and ignores subtree changes, only monitoring direct child changes.

  1. Global Observer: Registered in the DestroyablesManager constructor to watch for app root node removal (e.g., when the app is closed):
constructor() {
  this.#bodyObserver = new MutationObserver(this.#onMutate.bind(this));
  this.#bodyObserver.observe(document.body, DestroyableManager.#OBSERVE_OPTIONS);
}

Why cleanup fails

Despite the setup, cleanup fails in both relevant scenarios. Given the following slightly simplified cleanup function:

#onMutate() {
  for (const mutation of mutations) {
    for (const element of mutation.removedNodes) {
      for (const destroyable of this.#destroyables.get(element) ?? []) {
        destroyable.destroy();
      }
    }
  }
}

here is why the cleanup fails:

  • Application is closed

    • The global mutation observer detects the .window.window-app.npc node removal.
    • In the #onMutate function, the element variable points to .window.window-app.npc. The #destroyables.get() call returns undefined because the map key is .window-content, not .window.window-app.npc.
  • Application content is replaced

    • The .window-content observer detects changes when the form element is removed.
    • The element variable in #onMutate points to the form node. The #destroyables.get('form') call returns undefined because form is not a key in the map.
  • Tooltipster Elements are registered

    • For tooltipster elements, contentEl is set to the element tooltipster is registered for, not the closest .window window-app node.
    • This causes the cleanup to fail for similar reasons: the keys in #destroyables do not match the values in the removedNodes, as they will never contain the target - the tooltipster element - itself.

The previous approach of just destroying every destroyable for an
application whenever the content was replaced had potential issues
for cases when only a subset of the DOM was being replaced, as
is potentially planned for the kingdom and/or party sheet
@Codas Codas force-pushed the fix-observer-memory-leaks branch from 101ecfa to 7d772cd Compare December 12, 2024 10:12
@stwlam
Copy link
Collaborator

stwlam commented Dec 12, 2024

I think the fix for the body observer is to simply set the keys to the application frame rather than the content-window element.

@Codas
Copy link
Contributor Author

Codas commented Dec 12, 2024

That should work, yes.
But to still be able to fix the other issues, we would probably need two separate mutation observer handler functions, one for the global body observer and one for the local application content.

@Codas
Copy link
Contributor Author

Codas commented Dec 12, 2024

This should be better now, using only a single observer to watch for changes of the document.body childList.

@stwlam
Copy link
Collaborator

stwlam commented Dec 13, 2024

I think you can drop the TooltipsterTarget class entirely now as well.

@Codas
Copy link
Contributor Author

Codas commented Dec 13, 2024

I think you can drop the TooltipsterTarget class entirely now as well.

I'm not sure about that. I currently check for the inclusion of the html node linked to the destroyable instance in the removed html element. As far as I could tell, the plain Tooltipster instance value keeps no accessible reference to the html node, which makes the class still necessary.

We could as of now probably live without the contains check and just destroy every destroyable in the scope of the application that is being re-rendered, but I'm not sure I like that honestly. The contains check is reasonably cheap, only runs in very specific circumstances and gives some meassure of future proofing against partial html updates. (only thing that would need to change as of now to support this is also watching the subtree in the content mutation observer)

@stwlam
Copy link
Collaborator

stwlam commented Dec 13, 2024

I'm not sure about that. I currently check for the inclusion of the html node linked to the destroyable instance in the removed html element. As far as I could tell, the plain Tooltipster instance value keeps no accessible reference to the html node, which makes the class still necessary.

Try checking the return of the tooltipster instance's elementOrigin method.

@Codas
Copy link
Contributor Author

Codas commented Dec 13, 2024

Your are absolutely right, don't know why I missed that. Probably only looked at properties, not functions...

src/util/destroyables.ts Outdated Show resolved Hide resolved
src/util/destroyables.ts Outdated Show resolved Hide resolved
src/util/destroyables.ts Outdated Show resolved Hide resolved
@Codas
Copy link
Contributor Author

Codas commented Dec 13, 2024

The ad-hoc tooltipster instance type can also be removed as this is already available as JQueryTooltipster.ITooltipsterInstance.

@stwlam stwlam merged commit ecba01e into foundryvtt:master Dec 14, 2024
1 check passed
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.

2 participants