Node.js native addons offer a powerful way to extend Node.js with C/C++ capabilities, often for performance-critical tasks or interfacing with system libraries. This is documented extensively in the Node.js Addons documentation. Underpinning Node.js’s asynchronous behavior is libuv, a multi-platform C library providing the event loop and asynchronous I/O operations. When developing native addons that interact with libuv, developers might encounter cryptic assertions, one of which is assertion 'cb->active_handles == 0' failed
. This error is a critical signal from libuv indicating a resource management problem, specifically that libuv handles have not been properly cleaned up.
This article provides a comprehensive guide for experienced developers to understand, diagnose, and resolve this assertion. We will delve into libuv handle mechanics, common causes of leaks in native addons, effective debugging strategies, and best practices for robust handle management using N-API (Node-API) and direct libuv calls.
Understanding the Assertion: cb->active_handles == 0
To effectively debug this assertion, it’s essential to understand its components and context.
What is libuv and its Role?
Libuv is the engine driving asynchronous operations in Node.js. It provides the event loop, worker threads, and abstractions for non-blocking network I/O, asynchronous file system access, timers, child processes, and more. You can find detailed information in the official libuv documentation. Native addons can directly leverage libuv functionalities for advanced control over I/O and eventing.
Libuv Handles: The Core of Asynchronous Operations
Libuv handles represent long-lived objects that can perform operations while they are “active.” Examples include timers (uv_timer_t
), TCP sockets (uv_tcp_t
), or file system watchers (uv_fs_event_t
). Key characteristics of handles include:
- Initialization: Handles are created using functions like
uv_timer_init(loop, &timer_handle)
. - Activity: Active handles keep the libuv event loop alive (unless explicitly “unreferenced” using
uv_unref()
). - Closure: Critically, every initialized handle must be explicitly closed using
uv_close(handle, close_callback)
. Theclose_callback
is invoked once the handle is fully shut down and its resources can be safely released.
Decoding cb->active_handles == 0
The assertion cb->active_handles == 0
is an internal consistency check within libuv. While cb
isn’t universally defined without specific libuv source context, it generally refers to a control block or callback structure associated with a libuv operation, often during the cleanup phase of the event loop or a specific part of it (like uv_loop_close()
).
This assertion failing means libuv expected a particular counter tracking active handles (related to cb
) to be zero at a critical point. A non-zero value implies that one or more libuv handles, which should have been closed and their close_callback
executed, are still considered “active” by libuv. This is a definitive sign of a handle leak originating from your native addon or a library it uses.
Common Causes of Leaked Handles in Native Addons
The root cause of this assertion is almost always improper libuv handle lifecycle management. Here are the usual suspects:
- Forgetting
uv_close()
: The most straightforward cause. A handle is initialized withuv_TYPENAME_init()
butuv_close()
is never called on it. - Incorrect
uv_close()
Usage:- Premature Memory Release: Freeing the memory associated with a handle before its
uv_close_cb
has run. This can corrupt libuv’s internal state. - Calling
uv_close()
too early or too late: For instance, closing a handle before an operation it’s involved in has fully completed.
- Premature Memory Release: Freeing the memory associated with a handle before its
- Flawed N-API Object Lifecycle Management:
- If a libuv handle’s lifetime is tied to a JavaScript object managed by N-API, failing to use N-API finalizers (
napi_wrap
with a finalizer callback) or environment cleanup hooks (napi_add_env_cleanup_hook
) to triggeruv_close()
.
- If a libuv handle’s lifetime is tied to a JavaScript object managed by N-API, failing to use N-API finalizers (
- Incomplete Asynchronous Operations: Native addons performing asynchronous tasks (e.g., using
napi_create_async_work
or directly withuv_queue_work
) might create handles that are not closed if the addon unloads or an error occurs before the async operation completes and cleans up. - Thread Safety Violations: Attempting to manipulate libuv handles (especially calling
uv_close()
) from a thread different from the one running the handle’s event loop, without proper synchronization likeuv_async_send
. - Ignoring Errors: Failure to check return codes from libuv functions, which might leave handles in an indeterminate state or prevent cleanup paths from executing.
Diagnostic Strategies and Tools
Pinpointing the leaked handle(s) requires systematic investigation.
1. Initial Analysis & Simplification
- Isolate the Addon: If you have multiple native addons, try to determine which one is the source. Does the error occur when a specific addon’s functionality is invoked?
- Minimal Reproducible Example: Attempt to create the smallest possible Node.js script and C++ addon code that reproduces the assertion. This significantly simplifies debugging.
2. Strategic Logging
This is often the most effective first step. Add detailed logging around handle operations.
|
|
By observing these logs, you can trace:
- Which handles are initialized.
- Which
uv_close()
calls are made. - Which
uv_close_cb
functions are (or are not) executed. A handle initialized but whosemy_timer_close_cb
(or equivalent) never prints is a prime suspect.
3. Using Native Debuggers (GDB/LLDB)
Attach a native debugger like GDB (GNU Debugger) or LLDB to your Node.js process:
|
|
- Set Breakpoints:
- In your addon’s C++ code where handles are initialized (
uv_..._init
). - Before calls to
uv_close()
. - Inside your
uv_close_cb
functions. - Optionally, inside libuv functions like
uv_loop_close
or where the assertion occurs (requires libuv debug symbols).
- In your addon’s C++ code where handles are initialized (
- Inspect Variables: Examine handle pointers and related flags.
- Stack Traces: When the assertion hits,
bt
(backtrace) in GDB/LLDB will show the call stack leading to the failure, often providing clues about the context.
4. Leveraging uv_walk()
for Inspection (Use with Caution)
uv_walk()
iterates over all active handles in a loop. It can be used during development to list handles that your addon might have leaked.
|
|
Caveat: uv_walk()
shows all active handles in the loop, including those internal to Node.js or other addons. Only act on handles your addon is responsible for. Misusing uv_close()
here can crash the process. The function uv_handle_get_type()
retrieves the handle type, and uv_is_closing()
checks if uv_close()
has been called on it.
5. Memory Debuggers (Valgrind, ASan)
Memory corruption can indirectly lead to handle mismanagement if libuv’s internal structures are damaged.
- Valgrind (Memcheck tool):
1
valgrind --leak-check=full node your_script.js
- AddressSanitizer (ASan): Compile your addon with
-fsanitize=address -g
and run Node.js. ASan will report errors at runtime.
These tools can find use-after-free errors (e.g., freeing handle memory before its uv_close_cb
) or buffer overflows that might corrupt handle data.
Best Practices for Robust Handle Management
Preventing these assertions boils down to disciplined resource handling.
Strictly Pair
init
withclose
: For everyuv_TYPENAME_init()
, there must be a correspondinguv_close()
call at the appropriate time.The Sacred
uv_close_cb
:- The
uv_close_cb
is the only safe place to free memory allocated for the handle itself (if dynamically allocated) and any associated resources that depend on the handle being fully closed. - Do not assume
uv_close()
is synchronous. It schedules the close; the callback signals completion.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
// --- Correct memory management with uv_close_cb --- typedef struct { uv_timer_t timer_handle; // Embed handle or point to it int some_data; // Your addon-specific data } my_custom_timer_data_t; static void my_safe_close_cb(uv_handle_t* handle) { // Retrieve associated data using uv_handle_get_data or container_of my_custom_timer_data_t* timer_data = (my_custom_timer_data_t*)uv_handle_get_data(handle); fprintf(stderr, "ADDON_DEBUG: Handle %p (custom data %p) closed. Freeing.\n", (void*)handle, (void*)timer_data); // Free any resources within timer_data first if necessary // If timer_data encapsulates the handle (e.g. handle is a member) // and timer_data was malloc'd, free timer_data. // If handle itself was malloc'd separately, free(handle). free(timer_data); // Assuming timer_data is the encompassing allocated block } void create_and_manage_timer(uv_loop_t* loop) { my_custom_timer_data_t* timer_data = (my_custom_timer_data_t*)malloc(sizeof(my_custom_timer_data_t)); if (!timer_data) return; timer_data->some_data = 123; // Associate your data with the handle uv_handle_set_data((uv_handle_t*)&timer_data->timer_handle, timer_data); uv_timer_init(loop, &timer_data->timer_handle); // ... start the timer, use it ... // When it's time to clean up: fprintf(stderr, "ADDON_DEBUG: Closing handle %p (custom data %p).\n", (void*)&timer_data->timer_handle, (void*)timer_data); if (!uv_is_closing((uv_handle_t*)&timer_data->timer_handle)) { uv_close((uv_handle_t*)&timer_data->timer_handle, my_safe_close_cb); } else { // Handle already closing, data will be freed by existing close_cb fprintf(stderr, "ADDON_DEBUG: Handle %p already closing.\n", (void*)&timer_data->timer_handle); } }
You can use
uv_handle_set_data()
anduv_handle_get_data()
to associate arbitrary data with a handle.- The
N-API Lifecycles:
Finalizers: When wrapping C++ objects that manage libuv handles in JavaScript objects using
napi_wrap()
, provide a finalizer callback. This callback is invoked when the JS object is garbage-collected. This is a critical place to calluv_close()
.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
// --- Conceptual N-API finalizer for a wrapped object --- class MyNativeObject { public: uv_timer_t* _timer; // Pointer to a separately allocated uv_timer_t uv_loop_t* _loop; // ... other members ... MyNativeObject(uv_loop_t* loop) : _loop(loop), _timer(nullptr) { _timer = (uv_timer_t*)malloc(sizeof(uv_timer_t)); if (_timer) { uv_timer_init(_loop, _timer); // Store 'this' (MyNativeObject instance) in the handle's data // field so it can be retrieved in the close callback. _timer->data = this; } else { fprintf(stderr, "ADDON_ERROR: Failed to allocate timer memory.\n"); } fprintf(stderr, "ADDON_DEBUG: MyNativeObject %p created, timer %p.\n", (void*)this, (void*)_timer); } // Static finalizer called by N-API static void Finalize(napi_env env, void* finalize_data, void* hint) { MyNativeObject* self = static_cast<MyNativeObject*>(finalize_data); fprintf(stderr, "ADDON_DEBUG: N-API finalizer for MyNativeObject %p, timer %p.\n", (void*)self, (void*)self->_timer); if (self->_timer) { if (!uv_is_closing((uv_handle_t*)self->_timer)) { fprintf(stderr, "ADDON_DEBUG: Closing timer %p from finalizer.\n", (void*)self->_timer); // The close CB will free timer handle & delete 'self' uv_close((uv_handle_t*)self->_timer, [](uv_handle_t* handle_being_closed){ MyNativeObject* obj = (MyNativeObject*)handle_being_closed->data; fprintf(stderr, "ADDON_DEBUG: Timer %p (obj %p) closed via finalizer cb.\n", (void*)handle_being_closed, (void*)obj); free(handle_being_closed); // Free the uv_timer_t memory delete obj; // Delete the MyNativeObject instance }); } else { // Timer is already closing. The original close_cb is responsible. fprintf(stderr, "ADDON_DEBUG: Timer %p for obj %p already closing.\n", (void*)self->_timer, (void*)self); } } else { // _timer was null (e.g., malloc failed), just delete self. delete self; } // DO NOT 'delete self' here if uv_close was called (async). } ~MyNativeObject() { // Primarily for non-NAPI C++ cleanup if needed fprintf(stderr, "ADDON_DEBUG: MyNativeObject %p destructor. Timer %p.\n", (void*)this, (void*)_timer); // If _timer is valid, not closing, and owned, it should be closed here. // However, for N-API wrapped objects, Finalize is the main path. // This dtor might be called if 'self' is managed outside JS GC. // If _timer exists and Finalize hasn't run (e.g. C++ owner deletes): if (_timer && !uv_is_closing((uv_handle_t*)_timer)) { // This direct close is risky if event loop isn't expecting it // or if the object is still wrapped by N-API. // It is better to rely on N-API Finalize for GC'd objects. } else if (!_timer && !uv_is_closing((uv_handle_t*)_timer)) { // This condition indicates _timer is null but not closing, // which is fine, nothing to close. } } }; // When wrapping: // MyNativeObject* obj = new MyNativeObject(loop); // napi_wrap(env, js_obj, obj, MyNativeObject::Finalize, nullptr, &ref);
Environment Cleanup Hooks: Use
napi_add_env_cleanup_hook()
to register functions that run when the Node.js environment shuts down. This is suitable for cleaning up global or long-lived handles not tied to specific JS objects.
Asynchronous Operations: For operations queued with
napi_create_async_work
oruv_queue_work
, ensure theexecute
callback andcomplete
callback logic accounts for all created handles. If work can be cancelled or the addon unloaded, implement robust cancellation and cleanup.Thread Safety with
uv_async_send
: If you must interact with a libuv handle from a different thread than its loop thread, useuv_async_init()
to create an async handle anduv_async_send()
to safely signal the loop thread to perform the operation (likeuv_close()
).
Interpreting the Assertion’s Origin
While the assertion message itself is generic, the call stack obtained from a debugger (GDB/LLDB) or a core dump is invaluable. If you have libuv debug symbols installed or built Node.js in debug mode, the stack trace can point to the specific libuv function (e.g., within uv_loop_close()
, or a related internal cleanup function) where the check failed. This often reveals that the loop is being shut down while your addon’s handles are still active.
Conclusion
The assertion 'cb->active_handles == 0' failed
in libuv is a stark reminder of the need for meticulous resource management in Node.js native addons. By understanding libuv’s handle lifecycle, diligently applying uv_close()
with its callback for all initialized handles, and leveraging N-API’s lifecycle management features, developers can prevent these leaks. Systematic debugging involving logging, native debuggers, and memory analysis tools will help pinpoint the offending handles, leading to more stable and robust native modules. Remember: every uv_..._init
requires a corresponding uv_close
whose callback eventually runs.