Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Core/AppRuntime/Include/Babylon/AppRuntime.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ namespace Babylon

void Dispatch(Dispatchable<void(Napi::Env)> callback);

// 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) -- 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).
//
// 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);

Expand Down
7 changes: 7 additions & 0 deletions Core/AppRuntime/Source/AppRuntime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 5 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_Chakra.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ namespace Babylon
});
},
&dispatchFunction));

// 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"));

if (m_options.EnableDebugger)
Expand Down
65 changes: 65 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,56 @@
#include "AppRuntime.h"
#include <napi/env.h>

#if __APPLE__
#include "AppRuntime_PromiseRejection.h"

// JSGlobalContextSetUnhandledRejectionCallback is declared in the private JavaScriptCore header
// <JavaScriptCore/JSContextRefPrivate.h>, 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
// 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<napi_value>(const_cast<OpaqueJSValue*>(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);
}
}
#endif

void AppRuntime::RunEnvironmentTier(const char*)
{
auto globalContext = JSGlobalContextCreateInGroup(nullptr, nullptr);
Expand All @@ -16,8 +64,25 @@ 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;
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);
}
#endif

Run(env);

#if __APPLE__
t_rejectionContext = nullptr;
#endif

JSGlobalContextRelease(globalContext);

// Detach must come after JSGlobalContextRelease since it triggers finalizers which require env.
Expand Down
85 changes: 85 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_PromiseRejection.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#pragma once

#include "AppRuntime.h"

#include <napi/napi.h>

#include <algorithm>
#include <utility>
#include <vector>

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<typename CandidateT>
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<typename PredicateT>
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<CandidateT> candidates = std::move(m_candidates);
m_candidates.clear();

for (const CandidateT& candidate : candidates)
{
candidate.Report(m_runtime, env);
}
}

AppRuntime& m_runtime;
std::vector<CandidateT> m_candidates;
bool m_flushScheduled{false};
};
}
81 changes: 81 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_V8.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "AppRuntime.h"
#include "AppRuntime_PromiseRejection.h"
#include <napi/env.h>

#include <libplatform/libplatform.h>
Expand Down Expand Up @@ -61,6 +62,78 @@ namespace Babylon
};

std::unique_ptr<Module> Module::s_module;

// 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<v8::Value>) == sizeof(napi_value),
"Cannot convert between v8::Local<v8::Value> and napi_value");
napi_value JsValueFromV8LocalValue(v8::Local<v8::Value> local)
{
return reinterpret_cast<napi_value>(*local);
}

// 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
{
v8::Isolate* isolate{};
v8::Global<v8::Promise> promise;
v8::Global<v8::Value> reason;

void Report(AppRuntime& runtime, Napi::Env env) const
{
v8::HandleScope handleScope{isolate};
runtime.OnUnhandledPromiseRejection(Internal::ToError(env, JsValueFromV8LocalValue(reason.Get(isolate))));
}
};

using V8RejectionTracker = Internal::PromiseRejectionTracker<V8RejectionCandidate>;

// 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)
{
V8RejectionTracker* tracker{t_rejectionTracker};
if (tracker == nullptr)
{
return;
}

v8::Isolate* isolate{v8::Isolate::GetCurrent()};
v8::HandleScope handleScope{isolate};
const v8::Local<v8::Promise> promise{message.GetPromise()};

switch (message.GetEvent())
{
case v8::kPromiseRejectWithNoHandler:
{
tracker->Add(V8RejectionCandidate{
isolate,
v8::Global<v8::Promise>{isolate, promise},
v8::Global<v8::Value>{isolate, message.GetValue()}});
break;
}
case v8::kPromiseHandlerAddedAfterReject:
{
tracker->Remove([isolate, promise](const V8RejectionCandidate& candidate) {
return candidate.promise.Get(isolate) == promise;
});
break;
}
default:
{
// kPromiseRejectAfterResolved / kPromiseResolveAfterResolved carry no actionable
// unhandled-rejection signal.
break;
}
}
}
}

void AppRuntime::RunEnvironmentTier(const char* executablePath)
Expand All @@ -81,6 +154,11 @@ namespace Babylon

Napi::Env env = Napi::Attach(context);

// 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<V8InspectorAgent> agent;
if (m_options.EnableDebugger)
Expand All @@ -104,6 +182,9 @@ namespace Babylon
}
#endif

isolate->SetPromiseRejectCallback(nullptr);
t_rejectionTracker = nullptr;

Napi::Detach(env);
}

Expand Down
3 changes: 3 additions & 0 deletions Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ 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}")
# 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
Expand Down
14 changes: 8 additions & 6 deletions Tests/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ endif()

add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS})
target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}")

# 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()
target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_NAPI_ENGINE="${NAPI_JAVASCRIPT_ENGINE}")
# 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
Expand Down
Loading
Loading