fix(core): prevent dangling prevConsumer reference from leaking destroyed views#68137
fix(core): prevent dangling prevConsumer reference from leaking destroyed views#68137stewartmcgown wants to merge 2 commits into
Conversation
…oyed views When `producerAccessed` creates a new link for a non-live consumer (e.g. a computed signal with no readers), it eagerly sets `prevConsumer` to the producer's current `consumersTail`. However, because the consumer is not live, `producerAddLiveConsumer` is skipped and the link is never inserted into the producer's consumer doubly-linked list. This means the link holds a reference *into* the producer's consumer list without being *part* of it. When the node that `prevConsumer` points to is later removed via `producerRemoveLiveConsumerLink`, the dangling link is not patched because it isn't traversable from the list. The result is that the removed consumer link — and everything it references — is kept alive by the dangling `prevConsumer` pointer on the non-live link, which itself is kept alive through the computed signal's `producers` linked list. In practice this causes multi-MB memory leaks in Angular apps: a root-provided service with a computed signal (e.g. `AttachmentApiService.urls`) holds a producer link to `ApplicationEnvironmentService.environmentSignal`. That link's `prevConsumer` captures a stale reference to a destroyed view's `ReactiveLViewConsumer` link, retaining the entire LView hierarchy — components, QueryLists, ElementRefs, and detached DOM — after the view is destroyed. The fix initializes `prevConsumer` to `undefined` at link creation time. This is safe because `producerAddLiveConsumer` unconditionally sets `link.prevConsumer = consumersTail` (line 513) when the link is actually inserted into the consumer list. The value set in `producerAccessed` was always overwritten for live consumers, and was never correct for non-live consumers. Made-with: Cursor
|
cc @milomg |
|
This is a very nice catch! I wonder if the test could depend less on the internals (potentially enabling more flexibility to use a different data structure in the future) by using a FinalizationRegistry to confirm that the nodes were GC'd |
|
Thanks for looking @milomg @JeanMeche Would you like me to edit the test? I know you folks merge these things out with the pull request flow. |
a401548 to
4201380
Compare
|
FYI the bazel tests were timing out so I went back to the graph approach - if you want the proper GC tests need to configure the runner to support a .gc call? @milomg |
|
You can enable What I would do for such tests that rely on that flags is to early return with |
4201380 to
fefa753
Compare
|
@milomg @JeanMeche I've pushed with the original .gc() tests - thanks for the Bazel advice! |
fefa753 to
a8b640a
Compare
| refs.push(setupAndReturnRef(source)); | ||
| } | ||
|
|
||
| setTimeout(() => { |
There was a problem hiding this comment.
Can we use import {timeout} from '@angular/private/testing'; to reduce nesting and use await to improve readability here.
Also its unclear to me why you're using 50 ? Why not less ?
Is there a risk this test becomes flaky ?
There was a problem hiding this comment.
As far as I'm concerned all tests that rely on predictable garbage collection are flaky!
My original tests didn't rely on GC, they tested that the consumer had no traversable path. I would prefer to keep in those kinds of tests rather than any GC ones. @milomg suggestion was to avoid looking through internals though, so perhaps I'm not sure exactly how to frame a test here that doesn't inspect internals but is guaranteed to never flake due to GC internals!
There was a problem hiding this comment.
I've pushed all these changes - let me know what you think we should do RE the type of tests you'd like to see here!
Uses WeakRef + global.gc() to verify that destroyed effect consumers become garbage-collectable when a non-live computed reads the same producer. The jasmine_test target is configured with node_options: --expose-gc. GC tests are skipped in browser targets via isBrowser from @angular/private/testing. Made-with: Cursor
a8b640a to
84a0f40
Compare
| let link = (sourceNode as any).consumers; | ||
| while (link !== undefined) { | ||
| watchNode = link.consumer; | ||
| link = link.nextConsumer; | ||
| } |
There was a problem hiding this comment.
In the spirit of making this depend less on the datastructure of links, I wonder if it would make sense to use createWatch directly so we can avoid doing source[SIGNAL]?
(I'm a fan of this new GC direction, I think it's cleaner than before and this behavior of V8 should be reasonably dependable)
What is the current behavior?
When
producerAccessedcreates a newReactiveLinkfor a non-live consumer (e.g. acomputedsignal that has no readers yet), it eagerly setsprevConsumerto the producer's currentconsumersTail:Because the consumer is not live,
producerAddLiveConsumeris skipped and the link is never inserted into the producer's consumer doubly-linked list. ButprevConsumeralready holds a reference to a node inside that list.When that referenced node is later removed from the consumer list via
producerRemoveLiveConsumerLink, the doubly-linked list is correctly patched — but the danglingprevConsumeron the non-live link is not patched, because the non-live link isn't part of the list and isn't traversable from it.The removed link, and everything it retains, is kept alive indefinitely by this dangling reference.
How I found it
I was investigating a memory leak in a large Angular app (~1.5GB heap, other leaks going on too from our side!). After navigating away from a page, 3.3 MB of detached DOM (53+
cu-data-view-itemelements, their parentDataViewListComponent,ViewsBarComponent,ViewBlockComponent, etc.) remained alive.Using heap snapshot retainer analysis, I traced the single retention path:
The key evidence from the heap:
ReactiveLViewConsumer's ownproducersandproducersTailare bothundefined— confirmingconsumerDestroyran and cleaned up the consumer side.urlscomputed (@9563675) still hasprevConsumerpointing to the stale consumer link (@11501677), because the link was never part of the consumer list and was never patched during removal.AttachmentApiV1Servicealone eliminated all paths to GC roots (both JS and C++ InternalNode paths), confirming this is the sole retention mechanism.environmentSignal's own active consumer list (consumers/consumersTail) was correctly updated — the stale entry was removed from the active list. Only the danglingprevConsumeron the adjacent non-live link kept it alive.What is the fix?
Initialize
prevConsumertoundefinedat link creation time instead of eagerly capturingnode.consumersTail.This is safe because
producerAddLiveConsumerunconditionally setslink.prevConsumer = consumersTailwhen the link is actually inserted into the consumer list. The value set inproducerAccessedwas always overwritten for live consumers, so it only ever mattered for non-live consumers — where it was always wrong (a dangling pointer into a list the link doesn't belong to).Does this PR introduce a breaking change?
No. The
prevConsumerfield set inproducerAccessedwas dead-on-arrival for live consumers (overwritten byproducerAddLiveConsumer) and incorrect for non-live consumers (dangling reference). No code path readsprevConsumeron a link that hasn't been inserted viaproducerAddLiveConsumer.I checked the PR where this was added, and it doesn't seem to be commented on that this reference could retain. https://github.com/angular/angular/pull/62284/changes#diff-f1832f2d695da63c1470248ac2005fb2b57549086f0bedf6bc6db00b54bc17f0R260
Noting that I have applied this patch to our internal repo and tested it - the leak is resolved and signals from the root service appear to be functioning correctly.