Skip to content

node-api: execute tsfn finalizer after queue drains when aborted#61956

Open
KevinEady wants to merge 3 commits intonodejs:mainfrom
KevinEady:fix-tsfn-finalizer-and-queue-drain-order
Open

node-api: execute tsfn finalizer after queue drains when aborted#61956
KevinEady wants to merge 3 commits intonodejs:mainfrom
KevinEady:fix-tsfn-finalizer-and-queue-drain-order

Conversation

@KevinEady
Copy link
Contributor

Execute a threadsafe function's finalizer after the queue drains. It may be the case that the finalizer will free the context, which might be needed in order to properly clean up the data in the call_js callback during abort. If the finalizer runs before the queue drains, it may point to invalid memory.

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: nodejs#60026
@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/gyp
  • @nodejs/node-api

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. node-api Issues and PRs related to the Node-API. labels Feb 23, 2026
@KevinEady
Copy link
Contributor Author

KevinEady commented Feb 23, 2026

Without the fix commit, the new test fails:

Assertion failed: (ctx->state == Context::State::kCalled), function tsfn_finalize, file binding.cc, line 51.
[1]    89473 abort      out/Debug/node test/node-api/test_threadsafe_function_abort/test.js
* thread #1, name = 'node-MainThread', queue = 'com.apple.main-thread', stop reason = hit program assert
  * frame #0: 0x000000018d7e311c libsystem_kernel.dylib`__pthread_kill + 8
    frame #1: 0x000000018d81acc0 libsystem_pthread.dylib`pthread_kill + 288
    frame #2: 0x000000018d72aa50 libsystem_c.dylib`abort + 180
    frame #3: 0x000000018d729d6c libsystem_c.dylib`__assert_rtn + 284
    frame #4: 0x000000010be8b618 binding.node`tsfn_finalize(env=0x000000015cadc750, (null)=0x0000000000000000, finalize_hint=0x000000015cadcfe0) at binding.cc:51:3
    frame #5: 0x00000001001ea580 node`void node_napi_env__::CallFinalizer<false>(this=0x000000016fdfe638, env=0x000000015cadc750)(napi_env__*, void*, void*), void*, void*)::'lambda'(napi_env__*)::operator()(napi_env__*) const at node_api.cc:97:27
    frame #6: 0x00000001001ea480 node`void napi_env__::CallIntoModule<void node_napi_env__::CallFinalizer<false>(void (*)(napi_env__*, void*, void*), void*, void*)::'lambda'(napi_env__*)&, void node_napi_env__::CallbackIntoModule<false, void node_napi_env__::CallFinalizer<false>(void (*)(napi_env__*, void*, void*), void*, void*)::'lambda'(napi_env__*)>(void node_napi_env__::CallFinalizer<false>(void (*)(napi_env__*, void*, void*), void*, void*)::'lambda'(napi_env__*)&&)::'lambda'(napi_env__*, v8::Local<v8::Value>)>(this=0x000000015cadc750, call=0x000000016fdfe638, handle_exception=0x000000016fdfe5df) at js_native_api_v8.h:93:5
    frame #7: 0x00000001001ea434 node`void node_napi_env__::CallbackIntoModule<false, void node_napi_env__::CallFinalizer<false>(void (*)(napi_env__*, void*, void*), void*, void*)::'lambda'(napi_env__*)>(this=0x000000015cadc750, call=0x000000016fdfe638) at node_api.cc:138:3
    frame #8: 0x00000001001ea2a4 node`void node_napi_env__::CallFinalizer<false>(this=0x000000015cadc750, cb=(binding.node`tsfn_finalize(napi_env__*, void*, void*) at binding.cc:49), data=0x0000000000000000, hint=0x000000015cadcfe0) at node_api.cc:96:3
    frame #9: 0x00000001001ea128 node`v8impl::(anonymous namespace)::ThreadSafeFunction::Finalize(this=0x000000015cadc8a0) at node_api.cc:469:12
    frame #10: 0x00000001001ea01c node`v8impl::(anonymous namespace)::ThreadSafeFunction::CloseHandlesAndMaybeDelete(this=0x000000015cadcb88, handle=0x000000015cadc950)::'lambda'(uv_handle_s*)::operator()(uv_handle_s*) const at node_api.cc:493:18
    frame #11: 0x00000001001e9f80 node`void node::Environment::CloseHandle<uv_handle_s, v8impl::(anonymous namespace)::ThreadSafeFunction::CloseHandlesAndMaybeDelete(bool)::'lambda'(uv_handle_s*)>(this=0x000000016fdfe7a7, handle=0x000000015cadc950)::ThreadSafeFunction::CloseHandlesAndMaybeDelete(bool)::'lambda'(uv_handle_s*))::'lambda'(uv_handle_s*)::operator()(uv_handle_s*) const at env-inl.h:266:5
    frame #12: 0x00000001001e9f04 node`void node::Environment::CloseHandle<uv_handle_s, v8impl::(anonymous namespace)::ThreadSafeFunction::CloseHandlesAndMaybeDelete(bool)::'lambda'(uv_handle_s*)>(uv_handle_s*, v8impl::(anonymous namespace)::ThreadSafeFunction::CloseHandlesAndMaybeDelete(bool)::'lambda'(uv_handle_s*))::'lambda'(uv_handle_s*)::__invoke(handle=0x000000015cadc950) at env-inl.h:262:52
    frame #13: 0x0000000101f81138 node`uv__finish_close(handle=0x000000015cadc950) at core.c:363:5
    frame #14: 0x0000000101f7d54c node`uv__run_closing_handles(loop=0x0000000107f1a718) at core.c:377:5
    frame #15: 0x0000000101f7d3b8 node`uv_run(loop=0x0000000107f1a718, mode=UV_RUN_ONCE) at core.c:475:5
    frame #16: 0x0000000100153fe0 node`node::Environment::CleanupHandles(this=0x000000015d042200) at env.cc:1267:5
    frame #17: 0x0000000100154aec node`node::Environment::RunCleanup(this=0x000000015d042200) at env.cc:1323:3
    frame #18: 0x00000001000653ac node`node::FreeEnvironment(env=0x000000015d042200) at environment.cc:533:10
    frame #19: 0x0000000100060180 node`node::FunctionDeleter<node::Environment, &node::FreeEnvironment(node::Environment*)>::operator()(this=0x000000016fdfecc0, pointer=0x000000015d042200) const at util.h:674:39
    frame #20: 0x000000010005b108 node`std::__1::unique_ptr<node::Environment, node::FunctionDeleter<node::Environment, &node::FreeEnvironment(node::Environment*)>>::reset[abi:nn210104](this=0x000000016fdfecc0, __p=0x0000000000000000) at unique_ptr.h:290:7
    frame #21: 0x000000010005da28 node`std::__1::unique_ptr<node::Environment, node::FunctionDeleter<node::Environment, &node::FreeEnvironment(node::Environment*)>>::~unique_ptr[abi:nn210104](this=0x000000016fdfecc0) at unique_ptr.h:259:71
    frame #22: 0x000000010005d948 node`std::__1::unique_ptr<node::Environment, node::FunctionDeleter<node::Environment, &node::FreeEnvironment(node::Environment*)>>::~unique_ptr[abi:nn210104](this=0x000000016fdfecc0) at unique_ptr.h:259:69
    frame #23: 0x000000010031ce94 node`node::NodeMainInstance::Run(this=0x000000016fdfed98) at node_main_instance.cc:101:1
    frame #24: 0x00000001001d3974 node`node::StartInternal(argc=5, argv=0x000000015cb040e0) at node.cc:1585:24
    frame #25: 0x00000001001d3520 node`node::Start(argc=5, argv=0x000000016fdff180) at node.cc:1592:27
    frame #26: 0x00000001026ca3d8 node`main(argc=5, argv=0x000000016fdff180) at node_main.cc:97:10
    frame #27: 0x000000018d4a1058 dyld`start + 2224

With the fix commit, the process completes as expected.

Add a test where a threadsafe function's `call_js` callback uses its
context which is freed in the threadsafe function's finalizer.
@KevinEady KevinEady force-pushed the fix-tsfn-finalizer-and-queue-drain-order branch from 3ff5d15 to d52e75c Compare February 23, 2026 18:59
@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.66%. Comparing base (da5efc4) to head (e9fec8b).
⚠️ Report is 131 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61956      +/-   ##
==========================================
+ Coverage   88.84%   89.66%   +0.82%     
==========================================
  Files         674      676       +2     
  Lines      204957   206465    +1508     
  Branches    39309    39538     +229     
==========================================
+ Hits       182087   185135    +3048     
+ Misses      15088    13453    -1635     
- Partials     7782     7877      +95     
Files with missing lines Coverage Δ
src/node_api.cc 75.49% <100.00%> (+0.33%) ⬆️

... and 252 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@KevinEady KevinEady moved this from Need Triage to In Progress in Node-API Team Project Feb 26, 2026
toyobayashi added a commit to toyobayashi/emnapi that referenced this pull request Mar 6, 2026

std::function<void(void*)> deleter = [](void* ptr) {
std::fprintf(stderr, "Context: deleter called\n");
delete static_cast<int*>(ptr);
Copy link
Member

Choose a reason for hiding this comment

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

Can we add a field to Context, for example, int stage, and in each callback increment the stage and assert the value, to ensure that the callback and the finalizer are invoked in order? This could avoid relying on logs or SEGV to verify the call order.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e9fec8b

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed in the 13 March 2026 Node-API meeting, I've updated the comment on #61956 (comment) based on the updated test 👍 .

toyobayashi added a commit to toyobayashi/emnapi that referenced this pull request Mar 10, 2026
@KevinEady KevinEady force-pushed the fix-tsfn-finalizer-and-queue-drain-order branch from bc464e2 to e9fec8b Compare March 12, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. node-api Issues and PRs related to the Node-API.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants