adllm Insights logo adllm Insights logo

Debugging `assertion 'cb->active_handles == 0' failed` in libuv for Node.js Native Addons

Published on by The adllm Team. Last modified: . Tags: libuv Node.js Native Addons N-API Debugging C++ Resource Leak

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). The close_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:

  1. Forgetting uv_close(): The most straightforward cause. A handle is initialized with uv_TYPENAME_init() but uv_close() is never called on it.
  2. 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.
  3. 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 trigger uv_close().
  4. Incomplete Asynchronous Operations: Native addons performing asynchronous tasks (e.g., using napi_create_async_work or directly with uv_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.
  5. 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 like uv_async_send.
  6. 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.

 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
// --- In your N-API addon's C++ code ---
#include <uv.h>
#include <stdio.h> // For fprintf

// Example: Managing a uv_timer_t
static void my_timer_close_cb(uv_handle_t* handle) {
    fprintf(stderr, "ADDON_DEBUG: Timer %p close callback executed. Freeing.\n", 
            (void*)handle);
    free(handle); // Safe to free memory here
}

static void my_timer_cb(uv_timer_t* handle) {
    fprintf(stderr, "ADDON_DEBUG: Timer %p callback fired.\n", (void*)handle);
    //
    // Perform timer actions...
    //
    
    // Example: stop and close timer after one execution for this demo
    uv_timer_stop(handle);
    fprintf(stderr, "ADDON_DEBUG: Closing timer %p from timer_cb.\n", 
            (void*)handle);
    uv_close((uv_handle_t*)handle, my_timer_close_cb);
}

// Function called from JavaScript to start the timer
void start_some_timer(uv_loop_t* loop) {
    uv_timer_t* timer_handle = (uv_timer_t*)malloc(sizeof(uv_timer_t));
    if (!timer_handle) {
        perror("ADDON_DEBUG: Failed to malloc for timer_handle");
        return;
    }
    fprintf(stderr, "ADDON_DEBUG: Initializing timer %p.\n", (void*)timer_handle);
    
    int init_status = uv_timer_init(loop, timer_handle);
    if (init_status != 0) {
        fprintf(stderr, "ADDON_DEBUG: uv_timer_init failed for %p: %s\n",
                (void*)timer_handle, uv_strerror(init_status));
        free(timer_handle); // Clean up if init failed
        return;
    }

    fprintf(stderr, "ADDON_DEBUG: Starting timer %p.\n", (void*)timer_handle);
    uv_timer_start(timer_handle, my_timer_cb, 1000, 0); // 1s, no repeat
}

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 whose my_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:

1
2
3
gdb --args node your_script_using_the_addon.js
# or
lldb -- node your_script_using_the_addon.js
  • 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).
  • 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.

 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
// --- In a cleanup hook or a specific debug function ---
#include <uv.h>
#include <stdio.h>

// Helper to get string representation of handle type
const char* uv_handle_type_name_str(uv_handle_type type) { // Renamed for clarity
    switch (type) {
        case UV_ASYNC: return "UV_ASYNC";
        case UV_CHECK: return "UV_CHECK";
        case UV_FS_EVENT: return "UV_FS_EVENT";
        // ... add all other uv_handle_type enum values from uv.h
        case UV_TIMER: return "UV_TIMER";
        case UV_TCP: return "UV_TCP";
        // Ensure all types from uv_handle_type in your libuv version are covered
        default: return "UV_UNKNOWN_HANDLE";
    }
}

static void print_active_handles_cb(uv_handle_t* handle, void* arg) {
    fprintf(stderr, "ADDON_DEBUG (uv_walk): Active handle %p, type: %s. Closing: %s\n",
            (void*)handle,
            uv_handle_type_name_str(uv_handle_get_type(handle)), // Use API getter
            uv_is_closing(handle) ? "yes" : "no");

    // IMPORTANT: DO NOT call uv_close() here indiscriminately during diagnostics
    // unless you are certain this is the correct place and your addon owns
    // this handle and it needs to be closed here.
    // This function is for inspection primarily.
}

// Call this from a suitable place, e.g., an N-API environment cleanup hook
void inspect_active_handles(uv_loop_t* loop) {
    fprintf(stderr, "ADDON_DEBUG: Walking active handles...\n");
    uv_walk(loop, print_active_handles_cb, NULL);
    fprintf(stderr, "ADDON_DEBUG: Handle walk complete.\n");
}

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.

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.

  1. Strictly Pair init with close: For every uv_TYPENAME_init(), there must be a corresponding uv_close() call at the appropriate time.

  2. 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() and uv_handle_get_data() to associate arbitrary data with a handle.

  3. 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 call uv_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.

  4. Asynchronous Operations: For operations queued with napi_create_async_work or uv_queue_work, ensure the execute callback and complete callback logic accounts for all created handles. If work can be cancelled or the addon unloaded, implement robust cancellation and cleanup.

  5. Thread Safety with uv_async_send: If you must interact with a libuv handle from a different thread than its loop thread, use uv_async_init() to create an async handle and uv_async_send() to safely signal the loop thread to perform the operation (like uv_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.