Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
mmomtchev opened this issue Apr 30, 2025 · 3 comments

Comments

@mmomtchev
Copy link

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);
});
@mmomtchev
Copy link
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'

@mmomtchev
Copy link
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'
}

@mmomtchev
Copy link
Author

mmomtchev commented May 1, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Need Triage
Development

No branches or pull requests

1 participant