diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index f6ca7dfd..cab418e6 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -21,6 +21,15 @@ namespace Babylon // Optional handler for unhandled exceptions. std::function UnhandledExceptionHandler{DefaultUnhandledExceptionHandler}; + // When true, unhandled promise rejections are routed to UnhandledExceptionHandler so + // the embedder's crash/telemetry pipeline can observe fire-and-forget failures (e.g. an + // un-awaited fetch() that rejects). Defaults to false to preserve existing behavior -- + // when false, no per-engine rejection tracker is installed. Reporting is deferred to the + // end of the current turn, so a rejection that is handled synchronously (e.g. + // `const p = Promise.reject(e); p.catch(...)`) does not reach the handler. Implemented for + // the Chakra and V8 engines. + bool EnableUnhandledPromiseRejectionTracking{false}; + // Defines whether to enable the debugger. Only implemented for V8 and Chakra. bool EnableDebugger{false}; @@ -45,6 +54,12 @@ namespace Babylon void Dispatch(Dispatchable callback); + // Routes an unhandled promise rejection to the embedder's UnhandledExceptionHandler. Called + // by the per-engine promise-rejection tracker installed in RunEnvironmentTier when + // Options::EnableUnhandledPromiseRejectionTracking is set. Intended for internal + // (engine-implementation) use. + void OnUnhandledPromiseRejection(const Napi::Error& error); + // Default unhandled exception handler that outputs the error message to the program output. static void BABYLON_API DefaultUnhandledExceptionHandler(const Napi::Error& error); diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 99298df2..acb8e20e 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -125,4 +125,11 @@ namespace Babylon }); }); } + + void AppRuntime::OnUnhandledPromiseRejection(const Napi::Error& error) + { + // The reason is wrapped into a Napi::Error by the engine implementation (the napi_value -> + // Napi::Value bridge is shim-specific), so this just forwards to the embedder's handler. + m_options.UnhandledExceptionHandler(error); + } } diff --git a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp index 2329ad30..09fcb2ed 100644 --- a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp +++ b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp @@ -50,6 +50,12 @@ namespace Babylon }); }, &dispatchFunction)); + + // Options::EnableUnhandledPromiseRejectionTracking is not honored on this backend: the OS + // EdgeMode Chakra runtime (chakrart.h) does not expose a host promise-rejection tracker + // (JsSetHostPromiseRejectionTracker is ChakraCore-only), so there is no hook to route + // unhandled rejections from. The option is implemented for the V8 backend. + ThrowIfFailed(JsProjectWinRTNamespace(L"Windows")); if (m_options.EnableDebugger) diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 1297fdbb..6a4b8958 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -8,6 +8,8 @@ #endif #include +#include +#include namespace Babylon { @@ -61,6 +63,105 @@ namespace Babylon }; std::unique_ptr Module::s_module; + + // Tracks promises rejected without a handler so they can be reported once the current turn + // settles. V8 fires kPromiseRejectWithNoHandler when a promise is rejected with no handler + // and kPromiseHandlerAddedAfterReject if one is attached afterwards; reporting is deferred + // (via AppRuntime::Dispatch) so a synchronous `Promise.reject(e); ...; p.catch(...)` is + // removed before it is ever reported. Keyed by promise identity hash; the promise and reason + // are held in v8::Global handles so they survive until the deferred flush runs. + struct V8RejectionTracker + { + AppRuntime* runtime{}; + v8::Isolate* isolate{}; + std::unordered_map, v8::Global>> unhandled{}; + bool flushScheduled{false}; + }; + + // The promise-rejection callback is a bare function pointer with no user-data argument. Each + // AppRuntime owns a dedicated isolate running on its own thread, and V8 invokes the callback + // on that thread, so a thread_local pointer associates the callback with the right tracker + // without risking isolate-data-slot collisions with the Node-API shim. + thread_local V8RejectionTracker* t_rejectionTracker{nullptr}; + + // Mirrors v8impl::JsValueFromV8LocalValue (js_native_api_v8.h), which is internal to the + // Node-API V8 shim and not on the public include path. + static_assert(sizeof(v8::Local) == sizeof(napi_value), + "Cannot convert between v8::Local and napi_value"); + napi_value JsValueFromV8LocalValue(v8::Local local) + { + return reinterpret_cast(*local); + } + + // Wrap a rejection reason as a Napi::Error. An Error-like object is forwarded as-is + // (preserving message/stack/cause); any other value is stringified so the embedder's handler + // always receives a Napi::Error. Done here (not in shared code) because the napi_value -> + // Napi::Value bridge is specific to the V8/standard Node-API shim. + Napi::Error ToError(Napi::Env env, napi_value reason) + { + const Napi::Value reasonValue{env, reason}; + return reasonValue.IsObject() + ? Napi::Error{env, reason} + : Napi::Error::New(env, reasonValue.ToString().Utf8Value()); + } + + void FlushUnhandledRejections(V8RejectionTracker& tracker, Napi::Env env) + { + tracker.flushScheduled = false; + + v8::Isolate::Scope isolateScope{tracker.isolate}; + v8::HandleScope handleScope{tracker.isolate}; + for (auto& entry : tracker.unhandled) + { + const v8::Local reason = entry.second.second.Get(tracker.isolate); + tracker.runtime->OnUnhandledPromiseRejection(ToError(env, JsValueFromV8LocalValue(reason))); + } + tracker.unhandled.clear(); + } + + void OnPromiseReject(v8::PromiseRejectMessage message) + { + V8RejectionTracker* tracker{t_rejectionTracker}; + if (tracker == nullptr) + { + return; + } + + v8::HandleScope handleScope{tracker->isolate}; + const v8::Local promise{message.GetPromise()}; + const int hash{promise->GetIdentityHash()}; + + switch (message.GetEvent()) + { + case v8::kPromiseRejectWithNoHandler: + { + const v8::Local reason{message.GetValue()}; + tracker->unhandled[hash] = { + v8::Global{tracker->isolate, promise}, + v8::Global{tracker->isolate, reason}}; + + if (!tracker->flushScheduled) + { + tracker->flushScheduled = true; + tracker->runtime->Dispatch([tracker](Napi::Env env) { + FlushUnhandledRejections(*tracker, env); + }); + } + break; + } + case v8::kPromiseHandlerAddedAfterReject: + { + tracker->unhandled.erase(hash); + break; + } + default: + { + // kPromiseRejectAfterResolved / kPromiseResolveAfterResolved carry no actionable + // unhandled-rejection signal. + break; + } + } + } } void AppRuntime::RunEnvironmentTier(const char* executablePath) @@ -81,6 +182,13 @@ namespace Babylon Napi::Env env = Napi::Attach(context); + V8RejectionTracker rejectionTracker{this, isolate, {}, false}; + if (m_options.EnableUnhandledPromiseRejectionTracking) + { + t_rejectionTracker = &rejectionTracker; + isolate->SetPromiseRejectCallback(OnPromiseReject); + } + #ifdef ENABLE_V8_INSPECTOR std::optional agent; if (m_options.EnableDebugger) @@ -104,6 +212,12 @@ namespace Babylon } #endif + if (m_options.EnableUnhandledPromiseRejectionTracking) + { + isolate->SetPromiseRejectCallback(nullptr); + t_rejectionTracker = nullptr; + } + Napi::Detach(env); } diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 0af5caa8..c870151c 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(UnitTestsJNI SHARED ${UNIT_TESTS_DIR}/Shared/Shared.cpp) target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") +target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_NAPI_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") target_compile_definitions(UnitTestsJNI PRIVATE ARCANA_TEST_HOOKS) target_include_directories(UnitTestsJNI diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 2dbc7619..c12b12f8 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -45,6 +45,7 @@ endif() add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS}) target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") +target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_NAPI_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") # The V8JSI Node-API shim does not implement napi_create_dataview, so the # CreateDataViewRejectsOverflowingRange test is compiled out on that backend. diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index a920fa1f..8530b04c 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include namespace @@ -279,6 +280,72 @@ TEST(AppRuntime, DestroyDoesNotDeadlock) testThread.join(); } +TEST(AppRuntime, UnhandledPromiseRejectionReachesHandler) +{ + // EnableUnhandledPromiseRejectionTracking is only implemented on the V8 backend so far (the OS + // EdgeMode Chakra runtime exposes no host promise-rejection hook; JavaScriptCore/JSI are + // follow-ups). Skip elsewhere so the test doesn't hang waiting for a report that never comes. +#if defined(JSRUNTIMEHOST_NAPI_ENGINE) + if (std::string_view{JSRUNTIMEHOST_NAPI_ENGINE} != "V8") + { + GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 backend"; + } +#endif + + // A fire-and-forget rejected promise (no handler ever attached) must reach the embedder's + // UnhandledExceptionHandler when EnableUnhandledPromiseRejectionTracking is set. + Babylon::AppRuntime::Options options{}; + options.EnableUnhandledPromiseRejectionTracking = true; + + std::promise rejectionMessage; + options.UnhandledExceptionHandler = [&rejectionMessage](const Napi::Error& error) { + rejectionMessage.set_value(error.Message()); + }; + + Babylon::AppRuntime runtime{options}; + + Babylon::ScriptLoader loader{runtime}; + loader.Eval("Promise.reject(new Error('boom from fire-and-forget'));", ""); + + auto future = rejectionMessage.get_future(); + ASSERT_EQ(future.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "unhandled rejection did not reach the host handler"; + EXPECT_NE(future.get().find("boom from fire-and-forget"), std::string::npos); +} + +TEST(AppRuntime, SynchronouslyHandledRejectionDoesNotReachHandler) +{ + // Only the V8 backend implements unhandled promise rejection tracking (see the note above). +#if defined(JSRUNTIMEHOST_NAPI_ENGINE) + if (std::string_view{JSRUNTIMEHOST_NAPI_ENGINE} != "V8") + { + GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 backend"; + } +#endif + + // A rejection that is handled synchronously in the same turn must NOT reach the handler -- + // reporting is deferred to the end of the turn, by which point the .catch has been attached. + Babylon::AppRuntime::Options options{}; + options.EnableUnhandledPromiseRejectionTracking = true; + + std::atomic handlerFired{false}; + options.UnhandledExceptionHandler = [&handlerFired](const Napi::Error&) { + handlerFired = true; + }; + + Babylon::AppRuntime runtime{options}; + + Babylon::ScriptLoader loader{runtime}; + loader.Eval("const p = Promise.reject(new Error('handled')); p.catch(() => {});", ""); + + // Round-trip a dispatch so any deferred rejection-flush task has run before we check. + std::promise drained; + loader.Dispatch([&drained](Napi::Env) { drained.set_value(); }); + drained.get_future().wait(); + + EXPECT_FALSE(handlerFired.load()) << "a synchronously-handled rejection must not reach the host handler"; +} + // The V8JSI Node-API shim does not implement napi_create_dataview / // napi_get_dataview_info (its DataView::New throws "TODO"), so this native test // only builds on the Chakra, V8, and JavaScriptCore backends. The size_t-width