Skip to content

Node-API addons with normal finalizers keep GBs of dead external memory on V8 >= 14.8 (v8-canary) #66859

Open

Description

@edusperoni

Version

v27.0.0-v8-canary2026101812b4708d0e (V8 15.8.21). Not reproducible on v26.12.0 (V8 14.6.202.34).

Platform

Darwin 24.6.0 arm64 (macOS)

Subsystem

node-api (root cause in V8 heap)

What steps will reproduce the bug?

This is a Node-API addon that wraps 1 MiB of native memory per object, reports it with napi_adjust_external_memory, and releases it in a normal (non-basic) napi_wrap finalizer. The script drops every object immediately and yields to the event loop every 100 allocations.

addon.c:

#include <node_api.h>
#include <stdlib.h>

#define SIZE (1 << 20)
static int64_t live = 0;

static void finalize(napi_env env, void* data, void* hint) {
  int64_t ignored;
  free(data);
  live--;
  napi_adjust_external_memory(env, -SIZE, &ignored);
}

static napi_value Make(napi_env env, napi_callback_info info) {
  napi_value obj;
  int64_t ignored;
  void* mem = malloc(SIZE);
  live++;
  napi_create_object(env, &obj);
  napi_adjust_external_memory(env, SIZE, &ignored);
  napi_wrap(env, obj, mem, finalize, NULL, NULL);
  return obj;
}

static napi_value Live(napi_env env, napi_callback_info info) {
  napi_value result;
  napi_create_int64(env, live, &result);
  return result;
}

static napi_value Init(napi_env env, napi_value exports) {
  napi_value fn;
  napi_create_function(env, "make", NAPI_AUTO_LENGTH, Make, NULL, &fn);
  napi_set_named_property(env, exports, "make", fn);
  napi_create_function(env, "live", NAPI_AUTO_LENGTH, Live, NULL, &fn);
  napi_set_named_property(env, exports, "live", fn);
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

binding.gyp:

{ "targets": [ { "target_name": "addon", "sources": ["addon.c"] } ] }

run.js:

const { make, live } = require("./build/Release/addon.node");
(async () => {
  let peak = 0;
  for (let i = 0; i < 20000; i++) {
    make();
    if (i % 100 === 99) {
      peak = Math.max(peak, live());
      await new Promise((r) => setImmediate(r));
    }
  }
  console.log(`${process.version} (V8 ${process.versions.v8}): peak live = ${peak} MiB`);
})();
npx node-gyp rebuild --nodedir=/path/to/node
node run.js

How often does it reproduce? Is there a required condition?

Always on the v8-canary build. The condition is that native memory reported with napi_adjust_external_memory is released from a normal finalizer, i.e. after the GC that found the object dead.

What is the expected behavior? Why is that the expected behavior?

The amount of dead native memory waiting for collection should stay bounded, as it does on v26.12.0:

v26.12.0 (V8 14.6.202.34-node.39): peak live = 136 MiB

What do you see instead?

v27.0.0-v8-canary2026101812b4708d0e (V8 15.8.21-node.7): peak live = 5660 MiB

GCs back off geometrically. --trace-gc --trace-gc-verbose --trace-gc-nvp shows the global allocation limit tracking the garbage, at 64, 117, 204, 272, 461, … MB.

Using a basic finalizer (NAPI_EXPERIMENTAL, which Node calls from the first-pass weak callback) keeps the peak at about 1 MB.

Additional information

The root cause is in V8, filed as https://issues.chromium.org/issues/574627777, which includes a stock-V8-API repro.

Since V8 14.8 (6a5039d9db9, external_memory_accounted_in_global_limit on by default), V8 computes the global allocation limit inside the GC pause from external memory that still includes the objects that GC just found dead. Memory released after the pause never lowers that limit. That covers second-pass callbacks and the normal Node-API finalizers queued by napi_env__::InvokeFinalizerFromGC.

Normal finalizers are the Node-API default, so native addons that hold large buffers will be affected once Node ships a V8 with this scheme. On v26 the same behavior can be previewed with --external-memory-accounted-in-global-limit. 14.6's implementation of that flag differs, though, so the canary is the faithful repro.

The scheme is also discussed in #61967 as the expected fix for the Buffer regression there.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions