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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ FetchContent_Declare(llhttp
EXCLUDE_FROM_ALL)
FetchContent_Declare(UrlLib
GIT_REPOSITORY https://github.com/BabylonJS/UrlLib.git
GIT_TAG 74985214bd4f83a4906b2c62134ac2f9ab89e1ae
GIT_TAG e86ffb34e77092266145497681efc74e0a920ffe
EXCLUDE_FROM_ALL)
# --------------------------------------------------

Expand Down
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
15 changes: 13 additions & 2 deletions Polyfills/AbortController/Readme.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
# AbortController
Implements parts of [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/) and [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Provides a way to trigger the abort signal. *Work In Progress*

Supported on `AbortSignal`:
* `aborted` (read-only) and `reason`
* [`throwIfAborted()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted)
* static [`AbortSignal.abort(reason?)`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort)
* `onabort`, `addEventListener("abort", ...)`, `removeEventListener`

`AbortController.abort(reason?)` forwards the reason to the signal; when no reason is given the
signal's `reason` defaults to an `AbortError` (an `Error` whose `name` is `"AbortError"`, since
there is no `DOMException` polyfill). `fetch()` honors an `AbortSignal` passed via `init.signal`:
an already-aborted signal rejects the promise synchronously, and an in-flight abort cancels the
transport and rejects with the signal's `reason`. (Transport cancellation is effective on backends
where `UrlLib::UrlRequest::Abort()` is implemented.)

Currently not implemented:
* [`ThrowIfAborted`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted)
* [`Timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout)
* [`Abort`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort)

Both the AbortController and AbortSignal polyfills are initialized inside AbortController's initialize method:
```c++
Expand Down
6 changes: 3 additions & 3 deletions Polyfills/AbortController/Source/AbortController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ namespace Babylon::Polyfills::Internal
return m_signal.Value();
}

void AbortController::Abort(const Napi::CallbackInfo&)
void AbortController::Abort(const Napi::CallbackInfo& info)
{
AbortSignal* sig = AbortSignal::Unwrap(m_signal.Value());

assert(sig != nullptr);
sig->Abort();
sig->Abort(info.Length() > 0 ? info[0] : info.Env().Undefined());
}

AbortController::AbortController(const Napi::CallbackInfo& info)
Expand Down
53 changes: 49 additions & 4 deletions Polyfills/AbortController/Source/AbortSignal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ namespace Babylon::Polyfills::Internal
env,
JS_ABORT_SIGNAL_CONSTRUCTOR_NAME,
{
InstanceAccessor("aborted", &AbortSignal::GetAborted, &AbortSignal::SetAborted),
InstanceAccessor("aborted", &AbortSignal::GetAborted, nullptr),
InstanceAccessor("reason", &AbortSignal::GetReason, nullptr),
InstanceAccessor("onabort", &AbortSignal::GetOnAbort, &AbortSignal::SetOnAbort),
InstanceMethod("throwIfAborted", &AbortSignal::ThrowIfAborted),
InstanceMethod("addEventListener", &AbortSignal::AddEventListener),
InstanceMethod("removeEventListener", &AbortSignal::RemoveEventListener),
StaticMethod("abort", &AbortSignal::AbortStatic),
});

env.Global().Set(JS_ABORT_SIGNAL_CONSTRUCTOR_NAME, func);
Expand All @@ -26,10 +29,30 @@ namespace Babylon::Polyfills::Internal
{
}

void AbortSignal::Abort()
Napi::Value AbortSignal::CreateAbortError(Napi::Env env, const char* message)
{
// There is no DOMException polyfill, so represent the abort reason as an Error whose `name`
// is "AbortError" -- the value web code checks (`err.name === "AbortError"`).
Napi::Error error = Napi::Error::New(env, message);
error.Set("name", Napi::String::New(env, "AbortError"));
return error.Value();
}

void AbortSignal::Abort(const Napi::Value& reason)
{
if (m_aborted)
{
return;
}

m_aborted = true;

Napi::Env env = Env();
const Napi::Value resolvedReason = (reason.IsUndefined() || reason.IsEmpty())
? CreateAbortError(env, "The operation was aborted.")
: reason;
m_reason = Napi::Persistent(resolvedReason);

auto onabort = m_onabort.Value();
if (!onabort.IsNull() && !onabort.IsUndefined())
{
Expand All @@ -39,14 +62,36 @@ namespace Babylon::Polyfills::Internal
RaiseEvent("abort");
}

Napi::Value AbortSignal::AbortStatic(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
Napi::Object signalObject = env.Global().Get(JS_ABORT_SIGNAL_CONSTRUCTOR_NAME).As<Napi::Function>().New({});
AbortSignal* signal = AbortSignal::Unwrap(signalObject);
signal->Abort(info.Length() > 0 ? info[0] : env.Undefined());
return signalObject;
}

Napi::Value AbortSignal::GetAborted(const Napi::CallbackInfo&)
{
return Napi::Value::From(Env(), m_aborted);
}

void AbortSignal::SetAborted(const Napi::CallbackInfo&, const Napi::Value& value)
Napi::Value AbortSignal::GetReason(const Napi::CallbackInfo&)
{
m_aborted = value.As<Napi::Boolean>();
if (m_reason.IsEmpty())
{
return Env().Undefined();
}

return m_reason.Value();
}

void AbortSignal::ThrowIfAborted(const Napi::CallbackInfo& info)
{
if (m_aborted)
{
throw Napi::Error{info.Env(), GetReason(info)};
}
}

Napi::Value AbortSignal::GetOnAbort(const Napi::CallbackInfo&)
Expand Down
17 changes: 15 additions & 2 deletions Polyfills/AbortController/Source/AbortSignal.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,23 @@ namespace Babylon::Polyfills::Internal
static void Initialize(Napi::Env env);
explicit AbortSignal(const Napi::CallbackInfo& info);

void Abort();
// Transition the signal to the aborted state with the given reason (undefined -> a default
// "AbortError"), firing onabort and any "abort" listeners. No-op if already aborted.
void Abort(const Napi::Value& reason);

// Build the default abort reason: an Error whose name is "AbortError" (there is no
// DOMException polyfill), matching what the platform uses when abort() is called with no
// reason and what fetch() rejects with on abort.
static Napi::Value CreateAbortError(Napi::Env env, const char* message);

private:
Napi::Value GetAborted(const Napi::CallbackInfo& info);
void SetAborted(const Napi::CallbackInfo&, const Napi::Value& value);

Napi::Value GetReason(const Napi::CallbackInfo& info);
void ThrowIfAborted(const Napi::CallbackInfo& info);

// AbortSignal.abort(reason?) -- returns an AbortSignal already in the aborted state.
static Napi::Value AbortStatic(const Napi::CallbackInfo& info);

Napi::Value GetOnAbort(const Napi::CallbackInfo& info);
void SetOnAbort(const Napi::CallbackInfo&, const Napi::Value& value);
Expand All @@ -33,6 +45,7 @@ namespace Babylon::Polyfills::Internal
std::unordered_map<std::string, std::vector<Napi::FunctionReference>> m_eventHandlerRefs;

Napi::FunctionReference m_onabort;
Napi::Reference<Napi::Value> m_reason;
bool m_aborted = false;
};
}
Loading
Loading