Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 22 additions & 36 deletions src/jsc/bindings/napi.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
extern "C" void napi_internal_suppress_crash_on_abort_if_desired();
extern "C" void Bun__crashHandler(const char* message, size_t message_len);

static bool equal(napi_async_cleanup_hook_handle, napi_async_cleanup_hook_handle);

namespace Napi {

static constexpr int DEFAULT_NAPI_VERSION = 10;
Expand Down Expand Up @@ -80,15 +78,7 @@

bool operator==(const AsyncCleanupHook& other) const
{
if (this == &other || (function == other.function && data == other.data)) {
if (handle && other.handle) {
return equal(handle, other.handle);
}

return !handle && !other.handle;
}

return false;
return this == &other || (function == other.function && data == other.data && handle == other.handle);
}
};

Expand Down Expand Up @@ -129,27 +119,18 @@
napi_status defineProperty(napi_env env, JSC::JSObject* to, const napi_property_descriptor& property, JSC::ThrowScope& scope);
}

// Owned by the addon: allocated by napi_add_async_cleanup_hook and freed only
// by napi_remove_async_cleanup_hook, which the addon may call after the hook
// itself has already run (that call is how it signals completion).
Comment thread
robobun marked this conversation as resolved.
struct napi_async_cleanup_hook_handle__ {
napi_env env;
Napi::HookSet::iterator iter;

napi_async_cleanup_hook_handle__(napi_env env, decltype(iter) iter)
explicit napi_async_cleanup_hook_handle__(napi_env env)
: env(env)
, iter(iter)
{
}

bool operator==(const napi_async_cleanup_hook_handle__& other) const
{
return this == &other || (env == other.env && iter == other.iter);
}
};

static bool equal(napi_async_cleanup_hook_handle one, napi_async_cleanup_hook_handle two)
{
return one == two || *one == *two;
}

#define NAPI_ABORT(message) \
do { \
napi_internal_suppress_crash_on_abort_if_desired(); \
Expand Down Expand Up @@ -382,11 +363,10 @@
}
}

auto handle = std::make_unique<napi_async_cleanup_hook_handle__>(this, m_cleanupHooks.end());
auto handle = std::make_unique<napi_async_cleanup_hook_handle__>(this);

auto [iter, inserted] = m_cleanupHooks.emplace(Napi::AsyncCleanupHook(function, handle.get(), data, ++m_cleanupHookCounter));
bool inserted = m_cleanupHooks.emplace(Napi::AsyncCleanupHook(function, handle.get(), data, ++m_cleanupHookCounter)).second;
NAPI_RELEASE_ASSERT(inserted, "Attempted to add a duplicate async NAPI environment cleanup hook");
handle->iter = iter;
return handle.release();
}

Expand All @@ -396,19 +376,21 @@
return false; // Invalid handle
}

for (const auto& hook : m_cleanupHooks) {
if (auto* async = std::get_if<Napi::AsyncCleanupHook>(&hook)) {
for (auto iter = m_cleanupHooks.begin(), end = m_cleanupHooks.end(); iter != end; ++iter) {
if (auto* async = std::get_if<Napi::AsyncCleanupHook>(&*iter)) {
if (async->handle == handle) {
m_cleanupHooks.erase(handle->iter);
delete handle;
return true;
m_cleanupHooks.erase(iter);
break;
}
}
}

// Node.js silently ignores removal of non-existent handles
// See: node/src/node_api.cc:849-855
return false;
// The handle is freed here unconditionally, matching Node.js
// (node/src/node_api.cc napi_remove_async_cleanup_hook): when drain()
// has already run the hook (and erased it from the set), the addon
// still owns the handle and this call is its completion signal.
Comment thread
robobun marked this conversation as resolved.
Outdated
delete handle;
return true;

Check warning on line 393 in src/jsc/bindings/napi.h

View check run for this annotation

Claude / Claude Code Review

removeAsyncCleanupHook: dead bool return and redundant null check

Now that this function unconditionally deletes the handle and returns `true`, the `bool` return type carries no information — the sole caller (`napi_remove_async_cleanup_hook` at napi.cpp:3298) already null-checks `handle` and discards the return value. Since this PR rewrote the function body and made the return degenerate, it should become `void` and drop the redundant `if (handle == nullptr) return false;` guard.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}

bool inGC() const
Expand Down Expand Up @@ -656,8 +638,12 @@
} else {
auto& async = std::get<Napi::AsyncCleanupHook>(hook);
ASSERT(async.function != nullptr);
// The addon keeps ownership of the handle: it frees it via
// napi_remove_async_cleanup_hook, possibly after this hook
// returns (e.g. from a threadsafe function finalizer that runs
// later during cleanup()). Deleting it here is a use-after-free
// in that case (#37201).
Comment thread
robobun marked this conversation as resolved.
Outdated
async.function(async.handle, async.data);
delete async.handle;
}
// Same invariant as the finalizer loop in cleanup(): a hook
// that leaked an exception must not poison the next hook.
Expand Down
11 changes: 11 additions & 0 deletions test/napi/napi-app/binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
],
},
{
"target_name": "test_async_cleanup_hook_tsfn_release",
"sources": ["test_async_cleanup_hook_tsfn_release.c"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"libraries": [],
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
"defines": [
"NAPI_DISABLE_CPP_EXCEPTIONS",
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
],
},
{
"target_name": "test_cleanup_hook_duplicates",
"sources": ["test_cleanup_hook_duplicates.c"],
Expand Down
5 changes: 5 additions & 0 deletions test/napi/napi-app/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,11 @@ nativeTests.test_async_cleanup_hook_remove_nonexistent = () => {
addon.test();
};

nativeTests.test_async_cleanup_hook_tsfn_release = () => {
const addon = require("./build/Debug/test_async_cleanup_hook_tsfn_release.node");
addon.start();
};

nativeTests.test_cleanup_hook_duplicates = () => {
const addon = require("./build/Debug/test_cleanup_hook_duplicates.node");
addon.test();
Expand Down
65 changes: 65 additions & 0 deletions test/napi/napi-app/test_async_cleanup_hook_tsfn_release.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// An async cleanup hook releases a threadsafe function, and the threadsafe
// function's finalizer (which runs later during env teardown) calls
// napi_remove_async_cleanup_hook. The handle must stay valid until the addon
// removes it; freeing it as soon as the hook returns is a use-after-free.
// See https://github.com/oven-sh/bun/issues/37201
#define NAPI_EXPERIMENTAL
#include <node_api.h>
#include <stdio.h>

// Statuses are printed (and compared against Node's output by the test), so a
// call that fails without crashing still fails the test.
#define CHECK(expr) \
do { \
napi_status status_ = (expr); \
if (status_ != napi_ok) { \
printf(#expr " failed: status=%d\n", status_); \
fflush(stdout); \
return NULL; \
} \
} while (0)

static napi_async_cleanup_hook_handle hook_handle;
static napi_threadsafe_function tsfn;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static void async_cleanup_hook(napi_async_cleanup_hook_handle handle, void* arg) {
printf("async cleanup hook fired\n");
fflush(stdout);
napi_status status = napi_release_threadsafe_function(tsfn, napi_tsfn_release);
printf("released tsfn: status=%d\n", status);
fflush(stdout);
}

static void tsfn_finalize(napi_env env, void* data, void* hint) {
printf("tsfn finalize: removing async cleanup hook\n");
fflush(stdout);
napi_status status = napi_remove_async_cleanup_hook(hook_handle);
printf("async cleanup hook removed: status=%d\n", status);
fflush(stdout);
Comment thread
robobun marked this conversation as resolved.
}

static void call_js_cb(napi_env env, napi_value js_cb, void* ctx, void* data) {}

static napi_value noop(napi_env env, napi_callback_info info) {
return NULL;
}

static napi_value start(napi_env env, napi_callback_info info) {
napi_value name;
CHECK(napi_create_string_utf8(env, "repro", NAPI_AUTO_LENGTH, &name));
napi_value js_cb;
CHECK(napi_create_function(env, "noop", NAPI_AUTO_LENGTH, noop, NULL, &js_cb));
CHECK(napi_create_threadsafe_function(env, js_cb, NULL, name, 0, 1, NULL, tsfn_finalize, NULL, call_js_cb, &tsfn));
CHECK(napi_unref_threadsafe_function(env, tsfn));
CHECK(napi_add_async_cleanup_hook(env, async_cleanup_hook, NULL, &hook_handle));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return NULL;
}

napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, "start", NAPI_AUTO_LENGTH, start, NULL, &fn);
napi_set_named_property(env, exports, "start", fn);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
7 changes: 7 additions & 0 deletions test/napi/napi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,13 @@
// Test that removing non-existent async hooks doesn't crash
await checkSameOutput("test_async_cleanup_hook_remove_nonexistent", []);
});

it("hook handle stays valid until the addon removes it (#37201)", async () => {
// An async cleanup hook releases a threadsafe function whose finalizer
// then calls napi_remove_async_cleanup_hook; the handle must not be
// freed when the hook returns.
await checkSameOutput("test_async_cleanup_hook_tsfn_release", []);
});

Check warning on line 1743 in test/napi/napi.test.ts

View check run for this annotation

Claude / Claude Code Review

Test lacks explicit assertion of expected lifecycle output

The test relies solely on `checkSameOutput` (Bun == Node) without pinning the expected lifecycle lines, so if both runtimes short-circuited identically the test would pass without exercising `napi_remove_async_cleanup_hook`. Since the fixture already prints `status=%d` for this purpose, consider capturing the result and asserting e.g. `expect(result).toContain("async cleanup hook removed: status=0")` and `"tsfn finalize: removing async cleanup hook"` — CodeRabbit's follow-up asked for this and t
Comment thread
claude[bot] marked this conversation as resolved.
});

describe("duplicate prevention", () => {
Expand Down