diff --git a/src/lib/libemval.js b/src/lib/libemval.js index ada9d19e87529..2819cceb564c5 100644 --- a/src/lib/libemval.js +++ b/src/lib/libemval.js @@ -420,6 +420,20 @@ ${functionBody} }, #endif + _emval_is_catchable_cpp_exception_object__deps: [ + '$Emval', +#if !DISABLE_EXCEPTION_CATCHING || WASM_EXCEPTIONS + '$isCppExceptionObject', +#endif + ], + _emval_is_catchable_cpp_exception_object: (object) => { +#if !DISABLE_EXCEPTION_CATCHING || WASM_EXCEPTIONS + return isCppExceptionObject(Emval.toValue(object)); +#else + return false; +#endif + }, + _emval_throw__deps: ['$Emval', #if !DISABLE_EXCEPTION_CATCHING || WASM_EXCEPTIONS #if !DISABLE_EXCEPTION_CATCHING diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index bb990b3f9c142..f9059b25371e9 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -356,6 +356,7 @@ sigs = { _emval_instanceof__sig: 'ipp', _emval_invoke__sig: 'dppppp', _emval_invoke_i64__sig: 'jppppp', + _emval_is_catchable_cpp_exception_object__sig: 'ip', _emval_is_number__sig: 'ip', _emval_is_string__sig: 'ip', _emval_iter_begin__sig: 'pp', diff --git a/system/include/emscripten/val.h b/system/include/emscripten/val.h index 3e8cedf4c4758..c45d33e77d8ad 100644 --- a/system/include/emscripten/val.h +++ b/system/include/emscripten/val.h @@ -107,6 +107,7 @@ bool _emval_is_number(EM_VAL object); bool _emval_is_string(EM_VAL object); bool _emval_in(EM_VAL item, EM_VAL object); bool _emval_delete(EM_VAL object, EM_VAL property); +bool _emval_is_catchable_cpp_exception_object(EM_VAL object); [[noreturn]] bool _emval_throw(EM_VAL object); EM_VAL _emval_await(EM_VAL promise); EM_VAL _emval_iter_begin(EM_VAL iterable); @@ -670,40 +671,63 @@ inline val::iterator val::begin() const { // of the type of the parent coroutine). // This one is used for Promises represented by the `val` type. class val::awaiter { + struct state_promise { val promise; }; + struct state_coro { + std::coroutine_handle<> handle; + // Is std::coroutine_handle? + // In other words, are we also enclosed by a JS Promise? + bool is_val_promise = false; + }; + struct state_result { val result; }; + struct state_error { val error; }; + // State machine holding awaiter's current state. One of: - // - initially created with promise - // - waiting with a given coroutine handle - // - completed with a result - std::variant, val> state; + std::variant< + state_promise, // Initially created with the JS Promise we're awaiting + state_coro, // Waiting with a given coroutine handle + state_result, // Resolved with result + state_error // Rejected with error + > state; - constexpr static std::size_t STATE_PROMISE = 0; - constexpr static std::size_t STATE_CORO = 1; - constexpr static std::size_t STATE_RESULT = 2; + void await_suspend_impl(state_coro coro) { + // Use get_if instead of get because we want it to work with exceptions disabled. + auto* promise_ptr = std::get_if(&state); + assert(promise_ptr && "Invalid awaiter state: expected JS Promise. An awaiter cannot be awaited multiple times."); + internal::_emval_coro_suspend(promise_ptr->promise.as_handle(), this); + state.emplace(coro); + } public: - awaiter(const val& promise) - : state(std::in_place_index, promise) {} + awaiter(val promise) + : state(std::in_place_type, std::move(promise)) {} // just in case, ensure nobody moves / copies this type around - awaiter(awaiter&&) = delete; + awaiter(const awaiter&) = delete; + awaiter& operator=(const awaiter&) = delete; // Promises don't have a synchronously accessible "ready" state. - bool await_ready() { return false; } + bool await_ready() const { return false; } // On suspend, store the coroutine handle and invoke a helper that will do // a rough equivalent of // `promise.then(value => this.resume_with(value)).catch(error => this.reject_with(error))`. + void await_suspend(std::coroutine_handle handle) { - internal::_emval_coro_suspend(std::get(state).as_handle(), this); - state.emplace(handle); + await_suspend_impl({handle, true}); + } + + void await_suspend(std::coroutine_handle<> handle) { + await_suspend_impl({handle, false}); } // When JS invokes `resume_with` with some value, store that value and resume // the coroutine. void resume_with(val&& result) { - auto coro = std::move(std::get(state)); - state.emplace(std::move(result)); - coro.resume(); + auto* coro_ptr = std::get_if(&state); + assert(coro_ptr && "Invalid awaiter state: expected suspended coroutine handle."); + auto coro = *coro_ptr; + state.emplace(std::move(result)); + coro.handle.resume(); } // When JS invokes `reject_with` with some error value, reject currently suspended @@ -714,7 +738,13 @@ class val::awaiter { // `await_resume` finalizes the awaiter and should return the result // of the `co_await ...` expression - in our case, the stored value. val await_resume() { - return std::move(std::get(state)); + if (auto* result = std::get_if(&state)) { + return std::move(result->result); + } + // If a JS exception ended up here, it will be uncaught as C++ code cannot catch it + auto* error_ptr = std::get_if(&state); + assert(error_ptr && "Invalid awaiter state: expected result or error."); + error_ptr->error.throw_(); } }; @@ -776,10 +806,23 @@ class val::promise_type { }; inline void val::awaiter::reject_with(val&& error) { - auto coro = std::move(std::get(state)); - auto& promise = coro.promise(); - promise.reject_with(std::move(error)); - coro.destroy(); + auto* coro_ptr = std::get_if(&state); + assert(coro_ptr && "Invalid awaiter state: expected suspended coroutine handle."); + auto coro = *coro_ptr; + + if (coro.is_val_promise) { + if (!internal::_emval_is_catchable_cpp_exception_object(error.as_handle())) { + // C++ code cannot catch JS exceptions. + // Thus, we can just reject an enclosing JS Promise. + auto& promise = std::coroutine_handle::from_address(coro.handle.address()).promise(); + promise.reject_with(std::move(error)); + coro.handle.destroy(); + return; + } + } + + state.emplace(std::move(error)); + coro.handle.resume(); } #endif diff --git a/test/embind/test_val_coro.cpp b/test/embind/test_val_coro.cpp index c962bd7931abe..345ec37caa052 100644 --- a/test/embind/test_val_coro.cpp +++ b/test/embind/test_val_coro.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include using namespace emscripten; @@ -94,8 +95,55 @@ val failingPromise<0>() { co_return 65; } +val catchCppExceptionPromise() { + try { + co_await throwingCoro<0>(); + } catch (const std::runtime_error &) { + co_return val("successfully caught!"); + } + co_return val("ignored??"); +} + + +class callback_coro { +public: + class promise_type { + std::function callback_; + std::function errorCallback_; + public: + promise_type( + std::function callback, + std::function errorCallback = std::terminate) + : callback_(std::move(callback)), + errorCallback_(std::move(errorCallback)) {} + + callback_coro get_return_object() const noexcept { + return callback_coro(); + } + + auto initial_suspend() const noexcept { return std::suspend_never{}; } + auto final_suspend() const noexcept { return std::suspend_never{}; } + + void return_value(int ret) { callback_(ret); } + + void unhandled_exception() const noexcept { errorCallback_(); } + }; +}; + +callback_coro sleepWithCallback(std::function) { + co_await promise_sleep(1); + co_return 42; +} + +void awaitInNonValCoro() { + sleepWithCallback([](int ret) { val::global("console").call("log", ret); }); +} + + EMSCRIPTEN_BINDINGS(test_val_coro) { function("asyncCoro", asyncCoro<3>); function("throwingCoro", throwingCoro<3>); function("failingPromise", failingPromise<3>); + function("catchCppExceptionPromise", catchCppExceptionPromise); + function("awaitInNonValCoro", awaitInNonValCoro); } diff --git a/test/embind/test_val_coro_noexcept.cpp b/test/embind/test_val_coro_noexcept.cpp new file mode 100644 index 0000000000000..d2fbb7b6a4658 --- /dev/null +++ b/test/embind/test_val_coro_noexcept.cpp @@ -0,0 +1,35 @@ +#include +#include +#include + +using namespace emscripten; + +EM_JS(EM_VAL, promise_fail_impl, (), { + let promise = new Promise((_, reject) => setTimeout(reject, 1, new Error("bang from JS promise!"))); + let handle = Emval.toHandle(promise); + // FIXME. See https://github.com/emscripten-core/emscripten/issues/16975. +#if __wasm64__ + handle = BigInt(handle); +#endif + return handle; +}); + +val promise_fail() { + return val::take_ownership(promise_fail_impl()); +} + +template +val failingPromise() { + co_await failingPromise(); + co_return 65; +} + +template <> +val failingPromise<0>() { + co_await promise_fail(); + co_return 65; +} + +EMSCRIPTEN_BINDINGS(test_val_coro) { + function("failingPromise", failingPromise<3>); +} diff --git a/test/test_core.py b/test/test_core.py index 5dd5fd029a8f7..a782eb4c3a911 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -7674,7 +7674,7 @@ def test_embind_val_coro(self): self.do_runf('embind/test_val_coro.cpp', '34\n') def test_embind_val_coro_propagate_cpp_exception(self): - self.set_setting('EXCEPTION_STACK_TRACES') + self.set_setting('EXCEPTION_STACK_TRACES') # For err.stack create_file('pre.js', r'''Module.onRuntimeInitialized = () => { Module.throwingCoro().then( console.log, @@ -7684,16 +7684,41 @@ def test_embind_val_coro_propagate_cpp_exception(self): self.cflags += ['-std=c++20', '--bind', '--pre-js=pre.js', '-fexceptions', '-sINCOMING_MODULE_JS_API=onRuntimeInitialized', '--no-entry'] self.do_runf('embind/test_val_coro.cpp', 'rejected with: std::runtime_error: bang from throwingCoro!\n') - def test_embind_val_coro_propagate_js_error(self): - self.set_setting('EXCEPTION_STACK_TRACES') + @parameterized({ + 'emscripten_eh': (['-fexceptions'],), + 'disable_catching': ([],), # Use defaults: DISABLE_EXCEPTION_CATCHING, NO_DISABLE_EXCEPTION_THROWING + 'no_exceptions': (['-fno-exceptions', '-Wno-coroutine-missing-unhandled-exception'],), + }) + def test_embind_val_coro_propagate_js_error(self, extra_flags): create_file('pre.js', r'''Module.onRuntimeInitialized = () => { Module.failingPromise().then( console.log, err => console.error(`rejected with: ${err.message}`) ); }''') - self.cflags += ['-std=c++20', '--bind', '--pre-js=pre.js', '-fexceptions', '-sINCOMING_MODULE_JS_API=onRuntimeInitialized', '--no-entry'] - self.do_runf('embind/test_val_coro.cpp', 'rejected with: bang from JS promise!\n') + self.cflags += ['-std=c++20', '--bind', '--pre-js=pre.js', *extra_flags, '-sINCOMING_MODULE_JS_API=onRuntimeInitialized', '--no-entry'] + self.do_runf('embind/test_val_coro_noexcept.cpp', 'rejected with: bang from JS promise!\n') + + @parameterized({ + 'emscripten_eh': (['-fexceptions'],), + 'wasm_eh': (['-fwasm-exceptions'],), + }) + def test_embind_val_coro_catch_cpp_exception(self, extra_flags): + if self.is_wasm2js() and '-fwasm-exceptions' in extra_flags: + self.skipTest('wasm2js does not support WASM exceptions') + self.set_setting('EXCEPTION_STACK_TRACES') # For debugging + create_file('pre.js', r'''Module.onRuntimeInitialized = () => { + Module.catchCppExceptionPromise().then(console.log); + }''') + self.cflags += ['-std=c++20', '--bind', '--pre-js=pre.js', *extra_flags, '-sINCOMING_MODULE_JS_API=onRuntimeInitialized', '--no-entry'] + self.do_runf('embind/test_val_coro.cpp', 'successfully caught!\n') + + def test_embind_val_coro_await_in_non_val_coro(self): + create_file('pre.js', r'''Module.onRuntimeInitialized = () => { + Module.awaitInNonValCoro(); + }''') + self.cflags += ['-std=c++20', '--bind', '--pre-js=pre.js', '-sINCOMING_MODULE_JS_API=onRuntimeInitialized', '--no-entry'] + self.do_runf('embind/test_val_coro.cpp', '42\n') def test_embind_dynamic_initialization(self): self.cflags += ['-lembind']