fix(napi): tie external buffer finalizer to ArrayBuffer lifecycle

Previously, `napi_create_external_buffer` used a two-stage finalization
approach: it created the ArrayBuffer with an empty finalizer, then added
the real finalizer separately via `vm.heap.addFinalizer()`. This caused
a race condition where native code could free/reuse the underlying memory
while Bun still held a reference to the ArrayBuffer.

The fix passes the finalizer directly to `ArrayBuffer::createFromBytes`,
matching the approach already used by `napi_create_external_arraybuffer`.
This ties the cleanup correctly to the ArrayBuffer's lifecycle.

Fixes #26423

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-24 20:34:16 +00:00
parent 827c7091d9
commit 66a84b353d
4 changed files with 297 additions and 9 deletions

View File

@@ -2044,20 +2044,14 @@ extern "C" napi_status napi_create_external_buffer(napi_env env, size_t length,
NAPI_RETURN_SUCCESS(env);
}
auto arrayBuffer = ArrayBuffer::createFromBytes({ reinterpret_cast<const uint8_t*>(data), length }, createSharedTask<void(void*)>([](void*) {
// do nothing
auto arrayBuffer = ArrayBuffer::createFromBytes({ reinterpret_cast<const uint8_t*>(data), length }, createSharedTask<void(void*)>([env, finalize_hint, finalize_cb](void* p) {
NAPI_LOG("external buffer finalizer");
env->doFinalizer(finalize_cb, p, finalize_hint);
}));
auto* buffer = JSC::JSUint8Array::create(globalObject, subclassStructure, WTF::move(arrayBuffer), 0, length);
NAPI_RETURN_IF_EXCEPTION(env);
// setup finalizer after creating the array. if it throws callers of napi_create_external_buffer are expected
// to free input
vm.heap.addFinalizer(buffer, [env = WTF::Ref<NapiEnv>(*env), finalize_cb, data, finalize_hint](JSCell* cell) -> void {
NAPI_LOG("external buffer finalizer");
env->doFinalizer(finalize_cb, data, finalize_hint);
});
*result = toNapi(buffer, globalObject);
NAPI_RETURN_SUCCESS(env);
}

View File

@@ -1825,6 +1825,118 @@ static napi_value test_napi_empty_buffer_info(const Napi::CallbackInfo &info) {
return ok(env);
}
// Test for napi_create_external_buffer finalizer correctness
// See: https://github.com/oven-sh/bun/issues/26423
// This test verifies that the finalizer receives the correct data pointer
// and is called at the right time.
static int external_buffer_finalizer_call_count = 0;
static void *external_buffer_finalizer_received_data = nullptr;
static void *external_buffer_finalizer_received_hint = nullptr;
static const size_t EXTERNAL_BUFFER_TEST_SIZE = 1024;
static void external_buffer_test_finalizer(napi_env env, void *data, void *hint) {
external_buffer_finalizer_call_count++;
external_buffer_finalizer_received_data = data;
external_buffer_finalizer_received_hint = hint;
}
static napi_value
test_napi_external_buffer_finalizer(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
// Reset global state
external_buffer_finalizer_call_count = 0;
external_buffer_finalizer_received_data = nullptr;
external_buffer_finalizer_received_hint = nullptr;
// Allocate test data
char *test_data = static_cast<char *>(malloc(EXTERNAL_BUFFER_TEST_SIZE));
if (!test_data) {
printf("FAIL: Could not allocate test data\n");
return env.Undefined();
}
// Fill with a known pattern
memset(test_data, 0x42, EXTERNAL_BUFFER_TEST_SIZE);
// Create an external buffer
napi_value buffer;
void *hint = reinterpret_cast<void *>(0xCAFEBABE);
napi_status status = napi_create_external_buffer(
env, EXTERNAL_BUFFER_TEST_SIZE, test_data, external_buffer_test_finalizer,
hint, &buffer);
if (status != napi_ok) {
printf("FAIL: napi_create_external_buffer failed with status %d\n", status);
free(test_data);
return env.Undefined();
}
// Verify the buffer has the correct data
void *buffer_data;
size_t buffer_length;
NODE_API_CALL(env, napi_get_buffer_info(env, buffer, &buffer_data, &buffer_length));
if (buffer_length != EXTERNAL_BUFFER_TEST_SIZE) {
printf("FAIL: Buffer length is %zu instead of %zu\n", buffer_length,
EXTERNAL_BUFFER_TEST_SIZE);
return env.Undefined();
}
if (buffer_data != test_data) {
printf("FAIL: Buffer data pointer %p doesn't match original %p\n",
buffer_data, test_data);
return env.Undefined();
}
// Verify the data wasn't corrupted
char *data_as_char = static_cast<char *>(buffer_data);
for (size_t i = 0; i < EXTERNAL_BUFFER_TEST_SIZE; i++) {
if (data_as_char[i] != 0x42) {
printf("FAIL: Data corrupted at index %zu: expected 0x42, got 0x%02x\n", i,
static_cast<unsigned char>(data_as_char[i]));
return env.Undefined();
}
}
printf("PASS: External buffer created with correct data pointer and length\n");
// Return the buffer - JS will trigger GC to test the finalizer
return buffer;
}
static napi_value
get_external_buffer_finalizer_stats(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
napi_value result;
NODE_API_CALL(env, napi_create_object(env, &result));
napi_value call_count;
NODE_API_CALL(env, napi_create_int32(env, external_buffer_finalizer_call_count, &call_count));
NODE_API_CALL(env, napi_set_named_property(env, result, "callCount", call_count));
napi_value received_data;
if (external_buffer_finalizer_received_data != nullptr) {
NODE_API_CALL(env, napi_create_bigint_uint64(
env, reinterpret_cast<uint64_t>(external_buffer_finalizer_received_data), &received_data));
} else {
NODE_API_CALL(env, napi_get_null(env, &received_data));
}
NODE_API_CALL(env, napi_set_named_property(env, result, "receivedData", received_data));
napi_value received_hint;
if (external_buffer_finalizer_received_hint != nullptr) {
NODE_API_CALL(env, napi_create_bigint_uint64(
env, reinterpret_cast<uint64_t>(external_buffer_finalizer_received_hint), &received_hint));
} else {
NODE_API_CALL(env, napi_get_null(env, &received_hint));
}
NODE_API_CALL(env, napi_set_named_property(env, result, "receivedHint", received_hint));
return result;
}
// Test for napi_typeof with boxed primitive objects (String, Number, Boolean)
// See: https://github.com/oven-sh/bun/issues/25351
static napi_value napi_get_typeof(const Napi::CallbackInfo &info) {
@@ -1887,6 +1999,8 @@ void register_standalone_tests(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, test_napi_freeze_seal_indexed);
REGISTER_FUNCTION(env, exports, test_napi_create_external_buffer_empty);
REGISTER_FUNCTION(env, exports, test_napi_empty_buffer_info);
REGISTER_FUNCTION(env, exports, test_napi_external_buffer_finalizer);
REGISTER_FUNCTION(env, exports, get_external_buffer_finalizer_stats);
REGISTER_FUNCTION(env, exports, napi_get_typeof);
}

View File

@@ -282,6 +282,15 @@ describe.concurrent("napi", () => {
expect(result).toContain("PASS: napi_is_detached_arraybuffer returns true for empty buffer's arraybuffer");
expect(result).not.toContain("FAIL");
});
// Regression test for https://github.com/oven-sh/bun/issues/26423
// Verifies that the finalizer for napi_create_external_buffer receives the correct
// data pointer and is properly tied to the ArrayBuffer's lifetime.
it("finalizer receives correct data pointer", async () => {
const result = await checkSameOutput("test_napi_external_buffer_finalizer", []);
expect(result).toContain("PASS: External buffer created with correct data pointer and length");
expect(result).not.toContain("FAIL");
});
});
describe("napi_async_work", () => {

View File

@@ -0,0 +1,171 @@
// Regression test for https://github.com/oven-sh/bun/issues/26423
// napi_create_external_buffer was using a two-stage finalization which caused
// a race condition where the native memory could be freed/reused before the
// JavaScript buffer was actually collected.
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
describe("napi_create_external_buffer", () => {
test("finalizer is properly tied to ArrayBuffer lifecycle", async () => {
// This test verifies that when napi_create_external_buffer is called,
// the finalizer callback receives the correct data pointer when the
// buffer is garbage collected. Previously, the implementation used a
// two-stage finalization approach which caused race conditions.
using dir = tempDir("napi-buffer-test", {
"binding.gyp": JSON.stringify({
targets: [
{
target_name: "test_external_buffer",
sources: ["test_external_buffer.c"],
},
],
}),
"test_external_buffer.c": `
#include <node_api.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
static int finalizer_called = 0;
static void* received_data = NULL;
static const size_t TEST_SIZE = 1024;
static void my_finalizer(napi_env env, void* data, void* hint) {
finalizer_called = 1;
received_data = data;
// Free the memory that was allocated
free(data);
}
static napi_value create_external_buffer(napi_env env, napi_callback_info info) {
// Reset state
finalizer_called = 0;
received_data = NULL;
// Allocate memory
char* data = (char*)malloc(TEST_SIZE);
if (!data) {
napi_throw_error(env, NULL, "malloc failed");
return NULL;
}
// Fill with a pattern
memset(data, 0x42, TEST_SIZE);
napi_value buffer;
napi_status status = napi_create_external_buffer(
env, TEST_SIZE, data, my_finalizer, NULL, &buffer);
if (status != napi_ok) {
free(data);
napi_throw_error(env, NULL, "napi_create_external_buffer failed");
return NULL;
}
return buffer;
}
static napi_value get_finalizer_called(napi_env env, napi_callback_info info) {
napi_value result;
napi_get_boolean(env, finalizer_called, &result);
return result;
}
static napi_value get_original_data_addr(napi_env env, napi_callback_info info) {
napi_value result;
napi_create_bigint_uint64(env, (uint64_t)received_data, &result);
return result;
}
static napi_value init(napi_env env, napi_value exports) {
napi_property_descriptor props[] = {
{ "createExternalBuffer", NULL, create_external_buffer, NULL, NULL, NULL, napi_default, NULL },
{ "getFinalizerCalled", NULL, get_finalizer_called, NULL, NULL, NULL, napi_default, NULL },
{ "getOriginalDataAddr", NULL, get_original_data_addr, NULL, NULL, NULL, napi_default, NULL },
};
napi_define_properties(env, exports, 3, props);
return exports;
}
NAPI_MODULE(test_external_buffer, init)
`,
"test.js": `
const addon = require('./build/Release/test_external_buffer.node');
// Create an external buffer
let buf = addon.createExternalBuffer();
// Verify the buffer has the correct length
if (buf.length !== 1024) {
console.log("FAIL: Buffer length is " + buf.length + " instead of 1024");
process.exit(1);
}
// Verify the data wasn't corrupted
for (let i = 0; i < buf.length; i++) {
if (buf[i] !== 0x42) {
console.log("FAIL: Data corrupted at index " + i + ": expected 0x42, got " + buf[i].toString(16));
process.exit(1);
}
}
console.log("PASS: Buffer created with correct data");
// Clear reference and trigger GC
buf = null;
if (process.isBun) {
Bun.gc(true);
} else if (global.gc) {
global.gc();
}
// Small delay to let finalizers run
setTimeout(() => {
console.log("PASS: Test completed successfully");
}, 100);
`,
"package.json": JSON.stringify({
name: "test-external-buffer",
version: "1.0.0",
private: true,
}),
});
// Build the addon
const buildResult = Bun.spawnSync({
cmd: ["npx", "node-gyp", "rebuild"],
cwd: String(dir),
env: bunEnv,
stdio: ["inherit", "pipe", "pipe"],
});
if (!buildResult.success) {
console.log("Build stderr:", buildResult.stderr.toString());
throw new Error("node-gyp build failed");
}
// Run the test
await using proc = Bun.spawn({
cmd: [bunExe(), "--expose-gc", "test.js"],
cwd: String(dir),
env: bunEnv,
stdio: ["inherit", "pipe", "pipe"],
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (stderr) {
console.log("Test stderr:", stderr);
}
expect(stdout).toContain("PASS: Buffer created with correct data");
expect(stdout).not.toContain("FAIL");
expect(exitCode).toBe(0);
}, 60000);
});