From 2e7570573057a09b9dc81e32c0e18b4ce04c04f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:18:55 -0700 Subject: [PATCH 1/3] Hop 3: route unhandled promise rejections to the host handler A fire-and-forget rejected promise (an un-awaited fetch() that fails, or a throw inside a .then with no .catch) previously vanished silently: AppRuntime::Dispatch only catches synchronous Napi::Error throws, and no engine had a promise-rejection tracker, so the embedder's UnhandledExceptionHandler never fired and the process exited 0. - AppRuntime::Options gains opt-in EnableUnhandledPromiseRejectionTracking (default false = no behavior change). When set, unhandled rejections route into the existing UnhandledExceptionHandler. - AppRuntime::OnUnhandledPromiseRejection(const Napi::Error&) forwards to the handler; the napi_value -> Napi::Error wrapping is done per-engine (the JSI shim's napi.h has no Napi::Value/Error(napi_env, napi_value) constructor, so shared code must not do it). - V8: Isolate::SetPromiseRejectCallback, with reporting deferred via Dispatch so a rejection handled synchronously in the same turn is not reported (Node-like). - Chakra: the OS EdgeMode runtime exposes no host promise-rejection tracker (JsSetHostPromiseRejectionTracker is ChakraCore-only), so the option is a no-op here. - Native tests (skipped on non-V8 backends, including the Android JNI target which now defines JSRUNTIMEHOST_NAPI_ENGINE): a fire-and-forget rejection reaches the handler; a synchronously-handled rejection does not. JavaScriptCore and JSI trackers are follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Core/AppRuntime/Include/Babylon/AppRuntime.h | 15 +++ Core/AppRuntime/Source/AppRuntime.cpp | 7 ++ Core/AppRuntime/Source/AppRuntime_Chakra.cpp | 6 + Core/AppRuntime/Source/AppRuntime_V8.cpp | 114 ++++++++++++++++++ .../Android/app/src/main/cpp/CMakeLists.txt | 1 + Tests/UnitTests/CMakeLists.txt | 1 + Tests/UnitTests/Shared/Shared.cpp | 67 ++++++++++ 7 files changed, 211 insertions(+) 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 From 2eef6396159534481908b02ca04834adb8469783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:35:14 -0700 Subject: [PATCH 2/3] Address review (hop 3): implement JavaScriptCore, always-track, robustness Responds to the review on the unhandled-promise-rejection handler: - Implement the JavaScriptCore backend in this PR (no longer a follow-up). AppRuntime_JavaScriptCore.cpp registers a JS callback via JSGlobalContextSetUnhandledRejectionCallback (forward-declared SPI, guarded with __builtin_available). JSC fires it at the microtask checkpoint only for still-unhandled rejections, so the adapter is a thin direct report with no candidate bookkeeping. - Factor out the engine-agnostic bookkeeping into a shared header, AppRuntime_PromiseRejection.h: ToError() plus a PromiseRejectionTracker<> template that collects no-handler rejections, drops them on handler-added, and reports survivors at end-of-turn. Included only by the V8/JSC TUs (the JSI napi.h shim lacks the napi_value -> Napi::Value bridge ToError needs). - Always track unhandled rejections (routed to UnhandledExceptionHandler): removed the opt-in Options::EnableUnhandledPromiseRejectionTracking flag. - V8 robustness fixes: * Drain candidates into a local (std::move) before reporting, so a host handler that synchronously rejects another promise cannot invalidate the iterator mid-flush. * Match handler-added events by promise object identity instead of v8::Object::GetIdentityHash (which is not unique and could drop rejections). - Tests: replace the runtime JSRUNTIMEHOST_NAPI_ENGINE string compare with a compile-time gate. CMake now emits JSRUNTIMEHOST_NAPI_ENGINE_ (e.g. _V8, _JavaScriptCore) for both the desktop and Android UnitTests targets, and the rejection tests compile their body only on supported engines. - Consolidate the coverage documentation onto AppRuntime.h (OnUnhandledPromiseRejection): V8 and JavaScriptCore supported; Chakra (in-box/EdgeMode) and JSI no-op. Trim the scattered Chakra note accordingly. Validated on Win32: V8 Release -- both AppRuntime rejection tests pass and JavaScript.All stays green (203 passing) with always-on tracking; Chakra Debug compiles and the rejection tests skip via the compile-time gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Core/AppRuntime/Include/Babylon/AppRuntime.h | 27 ++--- Core/AppRuntime/Source/AppRuntime_Chakra.cpp | 7 +- .../Source/AppRuntime_JavaScriptCore.cpp | 54 +++++++++ .../Source/AppRuntime_PromiseRejection.h | 85 ++++++++++++++ Core/AppRuntime/Source/AppRuntime_V8.cpp | 105 ++++++------------ .../Android/app/src/main/cpp/CMakeLists.txt | 2 + Tests/UnitTests/CMakeLists.txt | 13 ++- Tests/UnitTests/Shared/Shared.cpp | 35 +++--- 8 files changed, 215 insertions(+), 113 deletions(-) create mode 100644 Core/AppRuntime/Source/AppRuntime_PromiseRejection.h diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index cab418e6..6d5b6e40 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -21,15 +21,6 @@ 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}; @@ -54,10 +45,20 @@ 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. + // Routes an unhandled promise rejection to the embedder's UnhandledExceptionHandler (which + // defaults to a benign logger), so an embedder's crash/telemetry pipeline can observe + // fire-and-forget failures (e.g. an un-awaited fetch() that rejects) -- matching the browser + // `unhandledrejection` behavior. Reporting is deferred to the end of the turn, so a rejection + // handled synchronously (e.g. `const p = Promise.reject(e); p.catch(...)`) is not reported. + // + // Coverage is determined by whether the engine exposes a host promise-rejection hook: + // * V8 (Isolate::SetPromiseRejectCallback) and JavaScriptCore + // (JSGlobalContextSetUnhandledRejectionCallback) -- supported. + // * Chakra (in-box/EdgeMode) and JSI -- no-op: neither exposes such a hook + // (JsSetHostPromiseRejectionTracker is ChakraCore-only, and neither jsi::Runtime nor + // V8JSI surfaces the V8 callback). + // + // 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. diff --git a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp index 09fcb2ed..50e998f1 100644 --- a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp +++ b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp @@ -51,10 +51,9 @@ 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. + // Unhandled promise rejection tracking (OnUnhandledPromiseRejection) is a no-op on this + // backend: the OS EdgeMode Chakra runtime (chakrart.h) exposes no host promise-rejection + // hook (JsSetHostPromiseRejectionTracker is ChakraCore-only). See AppRuntime.h. ThrowIfFailed(JsProjectWinRTNamespace(L"Windows")); diff --git a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp index a0322898..978f7260 100644 --- a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp @@ -1,8 +1,49 @@ #include "AppRuntime.h" +#include "AppRuntime_PromiseRejection.h" #include +// JSGlobalContextSetUnhandledRejectionCallback is declared in the private JavaScriptCore header +// , which is not part of the public macOS/iOS SDK. The symbol +// is exported by the JavaScriptCore framework (SPI), so it is forward-declared here and the call is +// guarded with __builtin_available (it is JSC_API_AVAILABLE(macos(10.15.4), ios(13.4))). It registers +// a JS function invoked at the microtask checkpoint with (promise, reason) for each promise that is +// still unhandled at that point, so -- unlike V8 -- no deferral or candidate bookkeeping is needed. +extern "C" void JSGlobalContextSetUnhandledRejectionCallback(JSGlobalContextRef ctx, JSObjectRef function, JSValueRef* exception); + namespace Babylon { + namespace + { + // JSObjectMakeFunctionWithCallback takes no user-data argument; each AppRuntime owns its JSC + // context on a dedicated thread, so a thread_local associates the callback with this runtime. + struct JSCRejectionContext + { + AppRuntime* runtime{}; + napi_env env{}; + }; + + thread_local JSCRejectionContext* t_rejectionContext{nullptr}; + + // Mirrors ToNapi (js_native_api_javascriptcore.cc): napi_value is a JSValueRef in the + // JavaScriptCore Node-API shim. + napi_value JsValueToNapi(JSValueRef value) + { + return reinterpret_cast(const_cast(value)); + } + + JSValueRef OnUnhandledRejection(JSContextRef ctx, JSObjectRef, JSObjectRef, size_t argumentCount, const JSValueRef arguments[], JSValueRef*) + { + JSCRejectionContext* context{t_rejectionContext}; + if (context != nullptr && argumentCount >= 2) + { + const Napi::Env env{context->env}; + context->runtime->OnUnhandledPromiseRejection(Internal::ToError(env, JsValueToNapi(arguments[1]))); + } + + return JSValueMakeUndefined(ctx); + } + } + void AppRuntime::RunEnvironmentTier(const char*) { auto globalContext = JSGlobalContextCreateInGroup(nullptr, nullptr); @@ -16,8 +57,21 @@ namespace Babylon Napi::Env env = Napi::Attach(globalContext); + // Always track unhandled promise rejections (routed to the host UnhandledExceptionHandler). + JSCRejectionContext rejectionContext{this, env}; + t_rejectionContext = &rejectionContext; + if (__builtin_available(iOS 13.4, macOS 10.15.4, *)) + { + JSStringRef callbackName = JSStringCreateWithUTF8CString("onUnhandledRejection"); + JSObjectRef callback = JSObjectMakeFunctionWithCallback(globalContext, callbackName, OnUnhandledRejection); + JSStringRelease(callbackName); + JSGlobalContextSetUnhandledRejectionCallback(globalContext, callback, nullptr); + } + Run(env); + t_rejectionContext = nullptr; + JSGlobalContextRelease(globalContext); // Detach must come after JSGlobalContextRelease since it triggers finalizers which require env. diff --git a/Core/AppRuntime/Source/AppRuntime_PromiseRejection.h b/Core/AppRuntime/Source/AppRuntime_PromiseRejection.h new file mode 100644 index 00000000..6121561b --- /dev/null +++ b/Core/AppRuntime/Source/AppRuntime_PromiseRejection.h @@ -0,0 +1,85 @@ +#pragma once + +#include "AppRuntime.h" + +#include + +#include +#include +#include + +namespace Babylon::Internal +{ + // Wraps an unhandled-promise rejection reason as a Napi::Error: an Error-like object passes + // through (preserving message/stack/cause); any other value is stringified so the host handler + // always receives a Napi::Error. Lives here rather than in shared AppRuntime.cpp because the + // napi_value -> Napi::Value bridge is unavailable on the JSI Node-API shim, and only the engines + // that support rejection tracking (V8, JavaScriptCore) include this header. + inline Napi::Error ToError(Napi::Env env, napi_value reason) + { + const Napi::Value value{env, reason}; + return value.IsObject() + ? Napi::Error{env, reason} + : Napi::Error::New(env, value.ToString().Utf8Value()); + } + + // Engine-agnostic bookkeeping for engines that report an unhandled rejection immediately and a + // later handler-added event separately (V8): collect candidates as promises reject without a + // handler, drop them when a handler is attached, and report the survivors to the host handler at + // the end of the current turn -- so a rejection handled synchronously within the same turn is + // never reported. Engines whose host hook already fires only for still-unhandled rejections at + // the microtask checkpoint (JavaScriptCore) report directly and do not need this. + // + // CandidateT is engine-specific and must provide: + // void Report(AppRuntime& runtime, Napi::Env env) const; + // // convert its stored reason to a Napi::Error (via ToError) and call + // // runtime.OnUnhandledPromiseRejection + template + class PromiseRejectionTracker + { + public: + explicit PromiseRejectionTracker(AppRuntime& runtime) + : m_runtime{runtime} + { + } + + void Add(CandidateT candidate) + { + m_candidates.push_back(std::move(candidate)); + + if (!m_flushScheduled) + { + m_flushScheduled = true; + m_runtime.Dispatch([this](Napi::Env env) { Flush(env); }); + } + } + + template + void Remove(PredicateT predicate) + { + m_candidates.erase( + std::remove_if(m_candidates.begin(), m_candidates.end(), std::move(predicate)), + m_candidates.end()); + } + + private: + void Flush(Napi::Env env) + { + m_flushScheduled = false; + + // Move the candidates out before reporting: a host handler could synchronously reject + // another promise, re-entering Add() and mutating m_candidates mid-iteration. + const std::vector candidates = std::move(m_candidates); + m_candidates.clear(); + + for (const CandidateT& candidate : candidates) + { + candidate.Report(m_runtime, env); + } + } + + AppRuntime& m_runtime; + std::vector m_candidates; + bool m_flushScheduled{false}; + }; +} diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 6a4b8958..2847797f 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -1,4 +1,5 @@ #include "AppRuntime.h" +#include "AppRuntime_PromiseRejection.h" #include #include @@ -8,8 +9,6 @@ #endif #include -#include -#include namespace Babylon { @@ -64,26 +63,6 @@ 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), @@ -93,31 +72,30 @@ namespace Babylon 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) + // A promise rejected without a handler, awaiting end-of-turn reporting. The promise and + // reason are held in v8::Global handles so they survive until the deferred flush; the promise + // is retained so a later handler-added event can drop this candidate by object identity + // (v8::Object::GetIdentityHash is not unique, so identity comparison is used instead). + struct V8RejectionCandidate { - 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* isolate{}; + v8::Global promise; + v8::Global reason; - v8::Isolate::Scope isolateScope{tracker.isolate}; - v8::HandleScope handleScope{tracker.isolate}; - for (auto& entry : tracker.unhandled) + void Report(AppRuntime& runtime, Napi::Env env) const { - const v8::Local reason = entry.second.second.Get(tracker.isolate); - tracker.runtime->OnUnhandledPromiseRejection(ToError(env, JsValueFromV8LocalValue(reason))); + v8::HandleScope handleScope{isolate}; + runtime.OnUnhandledPromiseRejection(Internal::ToError(env, JsValueFromV8LocalValue(reason.Get(isolate)))); } - tracker.unhandled.clear(); - } + }; + + using V8RejectionTracker = Internal::PromiseRejectionTracker; + + // 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}; void OnPromiseReject(v8::PromiseRejectMessage message) { @@ -127,31 +105,25 @@ namespace Babylon return; } - v8::HandleScope handleScope{tracker->isolate}; + v8::Isolate* isolate{v8::Isolate::GetCurrent()}; + v8::HandleScope handleScope{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); - }); - } + tracker->Add(V8RejectionCandidate{ + isolate, + v8::Global{isolate, promise}, + v8::Global{isolate, message.GetValue()}}); break; } case v8::kPromiseHandlerAddedAfterReject: { - tracker->unhandled.erase(hash); + tracker->Remove([isolate, promise](const V8RejectionCandidate& candidate) { + return candidate.promise.Get(isolate) == promise; + }); break; } default: @@ -182,12 +154,10 @@ namespace Babylon Napi::Env env = Napi::Attach(context); - V8RejectionTracker rejectionTracker{this, isolate, {}, false}; - if (m_options.EnableUnhandledPromiseRejectionTracking) - { - t_rejectionTracker = &rejectionTracker; - isolate->SetPromiseRejectCallback(OnPromiseReject); - } + // Always track unhandled promise rejections (routed to the host UnhandledExceptionHandler). + V8RejectionTracker rejectionTracker{*this}; + t_rejectionTracker = &rejectionTracker; + isolate->SetPromiseRejectCallback(OnPromiseReject); #ifdef ENABLE_V8_INSPECTOR std::optional agent; @@ -212,11 +182,8 @@ namespace Babylon } #endif - if (m_options.EnableUnhandledPromiseRejectionTracking) - { - isolate->SetPromiseRejectCallback(nullptr); - t_rejectionTracker = nullptr; - } + 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 c870151c..54dbda87 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -23,6 +23,8 @@ add_library(UnitTestsJNI SHARED target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_NAPI_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") +# Engine-specific compile-time gate (e.g. JSRUNTIMEHOST_NAPI_ENGINE_V8), matching Tests/UnitTests/CMakeLists.txt. +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 c12b12f8..ea8c6c82 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -46,12 +46,13 @@ 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. -if(NAPI_JAVASCRIPT_ENGINE STREQUAL "JSI") - target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_NAPI_ENGINE_JSI) -endif() +# Engine-specific compile-time gate so tests can select behavior by the active Node-API engine +# without a runtime string comparison, e.g.: +# JSRUNTIMEHOST_NAPI_ENGINE_V8, JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore, +# JSRUNTIMEHOST_NAPI_ENGINE_Chakra, JSRUNTIMEHOST_NAPI_ENGINE_JSI. +# (JSI compiles out CreateDataViewRejectsOverflowingRange since the V8JSI shim lacks +# napi_create_dataview; V8/JavaScriptCore gate the unhandled-rejection handler tests.) +target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_NAPI_ENGINE_${NAPI_JAVASCRIPT_ENGINE}) target_link_libraries(UnitTests PRIVATE AppRuntime diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 8530b04c..20bcd833 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -282,20 +282,16 @@ TEST(AppRuntime, DestroyDoesNotDeadlock) 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 - + // Unhandled promise rejection tracking is implemented on the engines that expose a host + // promise-rejection hook: V8 (Isolate::SetPromiseRejectCallback) and JavaScriptCore + // (JSGlobalContextSetUnhandledRejectionCallback). The OS EdgeMode Chakra runtime and the V8JSI + // (JSI) shim expose no such hook, so the body is compiled out there and the test is skipped. +#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) && !defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) + GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 and JavaScriptCore backends"; +#else // A fire-and-forget rejected promise (no handler ever attached) must reach the embedder's - // UnhandledExceptionHandler when EnableUnhandledPromiseRejectionTracking is set. + // UnhandledExceptionHandler. Babylon::AppRuntime::Options options{}; - options.EnableUnhandledPromiseRejectionTracking = true; std::promise rejectionMessage; options.UnhandledExceptionHandler = [&rejectionMessage](const Napi::Error& error) { @@ -311,22 +307,18 @@ TEST(AppRuntime, UnhandledPromiseRejectionReachesHandler) 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); +#endif } 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 - + // Only engines with a host promise-rejection hook implement this tracking (see the note above). +#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) && !defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) + GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 and JavaScriptCore backends"; +#else // 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&) { @@ -344,6 +336,7 @@ TEST(AppRuntime, SynchronouslyHandledRejectionDoesNotReachHandler) drained.get_future().wait(); EXPECT_FALSE(handlerFired.load()) << "a synchronously-handled rejection must not reach the host handler"; +#endif } // The V8JSI Node-API shim does not implement napi_create_dataview / From 5912897923674ecd367f450495000cf2e2380987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:42:43 -0700 Subject: [PATCH 3/3] Guard the JSC unhandled-rejection SPI to Apple platforms The macOS CI matrix built fine, but the Linux JSC job (WebKitGTK via gcc) failed: JSGlobalContextSetUnhandledRejectionCallback is an Apple SPI absent from WebKitGTK, and __builtin_available / its iOS/macOS platform names are Clang/Apple-only, so the unguarded registration didn't compile under gcc. - AppRuntime_JavaScriptCore.cpp: wrap the rejection-callback machinery (forward declaration, thread_local context, callback, and registration) in #if __APPLE__. On non-Apple JSC (WebKitGTK/Linux, Android JSC) tracking is now a documented no-op, consistent with the existing #if __APPLE__ guard around JSGlobalContextSetInspectable. - Shared.cpp: tighten the compile-time gate to V8 || (JavaScriptCore && __APPLE__), so the rejection tests skip on non-Apple JSC where the hook is unavailable (otherwise they would hang waiting for a report). - AppRuntime.h: note that JSC support is Apple-only (WebKitGTK JSC is a no-op). Re-validated on Win32 V8 Release: AppRuntime rejection tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Core/AppRuntime/Include/Babylon/AppRuntime.h | 6 ++++-- .../Source/AppRuntime_JavaScriptCore.cpp | 13 ++++++++++++- Tests/UnitTests/Shared/Shared.cpp | 15 ++++++++------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index 6d5b6e40..654da357 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -52,8 +52,10 @@ namespace Babylon // handled synchronously (e.g. `const p = Promise.reject(e); p.catch(...)`) is not reported. // // Coverage is determined by whether the engine exposes a host promise-rejection hook: - // * V8 (Isolate::SetPromiseRejectCallback) and JavaScriptCore - // (JSGlobalContextSetUnhandledRejectionCallback) -- supported. + // * V8 (Isolate::SetPromiseRejectCallback) -- supported on all platforms. + // * Apple JavaScriptCore (JSGlobalContextSetUnhandledRejectionCallback) -- supported. This + // is an SPI present only in Apple's JSC; the WebKitGTK JSC used on Linux does not expose + // it, so tracking is a no-op there. // * Chakra (in-box/EdgeMode) and JSI -- no-op: neither exposes such a hook // (JsSetHostPromiseRejectionTracker is ChakraCore-only, and neither jsi::Runtime nor // V8JSI surfaces the V8 callback). diff --git a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp index 978f7260..5215c341 100644 --- a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp @@ -1,17 +1,23 @@ #include "AppRuntime.h" -#include "AppRuntime_PromiseRejection.h" #include +#if __APPLE__ +#include "AppRuntime_PromiseRejection.h" + // JSGlobalContextSetUnhandledRejectionCallback is declared in the private JavaScriptCore header // , which is not part of the public macOS/iOS SDK. The symbol // is exported by the JavaScriptCore framework (SPI), so it is forward-declared here and the call is // guarded with __builtin_available (it is JSC_API_AVAILABLE(macos(10.15.4), ios(13.4))). It registers // a JS function invoked at the microtask checkpoint with (promise, reason) for each promise that is // still unhandled at that point, so -- unlike V8 -- no deferral or candidate bookkeeping is needed. +// This is Apple-only: the WebKitGTK JavaScriptCore used on Linux exposes neither this SPI nor +// __builtin_available, so unhandled-rejection tracking is a no-op there (see AppRuntime.h). extern "C" void JSGlobalContextSetUnhandledRejectionCallback(JSGlobalContextRef ctx, JSObjectRef function, JSValueRef* exception); +#endif namespace Babylon { +#if __APPLE__ namespace { // JSObjectMakeFunctionWithCallback takes no user-data argument; each AppRuntime owns its JSC @@ -43,6 +49,7 @@ namespace Babylon return JSValueMakeUndefined(ctx); } } +#endif void AppRuntime::RunEnvironmentTier(const char*) { @@ -57,6 +64,7 @@ namespace Babylon Napi::Env env = Napi::Attach(globalContext); +#if __APPLE__ // Always track unhandled promise rejections (routed to the host UnhandledExceptionHandler). JSCRejectionContext rejectionContext{this, env}; t_rejectionContext = &rejectionContext; @@ -67,10 +75,13 @@ namespace Babylon JSStringRelease(callbackName); JSGlobalContextSetUnhandledRejectionCallback(globalContext, callback, nullptr); } +#endif Run(env); +#if __APPLE__ t_rejectionContext = nullptr; +#endif JSGlobalContextRelease(globalContext); diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 20bcd833..e773a57b 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -283,11 +283,12 @@ TEST(AppRuntime, DestroyDoesNotDeadlock) TEST(AppRuntime, UnhandledPromiseRejectionReachesHandler) { // Unhandled promise rejection tracking is implemented on the engines that expose a host - // promise-rejection hook: V8 (Isolate::SetPromiseRejectCallback) and JavaScriptCore - // (JSGlobalContextSetUnhandledRejectionCallback). The OS EdgeMode Chakra runtime and the V8JSI - // (JSI) shim expose no such hook, so the body is compiled out there and the test is skipped. -#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) && !defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) - GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 and JavaScriptCore backends"; + // promise-rejection hook: V8 (Isolate::SetPromiseRejectCallback) and Apple JavaScriptCore + // (JSGlobalContextSetUnhandledRejectionCallback, an SPI absent from WebKitGTK/Linux JSC). The OS + // EdgeMode Chakra runtime and the V8JSI (JSI) shim expose no such hook, so the body is compiled + // out there (and on non-Apple JSC) and the test is skipped. +#if !(defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) || (defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) && defined(__APPLE__))) + GTEST_SKIP() << "unhandled promise rejection tracking requires the V8 or Apple JavaScriptCore backend"; #else // A fire-and-forget rejected promise (no handler ever attached) must reach the embedder's // UnhandledExceptionHandler. @@ -313,8 +314,8 @@ TEST(AppRuntime, UnhandledPromiseRejectionReachesHandler) TEST(AppRuntime, SynchronouslyHandledRejectionDoesNotReachHandler) { // Only engines with a host promise-rejection hook implement this tracking (see the note above). -#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) && !defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) - GTEST_SKIP() << "unhandled promise rejection tracking is only implemented for the V8 and JavaScriptCore backends"; +#if !(defined(JSRUNTIMEHOST_NAPI_ENGINE_V8) || (defined(JSRUNTIMEHOST_NAPI_ENGINE_JavaScriptCore) && defined(__APPLE__))) + GTEST_SKIP() << "unhandled promise rejection tracking requires the V8 or Apple JavaScriptCore backend"; #else // 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.