Skip to content

Fix GC root leak in RefPtr assignment operators#2699

Merged
sbc100 merged 2 commits into
WebAssembly:mainfrom
sumleo:fix/refptr-assignment-gc-leak
Feb 26, 2026
Merged

Fix GC root leak in RefPtr assignment operators#2699
sbc100 merged 2 commits into
WebAssembly:mainfrom
sumleo:fix/refptr-assignment-gc-leak

Conversation

@sumleo

@sumleo sumleo commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Summary

The copy and move assignment operators for RefPtr (both same-type and cross-type) overwrite root_index_ with a new root without first calling DeleteRoot on the old one. While the destructor correctly releases the root via reset(), assignments bypass this cleanup, leaking one GC root per reassignment.

In a long-running interpreter session where RefPtr objects are reassigned frequently, the leaked roots accumulate in the Store's root list. Because roots prevent the GC from collecting the referenced objects, this leads to unbounded memory growth.

Changes

  • Call reset() at the top of each assignment operator so the previous root is released before a new one is acquired.
  • Add self-assignment guards (if (this == &other) return *this;) to the same-type copy and move assignment operators to avoid use-after-reset on self-assignment.

The cross-type assignment operators (RefPtr<T>::operator=(const RefPtr<U>&) and RefPtr<T>::operator=(RefPtr<U>&&)) do not need a self-assignment guard because T and U are different types, so this and &other cannot alias.

Test plan

  • Verified that wasm-interp (which exercises RefPtr heavily through the interpreter) builds cleanly with no warnings.
  • The fix is a mechanical correction of a well-understood RAII pattern; existing interpreter tests cover RefPtr usage paths.

The copy and move assignment operators for RefPtr (both same-type and
cross-type) were overwriting root_index_ with a new root without first
calling DeleteRoot on the old one. This violates the RAII invariant that
the destructor's reset() path is supposed to maintain: every CopyRoot
must be balanced by a DeleteRoot.

In a long-running interpreter session where RefPtr objects are
reassigned frequently, the leaked roots accumulate in the Store's root
list. Because roots prevent the GC from collecting the referenced
objects, this leads to unbounded memory growth.

Fix by calling reset() at the top of each assignment operator so the
previous root is released before a new one is acquired. The same-type
operators also gain a self-assignment guard to avoid use-after-reset.

@sbc100 sbc100 left a comment

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.

lgtm, but checking with @zherczeg who as been working GC stuff recently. Does this seem reasonable to you?

@zherczeg zherczeg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, it looks like a serious bug.

template <typename T>
template <typename U>
RefPtr<T>& RefPtr<T>::operator=(const RefPtr<U>& other) {
reset();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why if (this == &other) return *this; is not added here?
I suspect the point of this check, is that if you run a reset on yourself, then you copy null values later (set by the reset), and just loose the object forever.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry, I see in your description that this case cannot happen. I think an assert would be still useful here that it really never happens.

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.

Good point. Added an assert to both cross-type assignment operators to document the invariant. Since T and U are different types self-assignment can't happen through normal use, but the assert catches any unexpected aliasing.

While cross-type assignment (RefPtr<T> from RefPtr<U>) cannot
self-assign because T and U are different types, add a defensive
assert to document this invariant and catch any unexpected aliasing
through type erasure.
@sbc100 sbc100 merged commit 11967f1 into WebAssembly:main Feb 26, 2026
17 checks 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.

3 participants