Skip to content

Drain deferred V8 N-API finalizers after dispatch - #217

Open
matthargett wants to merge 2 commits into
BabylonJS:mainfrom
rebeckerspecialties:v8-finalizer-drain
Open

Drain deferred V8 N-API finalizers after dispatch#217
matthargett wants to merge 2 commits into
BabylonJS:mainfrom
rebeckerspecialties:v8-finalizer-drain

Conversation

@matthargett

@matthargett matthargett commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Drain deferred V8 N-API finalizers at a safe host boundary, with an 8 ms budget per dispatcher turn.

V8 weak callbacks enqueue non-experimental N-API finalizers for a safe second pass. JsRuntimeHost did not run that pass, so collected wrappers retained native resources until runtime teardown. This is independent of Dawn, WebGPU, and application rendering code.

Reproduction

The profiling workload uses four packaged VRM files (from https://github.com/PolygonalMind/100Avatars ), loaded through the browser-shaped Blob path, with four thin instances per avatar. It replaces the batch ten times after 180 rendered frames:

const engine = new BABYLON.NativeEngine();
const scene = new BABYLON.Scene(engine);
scene.createDefaultCamera(true, true, true);
scene.createDefaultLight(true);
engine.runRenderLoop(() => scene.render());

const avatarUrls = [
    "app:///Assets/avatar-a.vrm",
    "app:///Assets/avatar-b.vrm",
    "app:///Assets/avatar-c.vrm",
    "app:///Assets/avatar-d.vrm",
];

const matrices = new Float32Array(4 * 16);
for (let index = 0; index < 4; ++index) {
    BABYLON.Matrix.Translation((index - 1.5) * 1.5, 0, 0).copyToArray(matrices, index * 16);
}

const waitFrames = (count) =>
    new Promise((resolve) => {
        const observer = scene.onAfterRenderObservable.add(() => {
            if (--count === 0) {
                scene.onAfterRenderObservable.remove(observer);
                resolve();
            }
        });
    });

(async () => {
    let containers = [];
    for (let cycle = 0; cycle < 10; ++cycle) {
        for (const container of containers) {
            container.dispose();
        }
        containers = [];

        for (const url of avatarUrls) {
            const response = await fetch(url);
            const blob = await response.blob();
            const objectUrl = URL.createObjectURL(blob);
            try {
                const container = await BABYLON.SceneLoader.LoadAssetContainerAsync(
                    "",
                    objectUrl,
                    scene,
                    undefined,
                    ".glb"
                );
                container.addAllToScene();
                for (const mesh of container.meshes) {
                    if (mesh.getTotalVertices() > 0) {
                        mesh.thinInstanceSetBuffer("matrix", matrices, 16, true);
                    }
                }
                containers.push(container);
            } finally {
                URL.revokeObjectURL(objectUrl);
            }
        }

        await waitFrames(180);
    }
})();

The measured files were four distinct 6-10 MB VRMs from PolygonalMind/100Avatars.

Validation

  • Current main reached about 2 GB RSS / 1.9 GB native heap with the deferred queue undrained.
  • Draining the queue kept the same workload near 429 MB RSS at 50 seconds.
  • Per-turn budgeting reduced the original 109.49 ms maximum drain to short slices. After removing an unrelated synchronous resource flush in the application finalizer, the 10-cycle run measured p95 8.00 ms, p99 8.06 ms, and max 8.36 ms.
  • V8 connected suite: 8 tests passed, including deterministic coverage that the queue yields between turns and then drains completely.
  • Android HWASan soak: 600,064 transient wrappers completed with bounded memory and no finding.

V8 weak callbacks enqueue N-API finalizers for a safe second pass, but AppRuntime did not drain that queue. Run the queue after each host dispatch and cover the behavior with a deterministic V8-only unit test.
Copilot AI review requested due to automatic review settings July 29, 2026 23:47

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 reached their quota limit.

napi_env env_ptr{env};

// Finalizers can mutate this queue, so continue until it is empty.
while (!env_ptr->pending_finalizers.empty())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

hypothetically, this could go on forever in some pathological case with a semi-malicious native module. lmk if you want a safeguard counter check in there.

Run deferred V8 finalizers once after each dispatcher batch and cap each batch at eight milliseconds. This prevents large GC queues from monopolizing the runtime thread while preserving eventual finalization on later turns. Add a deterministic slow-finalizer regression test that proves the queue yields and then drains completely.
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