node-addon-api icon indicating copy to clipboard operation
node-addon-api copied to clipboard

How about add C++20 coroutine support to `Napi::Value`?

Open toyobayashi opened this issue 1 year ago • 6 comments

embind already has coroutine implementation

https://github.com/emscripten-core/emscripten/blob/b5b7fedda835bdf8f172a700726109a4a3899909/system/include/emscripten/val.h#L703-L789

I just now tried to write a toy version, that makes it possible to co_await a JavaScript Promise in C++.

class CoPromise : public Napi::Promise
#include <coroutine>
#include <exception>
#include <napi.h>

class CoPromise : public Napi::Promise {
 public:
  CoPromise(napi_env env, napi_value value): Napi::Promise(env, value) {};

  class promise_type {
   private:
    Napi::Env env_;
    Napi::Promise::Deferred deferred_;
   
   public:
    promise_type(const Napi::CallbackInfo& info):
      env_(info.Env()), deferred_(Napi::Promise::Deferred::New(info.Env())) {}

    CoPromise get_return_object() const {
      return deferred_.Promise().As<CoPromise>();
    }
    std::suspend_never initial_suspend () const noexcept { return {}; }
    std::suspend_never final_suspend () const noexcept { return {}; }

    void unhandled_exception() const {
      std::exception_ptr exception = std::current_exception();
      try {
        std::rethrow_exception(exception);
      } catch (const Napi::Error& e) {
        deferred_.Reject(e.Value());
      } catch (const std::exception &e) {
        deferred_.Reject(Napi::Error::New(env_, e.what()).Value());
      } catch (const std::string& e) {
        deferred_.Reject(Napi::Error::New(env_, e).Value());
      } catch (const char* e) {
        deferred_.Reject(Napi::Error::New(env_, e).Value());
      } catch (...) {
        deferred_.Reject(Napi::Error::New(env_, "Unknown Error").Value());
      }
    }

    void return_value(Value value) const {
      Resolve(value);
    }

    void Resolve(Value value) const {
      deferred_.Resolve(value);
    }

    void Reject(Value value) const {
      deferred_.Reject(value);
    }
  };

  class Awaiter {
   private:
    Napi::Promise promise_;
    std::coroutine_handle<promise_type> handle_;
    Napi::Value fulfilled_result_;

   public:
    Awaiter(Napi::Promise promise): promise_(promise), handle_(), fulfilled_result_() {}

    constexpr bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<promise_type> handle) {
      handle_ = handle;
      promise_.Get("then").As<Napi::Function>().Call(promise_, {
        Napi::Function::New(promise_.Env(), [this](const Napi::CallbackInfo& info) -> Value {
          fulfilled_result_ = info[0];
          handle_.resume();
          return info.Env().Undefined();
        }),
        Napi::Function::New(promise_.Env(), [this](const Napi::CallbackInfo& info) -> Value {
          handle_.promise().Reject(info[0]);
          handle_.destroy();
          return info.Env().Undefined();
        })
      });
    }

    Value await_resume() const {
      return fulfilled_result_;
    }
  };

  Awaiter operator co_await() const {
    return Awaiter(*this);
  }
};

binding.gyp
{
  "target_defaults": {
    "cflags_cc": [ "-std=c++20" ],
    "xcode_settings": {
      "CLANG_CXX_LANGUAGE_STANDARD":"c++20"
    },
    # https://github.com/nodejs/node-gyp/issues/1662#issuecomment-754332545
    "msbuild_settings": {
      "ClCompile": {
        "LanguageStandard": "stdcpp20"
      }
    },
  },
  "targets": [
    {
      "target_name": "binding",
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      "dependencies": [
        "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except"
      ],
      "sources": [
        "src/binding.cpp"
      ]
    }
  ]
}

binding.cpp
CoPromise NestedCoroutine(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::Value async_function = info[0];
  if (!async_function.IsFunction()) {
    throw Napi::Error::New(env, "not function");
  }
  Napi::Value result = co_await async_function.As<Napi::Function>()({}).As<CoPromise>();
  co_return Napi::Number::New(env, result.As<Napi::Number>().DoubleValue() * 2);
}

CoPromise Coroutine(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::Value number = co_await NestedCoroutine(info);
  co_return Napi::Number::New(env, number.As<Napi::Number>().DoubleValue() * 2);
}

CoPromise CoroutineThrow(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::Value number = co_await NestedCoroutine(info);
  throw Napi::Error::New(env, "test error");
  co_return Napi::Value();
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("coroutine", Napi::Function::New(env, Coroutine));
  exports.Set("coroutineThrow", Napi::Function::New(env, CoroutineThrow));
  return exports;
}

NODE_API_MODULE(addon, Init)

index.js
const binding = require('./build/Release/binding.node')

async function main () {
  await binding.coroutine(function () {
    return new Promise((resolve, _) => {
      setTimeout(() => {
        resolve(42)
      }, 1000)
    })
  }).then(value => {
    console.log(value)
  }).catch(err => {
    console.error('JS caught error', err)
  })

  await binding.coroutine(function () {
    return new Promise((_, reject) => {
      setTimeout(() => {
        reject(42)
      }, 1000)
    })
  }).then(value => {
    console.log(value)
  }).catch(err => {
    console.error('JS caught error', err)
  })

  await binding.coroutineThrow(function () {
    return new Promise((resolve, _) => {
      setTimeout(() => {
        resolve(42)
      }, 1000)
    })
  }).then(value => {
    console.log(value)
  }).catch(err => {
    console.error('JS caught error', err)
  })
}

main()

node index.js
(1000ms after)
168
(1000ms after)
JS caught error 42
(1000ms after)
JS caught error [Error: test error]
output

toyobayashi avatar Feb 26 '24 15:02 toyobayashi

FWIW, node-addon-api is restricted to the same build restrictions as node, which is c++17.

KevinEady avatar Feb 26 '24 16:02 KevinEady

@KevinEady you are right, but we have two choices:

  • optin the feature in case C++ 20 is enabled
  • Create this new api as an external module

Whaty do you think about?

NickNaso avatar Feb 26 '24 16:02 NickNaso

I think in instances where functionality is added that is not specifically a wrapper for Node-API functionality, we defer to placing the functionality in a separate module/package owned by the original code writer (and therefore not maintained by us), eg. https://github.com/nodejs/node-addon-api/issues/1163

KevinEady avatar Feb 26 '24 16:02 KevinEady

@KevinEady Is it a better choice to add promise_type and operator co_await to Napi::Value instead of Napi::Promise? It's similar to JavaScript that can await any type of JavaScript values and the coroutine suspends when await a Thenable. If go this way, since the Napi::Value is the base class of all values, I think place changes of Napi::Value in node-addon-api repo is reasonable. Also adding #if __cplusplus >= 202002L guard to allow optin.

{
  "cflags_cc": [ "-std=c++20" ],
  "xcode_settings": {
    "CLANG_CXX_LANGUAGE_STANDARD":"c++20",
    "OTHER_CPLUSPLUSFLAGS": [ "-std=c++20" ]
  },
  # https://github.com/nodejs/node-gyp/issues/1662#issuecomment-754332545
  "msbuild_settings": {
    "ClCompile": {
      "LanguageStandard": "stdcpp20"
    }
  },
}

for example, I changed my toy implementation and placed it in node_modules/node-addon-api/napi.h

diff --git a/node_modules/node-addon-api/napi.h b/node_modules/node-addon-api/napi.h
diff --git a/node_modules/node-addon-api/napi.h b/node_modules/node-addon-api/napi.h
index 9f20cb8..8edc558 100644
--- a/node_modules/node-addon-api/napi.h
+++ b/node_modules/node-addon-api/napi.h
@@ -20,6 +20,11 @@
 #include <string>
 #include <vector>
 
+#if __cplusplus >= 202002L
+#include <coroutine>
+#include <variant>
+#endif
+
 // VS2015 RTM has bugs with constexpr, so require min of VS2015 Update 3 (known
 // good version)
 #if !defined(_MSC_VER) || _MSC_FULL_VER >= 190024210
@@ -169,6 +174,10 @@ namespace NAPI_CPP_CUSTOM_NAMESPACE {
 // Forward declarations
 class Env;
 class Value;
+#if __cplusplus >= 202002L
+class ValuePromiseType;
+class ValueAwaiter;
+#endif
 class Boolean;
 class Number;
 #if NAPI_VERSION > 5
@@ -482,6 +491,12 @@ class Value {
   MaybeOrValue<Object> ToObject()
       const;  ///< Coerces a value to a JavaScript object.
 
+#if __cplusplus >= 202002L
+  using promise_type = ValuePromiseType;
+
+  ValueAwaiter operator co_await() const;
+#endif
+
  protected:
   /// !cond INTERNAL
   napi_env _env;
@@ -3189,6 +3204,117 @@ class Addon : public InstanceWrap<T> {
 };
 #endif  // NAPI_VERSION > 5
 
+#if __cplusplus >= 202002L
+
+class ValuePromiseType {
+ private:
+  Env env_;
+  Promise::Deferred deferred_;
+
+ public:
+  ValuePromiseType(const CallbackInfo& info):
+    env_(info.Env()), deferred_(Promise::Deferred::New(info.Env())) {}
+
+  Value get_return_object() const {
+    return deferred_.Promise();
+  }
+  std::suspend_never initial_suspend () const NAPI_NOEXCEPT { return {}; }
+  std::suspend_never final_suspend () const NAPI_NOEXCEPT { return {}; }
+
+  void unhandled_exception() const {
+    std::exception_ptr exception = std::current_exception();
+#ifdef NAPI_CPP_EXCEPTIONS
+    try {
+      std::rethrow_exception(exception);
+    } catch (const Error& e) {
+      deferred_.Reject(e.Value());
+    } catch (const std::exception &e) {
+      deferred_.Reject(Error::New(env_, e.what()).Value());
+    } catch (const Value& e) {
+      deferred_.Reject(e);
+    } catch (const std::string& e) {
+      deferred_.Reject(Error::New(env_, e).Value());
+    } catch (const char* e) {
+      deferred_.Reject(Error::New(env_, e).Value());
+    } catch (...) {
+      deferred_.Reject(Error::New(env_, "Unknown Error").Value());
+    }
+#else
+    std::rethrow_exception(exception);
+#endif
+  }
+
+  void return_value(Value value) const {
+    if (env_.IsExceptionPending()) {
+      Reject(env_.GetAndClearPendingException().Value());
+    } else {
+      Resolve(value);
+    }
+  }
+
+  void Resolve(Value value) const {
+    deferred_.Resolve(value);
+  }
+
+  void Reject(Value value) const {
+    deferred_.Reject(value);
+  }
+};
+
+class ValueAwaiter {
+ private:
+  std::variant<Value, Value, Value> state_;
+
+ public:
+  ValueAwaiter(Value value): state_(std::in_place_index<0>, value) {}
+
+  bool await_ready() {
+    const Value* value = std::get_if<0>(&state_);
+    if (value->IsPromise() || (value->IsObject() && value->As<Object>().Get("then").IsFunction())) {
+      return false;
+    }
+    state_.emplace<1>(*value);
+    return true;
+  }
+
+  void await_suspend(std::coroutine_handle<ValuePromiseType> handle) {
+    Object thenable = std::get_if<0>(&state_)->As<Object>();
+    Env env = thenable.Env();
+    thenable.Get("then").As<Function>().Call(thenable, {
+      Function::New(env, [this, handle](const CallbackInfo& info) -> Value {
+        state_.emplace<1>(info[0]);
+        handle.resume();
+        return info.Env().Undefined();
+      }),
+      Function::New(env, [this, handle](const CallbackInfo& info) -> Value {
+        state_.emplace<2>(info[0]);
+#ifdef NAPI_CPP_EXCEPTIONS
+        handle.resume();
+#else
+        handle.promise().Reject(info[0]);
+        handle.destroy();
+#endif
+        return info.Env().Undefined();
+      })
+    });
+  }
+
+  Value await_resume() const {
+    const Value* ok = std::get_if<1>(&state_);
+    if (ok) {
+      return *ok;
+    }
+    const Value* err = std::get_if<2>(&state_);
+    NAPI_THROW(Error(err->Env(), *err), Value());
+  }
+};
+
+inline ValueAwaiter Value::operator co_await() const {
+  return { *this };
+}
+
+#endif  // __cplusplus >= 202002L
+
 #ifdef NAPI_CPP_CUSTOM_NAMESPACE
 }  // namespace NAPI_CPP_CUSTOM_NAMESPACE
 #endif

Then the usage becomes more nature

#ifdef NAPI_CPP_EXCEPTIONS
#define NAPI_THROW_CO_RETURN(e, ...) throw e
#else
#define NAPI_THROW_CO_RETURN(e, ...)                                           \
  do {                                                                         \
    (e).ThrowAsJavaScriptException();                                          \
    co_return __VA_ARGS__;                                                     \
  } while (0)
#endif

Napi::Value NestedCoroutine(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::Value async_function = info[0];
  if (!async_function.IsFunction()) {
    NAPI_THROW_CO_RETURN(Napi::Error::New(env, "not function"), Napi::Value());
  }
  Napi::Value result = co_await async_function.As<Napi::Function>()({});
  result = co_await result; // ok
  co_return Napi::Number::New(env, result.As<Napi::Number>().DoubleValue() * 2);
}

Napi::Value Coroutine(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  Napi::Value number = co_await NestedCoroutine(info);
  co_return Napi::Number::New(env, number.As<Napi::Number>().DoubleValue() * 2);
}

Napi::Value CoroutineThrow(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  co_await NestedCoroutine(info);
  NAPI_THROW_CO_RETURN(Napi::Error::New(env, "test error"), Napi::Value());
  co_return Napi::Value();
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("coroutine", Napi::Function::New(env, Coroutine));
  exports.Set("coroutineThrow", Napi::Function::New(env, CoroutineThrow));
  return exports;
}

It would be cool if node-addon-api can get this feature.

toyobayashi avatar Feb 27 '24 03:02 toyobayashi

https://github.com/nodejs/node-addon-api/compare/main...toyobayashi:node-addon-api:coroutine

I added changes and test in my fork. This is a very simple implementation and have not tested complex use case.

toyobayashi avatar Mar 01 '24 10:03 toyobayashi

Following up on @KevinEady's earlier comment about node-addon-api being a thin wrapper, this is documented in https://github.com/nodejs/node-addon-api/blob/main/CONTRIBUTING.md#source-changes.

We discussed in the node-api team meeting today and based on our documented approach we believe this functionality is best covered in a separated module outside of node-addon-api unless that is impossible.

Some team members are going to take a deeper look and we'll talk about it again next time.

mhdawson avatar Mar 01 '24 16:03 mhdawson