Skip to content

fix(core): prevent dangling prevConsumer reference from leaking destroyed views#68137

Open
stewartmcgown wants to merge 2 commits into
angular:mainfrom
stewartmcgown:fix/signal-prevConsumer-leak
Open

fix(core): prevent dangling prevConsumer reference from leaking destroyed views#68137
stewartmcgown wants to merge 2 commits into
angular:mainfrom
stewartmcgown:fix/signal-prevConsumer-leak

Conversation

@stewartmcgown

@stewartmcgown stewartmcgown commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

What is the current behavior?

When producerAccessed creates a new ReactiveLink for a non-live consumer (e.g. a computed signal that has no readers yet), it eagerly sets prevConsumer to the producer's current consumersTail:

const prevConsumerLink = node.consumersTail;
// ...
const newLink = {
  // ...
  prevConsumer: prevConsumerLink,  // captures a pointer INTO the consumer list
};
// ...
if (isLive) {
  producerAddLiveConsumer(node, newLink);  // only called when live
}

Because the consumer is not live, producerAddLiveConsumer is skipped and the link is never inserted into the producer's consumer doubly-linked list. But prevConsumer already 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 dangling prevConsumer on 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-item elements, their parent DataViewListComponent, ViewsBarComponent, ViewBlockComponent, etc.) remained alive.

Using heap snapshot retainer analysis, I traced the single retention path:

GC Root
  → Window.logNgrxState (closure)
    → NgZoneStoreService2.store
      → R3Injector.records (Map)
        → AttachmentApiV1Service [providedIn: 'root']
          → urls (computed signal)
            → SIGNAL node [producers linked list]
              → producer link @9563675 (edge: computed reads environmentSignal)
                → prevConsumer → @11501677 (STALE — destroyed view's consumer link)
                  → consumer → ReactiveLViewConsumer @20250859 (destroyed)
                    → view → LView (3.3 MB retained)
                      → ViewsBarComponent → ViewBlockComponent
                        → DataViewListComponent → 53+ detached DOM elements
image image

The key evidence from the heap:

  • The ReactiveLViewConsumer's own producers and producersTail are both undefined — confirming consumerDestroy ran and cleaned up the consumer side.
  • But the producer link for the urls computed (@9563675) still has prevConsumer pointing to the stale consumer link (@11501677), because the link was never part of the consumer list and was never patched during removal.
  • Cutting AttachmentApiV1Service alone eliminated all paths to GC roots (both JS and C++ InternalNode paths), confirming this is the sole retention mechanism.
  • The environmentSignal's own active consumer list (consumers/consumersTail) was correctly updated — the stale entry was removed from the active list. Only the dangling prevConsumer on the adjacent non-live link kept it alive.

What is the fix?

Initialize prevConsumer to undefined at link creation time instead of eagerly capturing node.consumersTail.

This is safe because producerAddLiveConsumer unconditionally sets link.prevConsumer = consumersTail when the link is actually inserted into the consumer list. The value set in producerAccessed was 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 prevConsumer field set in producerAccessed was dead-on-arrival for live consumers (overwritten by producerAddLiveConsumer) and incorrect for non-live consumers (dangling reference). No code path reads prevConsumer on a link that hasn't been inserted via producerAddLiveConsumer.

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.

…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
@angular-robot angular-robot Bot added area: core Issues related to the framework runtime requires: TGP This PR requires a passing TGP before merging is allowed labels Apr 10, 2026
@ngbot ngbot Bot added this to the Backlog milestone Apr 10, 2026
@JeanMeche

Copy link
Copy Markdown
Member

cc @milomg

@milomg

milomg commented Apr 11, 2026

Copy link
Copy Markdown
Member

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

@stewartmcgown

stewartmcgown commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

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.

@stewartmcgown stewartmcgown force-pushed the fix/signal-prevConsumer-leak branch 2 times, most recently from a401548 to 4201380 Compare April 11, 2026 12:14
@stewartmcgown

Copy link
Copy Markdown
Contributor Author

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

@JeanMeche

Copy link
Copy Markdown
Member

You can enable gc for that specific target yourself:


jasmine_test(
    name = "signals",
    data = [
        ":signals_lib",
    ],
    node_options = ["--expose-gc"],
)

What I would do for such tests that rely on that flags is to early return with if(isBrowser) {} so the tests are skipped on the browser targets.

@stewartmcgown stewartmcgown force-pushed the fix/signal-prevConsumer-leak branch from 4201380 to fefa753 Compare April 13, 2026 10:19
@stewartmcgown

Copy link
Copy Markdown
Contributor Author

@milomg @JeanMeche I've pushed with the original .gc() tests - thanks for the Bazel advice!

Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
@stewartmcgown stewartmcgown force-pushed the fix/signal-prevConsumer-leak branch from fefa753 to a8b640a Compare April 13, 2026 10:45
@stewartmcgown stewartmcgown requested a review from JeanMeche April 13, 2026 10:51
Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
Comment thread packages/core/test/signals/signal_graph_leak_spec.ts Outdated
refs.push(setupAndReturnRef(source));
}

setTimeout(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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
@stewartmcgown stewartmcgown force-pushed the fix/signal-prevConsumer-leak branch from a8b640a to 84a0f40 Compare April 13, 2026 15:58
Comment on lines +31 to +35
let link = (sourceNode as any).consumers;
while (link !== undefined) {
watchNode = link.consumer;
link = link.nextConsumer;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here is my proposed change: 2257e64

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Issues related to the framework runtime requires: TGP This PR requires a passing TGP before merging is allowed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants