Skip to content

Phantom Promise generated when returning a Promise to C++ triggers the unhandledRejection check #1658

Closed as not planned

Description

@mmomtchev

This is probably a V8 (or maybe Node.js) quirk, but since it is very node-addon-api-centric, I am posting it here.

If a JS function called by C++ returns a rejected Promise, there will be another phantom Promise - most probably originating in C++ code (see the traces below, it is rejected from the microtask queue) that will trigger the unhandledRejection check. This goes back at least to Node.js 16.

Ignoring the unhandledRejection check - by registering a callback or using the CLI option - solves the problem and everything works as expected.

C++ code, slightly reworked TypedThreasSafeFunction example - but the problem is not specific to it:

#include <cassert>
#include <chrono>
#include <napi.h>
#include <thread>

using namespace Napi;

using Context = Reference<Value>;
using DataType = void;
void CallJs(Napi::Env env, Function callback, Context *context, DataType *data);
using TSFN = TypedThreadSafeFunction<Context, DataType, CallJs>;
using FinalizerDataType = void;

std::thread nativeThread;
TSFN tsfn;

Value Start(const CallbackInfo &info) {
  Napi::Env env = info.Env();

  if (info.Length() < 1) {
    throw TypeError::New(env, "Expected an argument");
  } else if (!info[0].IsFunction()) {
    throw TypeError::New(env, "Expected first arg to be function");
  }

  // Create a new context set to the receiver (ie, `this`) of the function call
  Context *context = new Reference<Value>(Persistent(info.This()));

  // Create a ThreadSafeFunction
  tsfn = TSFN::New(
      env,
      info[0].As<Function>(), // JavaScript function called asynchronously
      "Resource Name",        // Name
      0,                      // Unlimited queue
      1,                      // Only one thread will use this initially
      context,
      [](Napi::Env, FinalizerDataType *,
         Context *ctx) { // Finalizer used to clean threads up
        nativeThread.join();
        delete ctx;
      });

  // Create a native thread
  nativeThread = std::thread([] {
    // Perform a blocking call
    napi_status status = tsfn.BlockingCall();
    if (status != napi_ok) {
      // Handle error
      printf("Failed\n");
    }

    std::this_thread::sleep_for(std::chrono::seconds(1));

    // Release the thread-safe function
    tsfn.Release();
  });

  return env.Undefined();
}

// Transform native data into JS data, passing it to the provided
// `callback` -- the TSFN's JavaScript function.
void CallJs(Napi::Env env, Function callback, Context *context,
            DataType *data) {
  Napi::Value r;
  // Is the JavaScript environment still available to call into, eg. the TSFN is
  // not aborted
  if (env != nullptr) {
    // On Node-API 5+, the `callback` parameter is optional; however, this
    // example does ensure a callback is provided.
    if (callback != nullptr) {
      r = callback.Call(context->Value(), 0, nullptr);
    }
  }
  if (r.IsPromise()) {
    printf("Resolving Promise from C++\n");
    napi_value js_catch = Function::New(env, [](const CallbackInfo &info) {
      printf("Caught!");
      return String::New(info.Env(), "caught");
    });
    napi_value js_then = Function::New(env, [](const CallbackInfo &info) {
      printf("Resolved!");
      return String::New(info.Env(), "resolved");
    });
    assert(r.IsObject());
    assert(r.ToObject().Get("catch").IsFunction());
    r.ToObject().Get("catch").As<Function>().Call(r, 1, &js_catch);
    r.ToObject().Get("then").As<Function>().Call(r, 1, &js_then);
    printf("Done\n");
  } else {
    printf("Didn't get a Promise\n");
  }
}

Napi::Object Init(Napi::Env env, Object exports) {
  exports.Set("start", Function::New(env, Start));
  return exports;
}

NODE_API_MODULE(clock, Init)
`binding.gyp`:
{
  "targets": [
    {
      "target_name": "promise-from-cpp",
      "includes": ["/usr/local/lib/node_modules/node-addon-api/except.gypi"],
      "include_dirs": ["/usr/local/lib/node_modules/node-addon-api"],
      "sources": ["promise-from-cpp.cc"]
    }
  ]
}

JS code:

const dll = require('./build/Debug/promise-from-cpp.node');

dll.start(() => {
  return Promise.reject(1337);
});

Activity

  1. mmomtchev commented on Apr 30, 2025

    @mmomtchev
    Author

    This is from instrumenting lib/internal/process/promises.js:

    Trace: promiseRejectHandler 0 Promise { <rejected> 1337 } 1337
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at Function.reject (<anonymous>)
        at Object.<anonymous> (/Users/mmom/src/swig/tmp/run.js:4:18)
    Trace: unhandledRejection Promise { <rejected> 1337 } 1337
        at unhandledRejection (node:internal/process/promises:264:11)
        at promiseRejectHandler (node:internal/process/promises:193:7)
        at Function.reject (<anonymous>)
        at Object.<anonymous> (/Users/mmom/src/swig/tmp/run.js:4:18)
    Resolving Promise from C++
    Trace: promiseRejectHandler 1 Promise { <rejected> 1337 } undefined
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at Promise.then (<anonymous>)
        at Promise.catch (<anonymous>)
    have seen     <--- a check whether this Promise has already been seen - this is the handler after rejection event
    Trace: handleRejection Promise { <rejected> 1337 }
        at handledRejection (node:internal/process/promises:279:11)
        at promiseRejectHandler (node:internal/process/promises:196:7)
        at Promise.then (<anonymous>)
        at Promise.catch (<anonymous>)
    backpedal. <--- the promise is removed from pendingUnhandledRejections by handledRejection in promises.js
    Done
    Caught!Trace: promiseRejectHandler 0 Promise { <rejected> 1337 } 1337 <---- this Promise is a different Promise, where is it coming from???
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    Trace: unhandledRejection Promise { <rejected> 1337 } 1337
        at unhandledRejection (node:internal/process/promises:264:11)
        at promiseRejectHandler (node:internal/process/promises:193:7)
        at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    Trace: processPromiseRejections
        at processPromiseRejections (node:internal/process/promises:448:11)
        at process.processTicksAndRejections (node:internal/process/task_queues:106:32)
    node:internal/process/promises:400
          new UnhandledPromiseRejection(reason);
          ^
    
    UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "1337".
        at throwUnhandledRejectionsMode (node:internal/process/promises:400:7)
        at processPromiseRejections (node:internal/process/promises:484:17)
        at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
      code: 'ERR_UNHANDLED_REJECTION'
    
  2. mmomtchev commented on Apr 30, 2025

    @mmomtchev
    Author

    If the Promise is marked as follows:

    const dll = require('./build/Debug/promise-from-cpp.node');
    
    dll.start(() => {
      const q = Promise.reject(1337);
      q.mark = true;
      return q;
    });

    then the second Promise won't carry this marker:

    Trace: promiseRejectHandler 0 Promise { <rejected> 1337 } 1337
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at Function.reject (<anonymous>)
        at Object.<anonymous> (/Users/mmom/src/swig/tmp/run.js:4:21)
    Trace: unhandledRejection Promise { <rejected> 1337 } 1337
        at unhandledRejection (node:internal/process/promises:264:11)
        at promiseRejectHandler (node:internal/process/promises:193:7)
        at Function.reject (<anonymous>)
        at Object.<anonymous> (/Users/mmom/src/swig/tmp/run.js:4:21)
    Resolving Promise from C++
    Trace: promiseRejectHandler 1 Promise { <rejected> 1337, mark: true } undefined
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at Promise.then (<anonymous>)
        at Promise.catch (<anonymous>)
    have seen
    Trace: handleRejection Promise { <rejected> 1337, mark: true }
        at handledRejection (node:internal/process/promises:279:11)
        at promiseRejectHandler (node:internal/process/promises:196:7)
        at Promise.then (<anonymous>)
        at Promise.catch (<anonymous>)
    backpedal
    Done
    Caught!Trace: promiseRejectHandler 0 Promise { <rejected> 1337 } 1337
        at promiseRejectHandler (node:internal/process/promises:185:11)
        at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    Trace: unhandledRejection Promise { <rejected> 1337 } 1337
        at unhandledRejection (node:internal/process/promises:264:11)
        at promiseRejectHandler (node:internal/process/promises:193:7)
        at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    Trace: processPromiseRejections
        at processPromiseRejections (node:internal/process/promises:448:11)
        at process.processTicksAndRejections (node:internal/process/task_queues:106:32)
    node:internal/process/promises:400
          new UnhandledPromiseRejection(reason);
          ^
    
    UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "1337".
        at throwUnhandledRejectionsMode (node:internal/process/promises:400:7)
        at processPromiseRejections (node:internal/process/promises:484:17)
        at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
      code: 'ERR_UNHANDLED_REJECTION'
    }
    
    
  3. mmomtchev commented on May 1, 2025

    @mmomtchev
    Author

    There is code in webstreams that suspiciously look as a workaround for this issue:
    https://github.com/nodejs/node/blob/fc054bbbb1005c924799c80b07eb0e7ed0278b99/lib/internal/webstreams/util.js#L186

  4. legendecas commented on Jun 13, 2025

    @legendecas
    Member

    In snippet code, a promise .then call is only been invoked with a fulfillment handler, and the result promise is not hangled.

    A correct way to do is:

    -    r.ToObject().Get("catch").As<Function>().Call(r, 1, &js_catch);
    -    r.ToObject().Get("then").As<Function>().Call(r, 1, &js_then);
    +    napi_value args[] = {js_then, js_catch};
    +    r.ToObject().Get("then").As<Function>().Call(r, 2, args);

    This is identical to writing it in JS:

    - promise.catch(onCaught);
    - promise.then(onFulfill);
    + promise.then(onFulfill, onCaught);

    I don't think this is a node-addon-api issue. Please feel free to reopen if there is any action on node-addon-api.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions