From e522e440abaa660ae6cc950ed26d5b29572f8ea8 Mon Sep 17 00:00:00 2001 From: Kevin Eady <8634912+KevinEady@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:29:08 +0100 Subject: [PATCH 1/2] node-api: execute tsfn finalizer after queue drains when aborted A threadsafe function may utilize its context in its `call_js` callback. The context should be valid during draining of the queue when the threadsafe function is aborted. Fixes: https://github.com/nodejs/node/issues/60026 --- src/node_api.cc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/node_api.cc b/src/node_api.cc index 191b3a28f568b2..d80569d9e92e00 100644 --- a/src/node_api.cc +++ b/src/node_api.cc @@ -308,7 +308,7 @@ class ThreadSafeFunction { return napi_ok; } - void EmptyQueueAndMaybeDelete() { + void EmptyQueue() { std::queue drain_queue; { node::Mutex::ScopedLock lock(this->mutex); @@ -317,6 +317,9 @@ class ThreadSafeFunction { for (; !drain_queue.empty(); drain_queue.pop()) { call_js_cb(nullptr, nullptr, context, drain_queue.front()); } + } + + void MaybeDelete() { { node::Mutex::ScopedLock lock(this->mutex); if (thread_count > 0) { @@ -464,11 +467,12 @@ class ThreadSafeFunction { void Finalize() { v8::HandleScope scope(env->isolate); + EmptyQueue(); if (finalize_cb) { AsyncResource::CallbackScope cb_scope(&*async_resource); env->CallFinalizer(finalize_cb, finalize_data, context); } - EmptyQueueAndMaybeDelete(); + MaybeDelete(); } void CloseHandlesAndMaybeDelete(bool set_closing = false) { From d52e75c858cd5020fa149eef39f5b3e4f1403d2e Mon Sep 17 00:00:00 2001 From: Kevin Eady <8634912+KevinEady@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:14:14 +0100 Subject: [PATCH 2/2] test: add node-api tsfn test with freed context after abort Add a test where a threadsafe function's `call_js` callback uses its context which is freed in the threadsafe function's finalizer. --- .../test_threadsafe_function_abort/binding.cc | 105 ++++++++++++++++++ .../binding.gyp | 11 ++ .../test_threadsafe_function_abort/test.js | 6 + 3 files changed, 122 insertions(+) create mode 100644 test/node-api/test_threadsafe_function_abort/binding.cc create mode 100644 test/node-api/test_threadsafe_function_abort/binding.gyp create mode 100644 test/node-api/test_threadsafe_function_abort/test.js diff --git a/test/node-api/test_threadsafe_function_abort/binding.cc b/test/node-api/test_threadsafe_function_abort/binding.cc new file mode 100644 index 00000000000000..b40f4ffe809495 --- /dev/null +++ b/test/node-api/test_threadsafe_function_abort/binding.cc @@ -0,0 +1,105 @@ +#include +#include +#include + +#include +#include +#include +#include + +template +inline auto call(const char* name, Args&&... args) -> R { + napi_status status; + if constexpr (std::is_same_v) { + status = func(std::forward(args)...); + if (status == napi_ok) { + return; + } + } else { + R ret; + status = func(std::forward(args)..., &ret); + if (status == napi_ok) { + return ret; + } + } + std::fprintf(stderr, "%s: %d\n", name, status); + std::abort(); +} + +#define NAPI_CALL(ret_type, func, ...) \ + call(#func, ##__VA_ARGS__) + +class Context { + public: + ~Context() { std::fprintf(stderr, "Context: destructor called\n"); } + + std::function create = [](int value) { + std::fprintf(stderr, "Context: create called\n"); + return new int(value); + }; + + std::function get = [](void* ptr) { + std::fprintf(stderr, "Context: get called\n"); + return *static_cast(ptr); + }; + + std::function deleter = [](void* ptr) { + std::fprintf(stderr, "Context: deleter called\n"); + delete static_cast(ptr); + }; +}; + +void tsfn_callback(napi_env env, napi_value js_cb, void* ctx_p, void* data) { + auto ctx = static_cast(ctx_p); + std::fprintf(stderr, "tsfn_callback: env=%p data=%d\n", env, ctx->get(data)); + ctx->deleter(data); +} + +void tsfn_finalize(napi_env env, void* finalize_data, void* finalize_hint) { + auto ctx = static_cast(finalize_hint); + std::fprintf(stderr, + "tsfn_finalize: env=%p finalize_data=%p finalize_hint=%p\n", + env, + finalize_data, + finalize_hint); + delete ctx; +} + +auto run(napi_env env, napi_callback_info info) -> napi_value { + auto global = NAPI_CALL(napi_value, napi_get_global, env); + auto undefined = NAPI_CALL(napi_value, napi_get_undefined, env); + auto ctx = new Context(); + auto tsfn = NAPI_CALL(napi_threadsafe_function, + napi_create_threadsafe_function, + env, + nullptr, + global, + undefined, + 0, + 1 /* initial_thread_count */, + nullptr, + tsfn_finalize, + ctx, + tsfn_callback); + + NAPI_CALL(void, + napi_call_threadsafe_function, + tsfn, + ctx->create(1), + napi_tsfn_blocking); + + NAPI_CALL(void, napi_unref_threadsafe_function, env, tsfn); + + NAPI_CALL(void, + napi_release_threadsafe_function, + tsfn, + napi_threadsafe_function_release_mode::napi_tsfn_abort); + return NAPI_CALL(napi_value, napi_get_undefined, env); +} + +napi_value init(napi_env env, napi_value exports) { + return NAPI_CALL( + napi_value, napi_create_function, env, nullptr, 0, run, nullptr); +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/test/node-api/test_threadsafe_function_abort/binding.gyp b/test/node-api/test_threadsafe_function_abort/binding.gyp new file mode 100644 index 00000000000000..eb08b447a94a86 --- /dev/null +++ b/test/node-api/test_threadsafe_function_abort/binding.gyp @@ -0,0 +1,11 @@ +{ + "targets": [ + { + "target_name": "binding", + "sources": ["binding.cc"], + "cflags_cc": ["--std=c++20"], + 'cflags!': [ '-fno-exceptions', '-fno-rtti' ], + 'cflags_cc!': [ '-fno-exceptions', '-fno-rtti' ], + } + ] +} diff --git a/test/node-api/test_threadsafe_function_abort/test.js b/test/node-api/test_threadsafe_function_abort/test.js new file mode 100644 index 00000000000000..2102bb3ced3e32 --- /dev/null +++ b/test/node-api/test_threadsafe_function_abort/test.js @@ -0,0 +1,6 @@ +'use strict'; + +const common = require('../../common'); +const binding = require(`./build/${common.buildType}/binding`); + +binding();