Skip to content

Drain deferred V8 N-API finalizers after each dispatcher turn - #2004

Open
matthargett wants to merge 1 commit into
BabylonJS:masterfrom
rebeckerspecialties:v8-finalizer-drain-monorepo
Open

matthargett wants to merge 1 commit into
BabylonJS:masterfrom
rebeckerspecialties:v8-finalizer-drain-monorepo

Conversation

@matthargett

Copy link
Copy Markdown
Contributor

On V8, a garbage collection does not run N-API finalizers directly. The weak callback enqueues each finalizer on napi_env::pending_finalizers for a second pass that has to run from a safe host boundary, and the host owns draining that queue. JsRuntimeHost never drained it, so every collected wrapper (Napi::External, ObjectWrap, external array buffers) kept its native resources alive until the environment was torn down. Plugins that hand out many short-lived wrappers per frame, such as WebGPU buffers, textures and command encoders, leaked without bound.

Change

  • AppRuntime::Run calls a new engine-specific DrainPostDispatchWork(Napi::Env) hook after each dispatcher turn that ran work. Running once per turn, rather than after each callback, keeps the drain off the per-callback path.
  • On V8 the hook is Napi::DrainFinalizers, which pops finalizers from the pending queue one at a time (a finalizer may enqueue or dequeue others) and stops once an 8 ms budget is spent, so a large batch cannot monopolize the JavaScript thread; the remainder runs on the following turns.
  • The hook is a no-op on Chakra, JavaScriptCore, QuickJS, Hermes and JSI, which finalize through their own mechanisms.

Tests

Two V8-only unit tests in Tests.AppRuntime.cpp, compiled under a new JSRUNTIMEHOST_NAPI_ENGINE_V8 definition in both unit-test CMake files:

  • AppRuntime.V8FinalizersDrainAfterDispatch: the finalizers of 32 collected externals have all run by the dispatcher turn after the collection.
  • AppRuntime.V8FinalizerDrainYieldsBetweenDispatcherTurns: a batch whose finalizers exceed the budget is spread across turns instead of completing in one, and completes within a bounded number of turns.

The JavaScriptCore build of AppRuntime, napi and JsRuntimeHostUnitTests passes its full suite on macOS.

Notes

This supersedes plugin-local workarounds such as the finalizer pump in the NativeDawn plugin (#1781), which reaches into napi_env internals from outside JsRuntimeHost.

Port of BabylonJS/JsRuntimeHost#217.

On V8, a garbage collection does not run N-API finalizers directly. The
weak callback enqueues each finalizer on napi_env::pending_finalizers for
a second pass that has to run from a safe host boundary, and the host owns
draining that queue. JsRuntimeHost never drained it, so every collected
wrapper (Napi::External, ObjectWrap, external array buffers) kept its
native resources alive until the environment was torn down. Plugins that
hand out many short-lived wrappers per frame, such as WebGPU buffers,
textures and command encoders, leaked without bound.

AppRuntime now calls an engine-specific DrainPostDispatchWork hook after
each dispatcher turn that ran work. On V8 the hook is Napi::DrainFinalizers,
which pops finalizers from the pending queue one at a time (a finalizer may
enqueue or dequeue others) and stops once an 8 ms budget is spent so a
large batch cannot monopolize the JavaScript thread; the remainder runs on
the following turns. Running once per turn rather than after each callback
keeps the drain off the per-callback path. The hook is a no-op on Chakra,
JavaScriptCore, QuickJS, Hermes and JSI, which finalize through their own
mechanisms.

Two V8-only unit tests cover the behaviour: the finalizers of collected
externals have all run by the dispatcher turn after the collection, and a
batch whose finalizers exceed the budget is spread across turns instead of
completing in one.

This supersedes plugin-local workarounds such as the finalizer pump in the
NativeDawn plugin (BabylonJS#1781), which reaches into napi_env internals from
outside JsRuntimeHost.

Port of BabylonJS/JsRuntimeHost#217.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 20:18

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

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.

2 participants