Skip to content
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

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

Open
toyobayashi opened this issue Feb 26, 2024 · 6 comments
Open

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

toyobayashi opened this issue Feb 26, 2024 · 6 comments

Comments

@toyobayashi
Copy link
Contributor

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
@KevinEady
Copy link
Contributor

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

@NickNaso
Copy link
Member

@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?

@KevinEady
Copy link
Contributor

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. #1163

@toyobayashi
Copy link
Contributor Author

toyobayashi commented Feb 27, 2024

@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 toyobayashi changed the title How about add C++20 coroutine support to Napi::Promise? How about add C++20 coroutine support to Napi::Value? Feb 27, 2024
@toyobayashi
Copy link
Contributor Author

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.

@mhdawson
Copy link
Member

mhdawson commented Mar 1, 2024

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.

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

No branches or pull requests

4 participants