Skip to content
Open
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
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
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
Loading
Loading