Skip to content

addEventListener with AbortSignal option can cause memory leaks #54739

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

Closed
jblas opened this issue Mar 6, 2024 · 2 comments
Closed

addEventListener with AbortSignal option can cause memory leaks #54739

jblas opened this issue Mar 6, 2024 · 2 comments
Assignees
Labels
area: zones Issues related to zone.js regression Indicates than the issue relates to something that worked in a previous version
Milestone

Comments

@jblas
Copy link

jblas commented Mar 6, 2024

Which @angular/* package(s) are the source of the bug?

zone.js

Is this a regression?

Yes

Description

Our application is using zone.js@0.14.3 outside of Angular, as a dependency of OpenTelemetry.

In our application we noticed memory leaks involving large subtrees of detached HTMLElements after updating to zone.js@0.14.3. Version 0.14.2 does not have an issue.

The issue seems to be related to the fact that zone.js registers an "abort" handler on the AbortSignal object passed in to the addEventListener() call and the callback it is registering captures the task in its scope. This handler is as far as I can tell never removed off the AbortSignal object.

https://github.com/angular/angular/blob/zone.js-0.14.3/packages/zone.js/lib/common/events.ts#L488

The big issue I see with the current implementation is that zone.js has no clue what the lifetime and ownership model is for the AbortSignal object in question or the element that is captured in the task within the callback that is never removed.

In our particular case the detached HTMLElement captured within the task is part of a larger subtree that gets removed/detached. The signal itself is owned by some code associated with an ancestor element that out-lives the subtree that is removed, so as long as that listener callback is alive, so is the task, the element it captures, and any other elements in the detached subtree the element is still referencing via parentNode, next/prevSibling, etc.

Also, we can't assume that this "abort" listener will be removed via a corresponding element's removeEventListener() call because there may never be a removeEventListener() call made ... and in most circumstances when a subtree of elements is detached, this would be ok and things would garbage collect just fine.

The issue can be observed with this simple test case:

addeventlistener-signal-bug.txt

If you load the test case into Chrome:

  • Press the remove button in the test case
  • Open the Web Inspector Memory Panel
  • Hit the Garbage Collect button
  • Trigger a Heap Snapshot

In the resulting Heap Snapshot you will notice that there are detached HTMLDiv, HTMLButton, HTMLUL, and HTMLLI elements listed.

If you remove the include of zone.js from the test case, and then repeat the steps above, you will see that the elements mentioned above are no longer listed in the Heap Snapshot, meaning they were garbage collected properly.

Please provide a link to a minimal reproduction of the bug

No response

Please provide the exception or error you saw

No response

Please provide the environment you discovered this bug in (run ng version)

No response

Anything else?

No response

@atscott atscott added the area: zones Issues related to zone.js label Mar 7, 2024
@ngbot ngbot bot added this to the needsTriage milestone Mar 7, 2024
@atscott atscott added the regression Indicates than the issue relates to something that worked in a previous version label Mar 7, 2024
@jblas
Copy link
Author

jblas commented Mar 7, 2024

Until a better solution is found,
one possible "workaround" to break the retainer cycle would be to use a callback that captures a WeakRef to the task instead of the task itself and then when it's triggered, call cancel() only if the task hasn't already been GC'd.

Unfortunately, that workaround could still leave "dead" listeners on the signal object.

arturovt added a commit to arturovt/angular that referenced this issue Apr 14, 2024
…s removed

This commit updates the implementation of the `addEventListener` patcher.

We're currently creating an abort event listener on the signal (when it's provided)
and never remove it. The abort event listener creates a closure which captures `task`
(tasks capture zones and other stuff too) and prevent `task`, zones and signals from
being garbage collected.

We now store the function which removes the abort event listener when the actual event
listener is being removed. The function is stored on task data since task data is already
being used to store different types of information that's necessary to be shared between
`addEventListener` and `removeEventListener`.

Closes angular#54739
arturovt added a commit to arturovt/angular that referenced this issue May 2, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
…s removed

This commit updates the implementation of the `addEventListener` patcher.

We're currently creating an abort event listener on the signal (when it's provided)
and never remove it. The abort event listener creates a closure which captures `task`
(tasks capture zones and other stuff too) and prevent `task`, zones and signals from
being garbage collected.

We now store the function which removes the abort event listener when the actual event
listener is being removed. The function is stored on task data since task data is already
being used to store different types of information that's necessary to be shared between
`addEventListener` and `removeEventListener`.

Closes angular#54739
AndrewKushnir pushed a commit that referenced this issue May 7, 2024
…s removed (#55339)

This commit updates the implementation of the `addEventListener` patcher.

We're currently creating an abort event listener on the signal (when it's provided)
and never remove it. The abort event listener creates a closure which captures `task`
(tasks capture zones and other stuff too) and prevent `task`, zones and signals from
being garbage collected.

We now store the function which removes the abort event listener when the actual event
listener is being removed. The function is stored on task data since task data is already
being used to store different types of information that's necessary to be shared between
`addEventListener` and `removeEventListener`.

Closes #54739

PR Close #55339
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Jun 7, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: zones Issues related to zone.js regression Indicates than the issue relates to something that worked in a previous version
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants