Skip to content
Closed
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
15 changes: 15 additions & 0 deletions Core/AppRuntime/Include/Babylon/AppRuntime.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ namespace Babylon
// Optional handler for unhandled exceptions.
std::function<void(const Napi::Error&)> 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};

Expand All @@ -45,6 +54,12 @@ namespace Babylon

void Dispatch(Dispatchable<void(Napi::Env)> 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);

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);
}
}
6 changes: 6 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_Chakra.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions Core/AppRuntime/Source/AppRuntime_V8.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#endif

#include <optional>
#include <unordered_map>
#include <utility>

namespace Babylon
{
Expand Down Expand Up @@ -61,6 +63,105 @@ namespace Babylon
};

std::unique_ptr<Module> 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<int, std::pair<v8::Global<v8::Promise>, v8::Global<v8::Value>>> 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<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);
}

// 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<v8::Value> 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<v8::Promise> promise{message.GetPromise()};
const int hash{promise->GetIdentityHash()};

switch (message.GetEvent())
{
case v8::kPromiseRejectWithNoHandler:
{
const v8::Local<v8::Value> reason{message.GetValue()};
tracker->unhandled[hash] = {
v8::Global<v8::Promise>{tracker->isolate, promise},
v8::Global<v8::Value>{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)
Expand All @@ -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<V8InspectorAgent> agent;
if (m_options.EnableDebugger)
Expand All @@ -104,6 +212,12 @@ namespace Babylon
}
#endif

if (m_options.EnableUnhandledPromiseRejectionTracking)
{
isolate->SetPromiseRejectCallback(nullptr);
t_rejectionTracker = nullptr;
}

Napi::Detach(env);
}

Expand Down
1 change: 1 addition & 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,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
Expand Down
1 change: 1 addition & 0 deletions Tests/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions Tests/UnitTests/Shared/Shared.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <cstdint>
#include <future>
#include <iostream>
#include <string_view>
#include <thread>

namespace
Expand Down Expand Up @@ -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<std::string> 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<bool> 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<void> 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
Expand Down