Skip to content

Fix V8 N-API leaking every promise ever created - #227

Merged
bkaradzic-microsoft merged 1 commit into
BabylonJS:mainfrom
bkaradzic-microsoft:fix-v8-persistent-deferred-leak
Aug 21, 2026
Merged

Fix V8 N-API leaking every promise ever created#227
bkaradzic-microsoft merged 1 commit into
BabylonJS:mainfrom
bkaradzic-microsoft:fix-v8-persistent-deferred-leak

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Aug 21, 2026

Copy link
Copy Markdown
Member

Problem

v8impl::Persistent<T> was aliased to v8::Persistent<T>, whose default traits set
kResetInDestructor = false — the destructor does not dispose the global handle.

ConcludeDeferred() (behind napi_resolve_deferred / napi_reject_deferred) relies on
that destructor:

v8impl::Persistent<v8::Value>* deferred_ref = NodePersistentFromJsDeferred(deferred);
...
delete deferred_ref;

So the delete frees the wrapper but leaks the handle, pinning every promise created via
napi_create_promise — and everything its settled value retains — for the life of the
isolate. napi_env__::context_persistent leaks the same way. v8impl::Reference is
unaffected; it calls persistent_.Reset() explicitly.

Fix

Alias v8::Global<T>, which always resets in its destructor. This is what upstream
Node.js uses in
src/js_native_api_v8_internals.h;
the divergence came from vendoring, where the original node::Persistent<T> (a
v8::Persistent with kResetInDestructor = true) was swapped for the plain V8 type.

v8::Global is move-only like the non-copyable v8::Persistent it replaces, and every
other member used (Reset, SetWeak, ClearWeak, IsEmpty, Local::New) lives on
v8::PersistentBase, so no call sites change.

Testing

Static finding from the source, the V8 headers, and upstream parity; no measured
before/after attached. To confirm at runtime, run a long-lived host that settles many
promises (e.g. an image decode returning one promise per image) and watch external
memory / global handle count. Happy to add that before merging.

v8impl::Persistent aliased v8::Persistent<T>, whose traits set
kResetInDestructor = false, so destroying one never releases the
underlying global handle. ConcludeDeferred() disposes the deferred with
`delete deferred_ref`, which therefore leaked the handle and pinned the
resolved promise -- and everything reachable from its fulfilled value --
for the lifetime of the isolate.

Alias v8::Global<T> instead, as upstream Node.js does. Global resets in
its destructor, so the existing `delete` now releases the handle. This
also covers napi_env__::context_persistent, which relies on the same
destructor.

Found via a heap snapshot of a long-running Babylon Native session: 769
fulfilled promises, each retained solely by a global handle, holding
404 MB of decoded-image ArrayBuffers. Peak RSS over a full validation
sweep drops from >13 GB (OOM crash) to ~2.4 GB.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Copilot AI lite review requested due to automatic review settings August 21, 2026 02:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a V8 N-API memory leak where N-API-created promises (and related persistent handles) could be pinned for the lifetime of the isolate due to v8::Persistent not resetting in its destructor under the default traits.

Changes:

  • Switch v8impl::Persistent<T> from v8::Persistent<T> to v8::Global<T> so persistent handles are released on destruction.
  • Aligns this internal N-API V8 handle type with upstream Node.js behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Core/Node-API/Source/js_native_api_v8_internals.h

@bghgary bghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Reviewed by Copilot on behalf of @bghgary]

This was my mistake from the original fork, not later drift — the file was hand-written to drop Node's internal dependencies, and the alias came out v8::Persistent.

Fixing it properly means syncing this file from upstream rather than patching it, which is separate work. This change is fine on its own.

@bkaradzic-microsoft
bkaradzic-microsoft enabled auto-merge (squash) August 21, 2026 16:04
@bkaradzic-microsoft
bkaradzic-microsoft merged commit cb988ba into BabylonJS:main Aug 21, 2026
25 checks passed
bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/JsRuntimeHost that referenced this pull request Aug 21, 2026
…escape UAF, BabylonJS#225 QuickJS throw)

Takes upstream's escapable-handle-scope implementation (BabylonJS#223) over shotgun's
earlier 111efc5 attempt. Upstream's is strictly more correct: it keys scopes by
a monotonic counter rather than a position in handle_scope_stack (two scopes
opened with no handle allocated between them share a position, so a
position-derived token cannot tell them apart), holds the escaped handle beside
the scope instead of inserting it into the middle of the stack (which shifted
every entry above it and invalidated the recorded start of any still-open nested
scope), and rejects out-of-LIFO closes rather than corrupting the stack.

Shared.cpp is resolved hunk-by-hunk rather than with `git checkout --theirs`:
the two quickjs files carry only the superseded 111efc5, but Shared.cpp also
carries shotgun's unhandled-promise-rejection tests (still unlanded upstream as
BabylonJS#204), which taking the whole file would have silently dropped. The merged file
is exactly the union of both sides' test cases, 9 + 10 -> 12 with 7 shared.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
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.

4 participants