fix(node_loader): plug 48-byte trampoline leak per metacall_await call (#578)#641
fix(node_loader): plug 48-byte trampoline leak per metacall_await call (#578)#641k5602 wants to merge 2 commits into
Conversation
a9e936b to
d977527
Compare
|
@k5602 could you reproduce this bug? Because I have had it inside the linux distributable but it does not happen inside the CI. Does your test reproduce the problem? |
|
I cannot reproduce it, I am going to leave it as a draft, if we achieve to reproduce it in some way, then we have this as reference. Upload the full logs by the way of all tests:
|
because it's suppressed, it's not internal but it runs through libnode.so so it's suppressed |
|
I was working on removing the leak suppression recently. In any case right now we also have this CI that may detect that issue better: |
|
In any case we can do peer programming for removing the suppression and review the issue. |

Description
Each call to
metacall_await/metacall_await_futureallocates aloader_impl_async_future_await_trampoline_typestruct (48 bytes) on the C++ heap and passes it into JavaScript as annapi_wrap-ped object (trampoline_ptr). When the JS promise settles,bootstrap.jscalls back intonode_loader_trampoline_resolveornode_loader_trampoline_reject, which extract the pointer vianapi_remove_wrap.napi_remove_wrapdetaches the native pointer from the JS object and suppresses the NAPI finalizer — transferring full ownership to C. Before this fix, the pointer was used and then silently abandoned, producing a 48-byte leak per settledawaitcall. Across the 3 allocations reported in #578 that accounts for the 144-byte figure observed under both ASAN and Heaptrack.Fix
Wrap the detached pointer in a
std::unique_ptrimmediately afternapi_remove_wrapin bothnode_loader_trampoline_resolveandnode_loader_trampoline_reject:loader_impl_async_future_await_trampoline trampoline = static_cast<loader_impl_async_future_await_trampoline>(result); std::unique_ptr<loader_impl_async_future_await_trampoline_type> trampoline_guard(trampoline); // trampoline_guard destructs at end of block, after return value is fully materialised return trampoline->resolve_trampoline(trampoline->node_impl, env, trampoline->resolve_callback, recv, args[1], trampoline->context);No behaviour change. The
returnexpression evaluates fully before theunique_ptrdestructor runs (end-of-block), so there is no use-after-free.Fixes #578
Type of change
Checklist:
make testorctest -VV -R <test-name>)../docker-compose.sh test &> outputand attached the output. (non-breaking single-file fix; full Docker suite not run locally)OPTION_BUILD_ADDRESS_SANITIZERor./docker-compose.sh test-address-sanitizer &> outputandOPTION_TEST_MEMORYCHECK. (built with-fsanitize=address,leak;AwaitTrampolineOwnershipRegressionpasses with zeroDirect leak/Indirect leakreports)OPTION_BUILD_THREAD_SANITIZERor./docker-compose.sh test-thread-sanitizer &> output. (fix is single-threaded ownership; no new synchronisation primitives introduced)Helgrindin case my code works with threading. (no threading changes;unique_ptrdestructs on the same thread that callednapi_remove_wrap)make clang-formatin order to format my code and my code follows the style guidelines.Test:
AwaitTrampolineOwnershipRegressionAdded to
metacall-node-async-test, exercising all three trampoline settlement paths:f(x)—new Promise(r => r(x))resolve_cbfiredg(x)—new Promise((_, r) => r(x))reject_cbfiredi()—asyncthrowreject_cbfiredPost-
metacall_destroy()assertions confirm exactly 1 resolve and 2 reject callbacks fired. Under ASAN withdetect_leaks=1the test produces a clean report with the fix applied.Additional info
this is my first pr in my GSoC campaign so i hope it's good and very welcoming to reviews from mentors.