diff --git a/CMakeLists.txt b/CMakeLists.txt index 714e175b..513fb318 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) # -------------------------------------------------- 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/Polyfills/AbortController/Readme.md b/Polyfills/AbortController/Readme.md index 4ec00a7f..f5b8b596 100644 --- a/Polyfills/AbortController/Readme.md +++ b/Polyfills/AbortController/Readme.md @@ -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++ diff --git a/Polyfills/AbortController/Source/AbortController.cpp b/Polyfills/AbortController/Source/AbortController.cpp index 3e6bcb43..0b5068be 100644 --- a/Polyfills/AbortController/Source/AbortController.cpp +++ b/Polyfills/AbortController/Source/AbortController.cpp @@ -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) diff --git a/Polyfills/AbortController/Source/AbortSignal.cpp b/Polyfills/AbortController/Source/AbortSignal.cpp index e0e77834..c2d44544 100644 --- a/Polyfills/AbortController/Source/AbortSignal.cpp +++ b/Polyfills/AbortController/Source/AbortSignal.cpp @@ -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); @@ -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()) { @@ -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().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(); + 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&) diff --git a/Polyfills/AbortController/Source/AbortSignal.h b/Polyfills/AbortController/Source/AbortSignal.h index a2de0e4f..a2888c80 100644 --- a/Polyfills/AbortController/Source/AbortSignal.h +++ b/Polyfills/AbortController/Source/AbortSignal.h @@ -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); @@ -33,6 +45,7 @@ namespace Babylon::Polyfills::Internal std::unordered_map> m_eventHandlerRefs; Napi::FunctionReference m_onabort; + Napi::Reference m_reason; bool m_aborted = false; }; } \ No newline at end of file diff --git a/Polyfills/Fetch/Readme.md b/Polyfills/Fetch/Readme.md index 5528774a..00c41cfc 100644 --- a/Polyfills/Fetch/Readme.md +++ b/Polyfills/Fetch/Readme.md @@ -28,3 +28,36 @@ Like `XMLHttpRequest`, `fetch()` supports loading local resources: * Only `GET` and `POST` methods are supported (a `UrlLib` limitation shared with `XMLHttpRequest`). * Only string request bodies are supported. * Consistent with the fetch spec, the promise rejects only on transport-level failures. A completed request with a non-`2xx` status (e.g. `404`) still resolves, with `response.ok === false`. + +## Transport-failure rejections +On a transport-level failure (DNS failure, connection refused, TLS failure, missing local +asset, ...) the promise rejects with a **`TypeError`** whose `message` is the stable string +`"fetch failed"`. The message is intentionally constant so crash-report grouping stays intact; +the variable detail is carried on `error.cause` (the Node/undici shape), never spread across the +message: + +```js +try { + await fetch("https://does-not-resolve.invalid/"); +} catch (error) { + error.message; // "fetch failed" (stable) + error.cause.code; // stable token, e.g. "CURLE_COULDNT_RESOLVE_HOST" (where available) + error.cause.detail; // full normalized UrlLib string (where available) + error.cause.url; // the requested URL + error.cause.status; // 0 for a transport failure +} +``` + +`error.cause.code` / `error.cause.detail` come from `UrlLib`'s normalized transport-error +accessors and are present on the backends that populate them (Apple, Linux); on backends that do +not yet (Windows, Android) they are omitted while the standard observable shape (a `TypeError` +with the stable message, plus `cause.url` / `cause.status`) is preserved. This is a deliberate, +strictly-additive superset of the spec: spec-conformant code only sees a `TypeError`, exactly as +in a browser, while BN-aware diagnostic code can read `cause` to distinguish a DNS failure from a +refused connection or a missing local asset. + +The rejection's `stack` is captured synchronously at the `fetch()` call site (before the request +is handed to a worker thread), so crash reports can attribute the failing call rather than an +empty scheduler tick. (Engines that only materialize `.stack` when an error is thrown may omit +the frames.) + diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 7ffee288..7a500df7 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -30,6 +30,32 @@ namespace Babylon::Polyfills::Internal std::vector body; }; + // Shared state for honoring an AbortSignal passed via init.signal. Co-owned by the "abort" + // listener (which sets the flag, captures the reason, and cancels the transport) and the + // completion continuation (which reports the AbortError and tears the listener down). + struct AbortState + { + bool aborted{false}; + Napi::Reference reason; + Napi::ObjectReference signal; + Napi::FunctionReference listener; + }; + + // The reason a fetch was aborted: the signal's `reason` (per the modern AbortSignal), or a + // fresh AbortError if the signal does not expose one. + Napi::Value GetAbortReason(Napi::Env env, const Napi::Object& signal) + { + const Napi::Value reason = signal.Get("reason"); + if (!reason.IsUndefined() && !reason.IsNull()) + { + return reason; + } + + Napi::Error error = Napi::Error::New(env, "The operation was aborted."); + error.Set("name", Napi::String::New(env, "AbortError")); + return error.Value(); + } + bool EqualsIgnoreCase(std::string_view a, std::string_view b) { return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char l, unsigned char r) { @@ -37,6 +63,85 @@ namespace Babylon::Polyfills::Internal }); } + // Stable message used for every transport-failure rejection. Browsers and Node both keep + // this constant (the variable detail rides on `cause`) so crash-report grouping stays + // intact; we follow Node/undici's "fetch failed" spelling. + constexpr const char* FETCH_FAILED_MESSAGE = "fetch failed"; + + // Snapshot the JS call-site stack synchronously, inside fetch(), before SendAsync() hands + // the request to a worker thread. The transport-failure rejection is otherwise built in a + // continuation that runs after fetch() has returned, where an Error would capture zero user + // frames. We go through the global JS `Error` constructor (rather than napi_create_error) so + // engines that materialize `.stack` from the JS constructor path capture the live caller + // frames. The result is a plain std::string, safe to carry across the thread hop (unlike a + // Napi::Reference, which must be created and destroyed on the JS thread). Empty if the engine + // does not expose a stack at construction time (e.g. Chakra, which only populates `.stack` + // when an error is thrown) -- in that case the rejection simply carries no synthetic frames. + std::string CaptureCallSiteStack(Napi::Env env) + { + // Detect the global Error constructor with IsUndefined()/IsNull() rather than + // IsFunction(): some JavaScriptCore/JSI builds classify constructor functions as + // typeof 'object', so napi_typeof reports napi_object and IsFunction() would + // incorrectly skip stack capture even though Error is callable (see the Blob check + // below for the same rationale). Error is always present, so this guard is defensive. + const Napi::Value errorCtor = env.Global().Get("Error"); + if (errorCtor.IsUndefined() || errorCtor.IsNull()) + { + return {}; + } + const Napi::Object error = errorCtor.As().New({}); + const Napi::Value stack = error.Get("stack"); + return stack.IsString() ? stack.As().Utf8Value() : std::string{}; + } + + // Reattach the synchronously-captured frames to the rejection's Error, replacing the captured + // header line (e.g. "Error\n at ...") with one matching the TypeError we actually reject + // with, so the stack reads correctly while preserving the user's call site. + std::string ComposeRejectionStack(const std::string& capturedStack) + { + std::string header{"TypeError: "}; + header += FETCH_FAILED_MESSAGE; + + const auto firstNewline = capturedStack.find('\n'); + if (firstNewline == std::string::npos) + { + return header; + } + return header + capturedStack.substr(firstNewline); + } + + // Build the transport-failure rejection: a TypeError with a stable message, carrying the + // variable detail under `cause` (Node/undici shape) rather than as top-level own-properties. + // `code`/`detail` come from UrlLib's normalized accessors and may be empty on backends that + // do not yet populate them (Windows/Android today) -- in that case the standard observable + // shape (TypeError + stable message + url) is preserved and only the extra detail is absent. + Napi::Error BuildTransportError(Napi::Env env, const UrlLib::UrlRequest& request, const std::string& url, const std::string& capturedStack) + { + Napi::Error error = Napi::TypeError::New(env, FETCH_FAILED_MESSAGE); + + Napi::Object cause = Napi::Object::New(env); + const std::string code{request.ErrorSymbol()}; + const std::string detail{request.ErrorString()}; + if (!code.empty()) + { + cause.Set("code", Napi::String::New(env, code)); + } + if (!detail.empty()) + { + cause.Set("detail", Napi::String::New(env, detail)); + } + cause.Set("url", Napi::String::New(env, url)); + cause.Set("status", Napi::Number::New(env, static_cast(static_cast(request.StatusCode())))); + error.Set("cause", cause); + + if (!capturedStack.empty()) + { + error.Set("stack", Napi::String::New(env, ComposeRejectionStack(capturedStack))); + } + + return error; + } + // fetch only resolves for GET and POST because the underlying UrlLib transport supports nothing else. UrlLib::UrlMethod ParseMethod(const std::string& method) { @@ -273,6 +378,7 @@ namespace Babylon::Polyfills::Internal UrlLib::UrlMethod method = UrlLib::UrlMethod::Get; std::optional body; Napi::Value headers = env.Undefined(); + Napi::Value signal = env.Undefined(); if (info.Length() > 1 && info[1].IsObject()) { @@ -295,6 +401,7 @@ namespace Babylon::Polyfills::Internal } headers = init.Get("headers"); + signal = init.Get("signal"); } auto request = std::make_shared(); @@ -306,6 +413,44 @@ namespace Babylon::Polyfills::Internal request->SetRequestBody(std::move(*body)); } + // Snapshot the caller's stack now -- before SendAsync() moves work onto a worker + // thread -- so a transport-failure rejection can be attributed to the fetch() call + // site rather than to an empty scheduler tick. + const std::string capturedStack = CaptureCallSiteStack(env); + + // Honor an AbortSignal passed via init.signal (WHATWG fetch). The signal is used + // through its JS interface (aborted / reason / add/removeEventListener) so fetch + // stays decoupled from the AbortController polyfill's C++ types. + std::shared_ptr abortState; + if (signal.IsObject()) + { + const Napi::Object signalObject = signal.As(); + + // Already aborted: reject synchronously with the signal's reason, never + // touching the transport. + if (signalObject.Get("aborted").ToBoolean().Value()) + { + deferred.Reject(GetAbortReason(env, signalObject)); + return deferred.Promise(); + } + + abortState = std::make_shared(); + abortState->signal = Napi::Persistent(signalObject); + + Napi::Function listener = Napi::Function::New(env, [abortState, request, env](const Napi::CallbackInfo&) { + if (!abortState->aborted) + { + abortState->aborted = true; + abortState->reason = Napi::Persistent(GetAbortReason(env, abortState->signal.Value())); + // Cancel the in-flight transport; the completion continuation then + // rejects with the AbortError instead of a transport TypeError. + request->Abort(); + } + }); + abortState->listener = Napi::Persistent(listener); + signalObject.Get("addEventListener").As().Call(signalObject, {Napi::String::New(env, "abort"), listener}); + } + // arcana::task::then captures the scheduler by reference (see arcana task.h) and // invokes it on the worker thread when the request completes -- after this fetch() // call has returned. A stack-local scheduler would therefore dangle. Heap-allocate @@ -313,7 +458,28 @@ namespace Babylon::Polyfills::Internal auto scheduler = std::make_shared(JsRuntime::GetFromJavaScript(env)); request->SendAsync() .then(*scheduler, arcana::cancellation::none(), - [deferred, request, env](const arcana::expected& result) { + [deferred, request, env, url, capturedStack, abortState](const arcana::expected& result) { + // The request has settled: stop listening for aborts (breaking the + // listener <-> abortState ownership cycle) before deciding the outcome. + if (abortState) + { + if (!abortState->signal.IsEmpty() && !abortState->listener.IsEmpty()) + { + Napi::Object signalObject = abortState->signal.Value(); + signalObject.Get("removeEventListener").As().Call(signalObject, {Napi::String::New(env, "abort"), abortState->listener.Value()}); + } + abortState->listener.Reset(); + abortState->signal.Reset(); + + if (abortState->aborted) + { + // Per the fetch spec, an aborted request rejects with the + // signal's reason (an AbortError), not a network error. + deferred.Reject(abortState->reason.Value()); + return; + } + } + const int status = static_cast(request->StatusCode()); // Per the WHATWG fetch spec, only transport-level failures reject. A completed @@ -321,7 +487,11 @@ namespace Babylon::Polyfills::Internal // A status of 0 indicates the transport never produced a response (network error). if (result.has_error() || status == 0) { - throw std::runtime_error{"fetch: network request failed"}; + // Reject with a TypeError carrying the normalized transport detail on `cause` + // (built here, where the UrlRequest's ErrorString()/ErrorSymbol() are still in + // scope) instead of throwing a constant string that discards them. + deferred.Reject(BuildTransportError(env, *request, url, capturedStack).Value()); + return; } auto data = std::make_shared(); @@ -339,10 +509,11 @@ namespace Babylon::Polyfills::Internal }) .then(*scheduler, arcana::cancellation::none(), [deferred, env, scheduler](const arcana::expected& result) { - // A throw from the continuation above (e.g. a network failure or a JS - // exception while building the response) lands here as an error result; - // surface it as a promise rejection so await fetch(...) settles. The - // scheduler is co-owned here so it outlives the in-flight request. + // A throw from the continuation above (e.g. a JS exception while building the + // response) lands here as an error result; surface it as a promise rejection so + // await fetch(...) settles. Transport failures are already rejected above, so this + // only handles unexpected exceptions. The scheduler is co-owned here so it + // outlives the in-flight request. if (result.has_error()) { deferred.Reject(Napi::Error::New(env, result.error()).Value()); diff --git a/Polyfills/XMLHttpRequest/Readme.md b/Polyfills/XMLHttpRequest/Readme.md index 5c40c747..ea00d006 100644 --- a/Polyfills/XMLHttpRequest/Readme.md +++ b/Polyfills/XMLHttpRequest/Readme.md @@ -5,6 +5,7 @@ Minimal implementation of XMLHttpRequest required to support the Babylon.js Requ We do not support `onload`-style event listeners. Instead, you should listen to events using `addEventListener`. At the moment, we only support the following events: * `loadend` * `readystatechange` +* `error` (fired on a transport failure or a non-`2xx` HTTP response, before `loadend`) ## Local files Unlike the web, XMLHttpRequest supports loading local files using two schemes: @@ -13,4 +14,18 @@ Unlike the web, XMLHttpRequest supports loading local files using two schemes: ## Other things to be aware of: * Only `GET` requests are currently supported -* For `readyState`, we only support `UNSENT`, `OPENED`, and `DONE` \ No newline at end of file +* For `readyState`, we only support `UNSENT`, `OPENED`, and `DONE` + +## Transport-error diagnostics (non-standard) +A transport-level failure surfaces the standard way -- an `error` event followed by `loadend`, +with `status === 0` -- exactly as on the web. In addition, two **non-standard, additive** +read-only properties expose the normalized `UrlLib` transport-error detail so BN-aware code can +tell a DNS failure from a refused connection or a missing local asset: +* `errorCode` -- the stable symbolic token (e.g. `"CURLE_COULDNT_CONNECT"`, `"NSURLErrorTimedOut"`, + `"AppResourceNotFound"`) +* `errorDetail` -- the full normalized `":(): "` string + +Both are empty strings unless the request failed at the transport layer, and are populated only +on backends that expose the detail (Apple, Linux) -- empty on Windows/Android until those +backends populate `UrlLib`'s accessors. Browsers do not expose these properties, so +spec-conformant code is unaffected. diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index 10903cac..495222a6 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -81,6 +81,12 @@ namespace Babylon::Polyfills::Internal InstanceAccessor("responseURL", &XMLHttpRequest::GetResponseURL, nullptr), InstanceAccessor("status", &XMLHttpRequest::GetStatus, nullptr), InstanceAccessor("statusText", &XMLHttpRequest::GetStatusText, nullptr), + // Non-standard, additive diagnostics: the normalized transport-error detail from + // UrlLib, empty unless the request failed at the transport layer. Browsers do not + // expose these, so spec-conformant code is unaffected; BN-aware code can read them + // to tell a DNS failure from a refused connection or a missing local asset. + InstanceAccessor("errorCode", &XMLHttpRequest::GetErrorCode, nullptr), + InstanceAccessor("errorDetail", &XMLHttpRequest::GetErrorDetail, nullptr), InstanceMethod("getAllResponseHeaders", &XMLHttpRequest::GetAllResponseHeaders), InstanceMethod("getResponseHeader", &XMLHttpRequest::GetResponseHeader), InstanceMethod("setRequestHeader", &XMLHttpRequest::SetRequestHeader), @@ -162,6 +168,20 @@ namespace Babylon::Polyfills::Internal return Napi::String::New(Env(), std::string{m_request.StatusText()}); } + Napi::Value XMLHttpRequest::GetErrorCode(const Napi::CallbackInfo&) + { + // Stable symbolic token for a transport failure (e.g. "CURLE_COULDNT_CONNECT", + // "NSURLErrorTimedOut", "AppResourceNotFound"); empty when there was no transport failure. + return Napi::String::New(Env(), std::string{m_request.ErrorSymbol()}); + } + + Napi::Value XMLHttpRequest::GetErrorDetail(const Napi::CallbackInfo&) + { + // Full normalized ":(): " string; empty when there was no + // transport failure. + return Napi::String::New(Env(), std::string{m_request.ErrorString()}); + } + Napi::Value XMLHttpRequest::GetResponseHeader(const Napi::CallbackInfo& info) { const auto headerName = info[0].As().Utf8Value(); diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h index 307828fd..ed35dbc9 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h @@ -35,6 +35,8 @@ namespace Babylon::Polyfills::Internal Napi::Value GetResponseURL(const Napi::CallbackInfo& info); Napi::Value GetStatus(const Napi::CallbackInfo& info); Napi::Value GetStatusText(const Napi::CallbackInfo& info); + Napi::Value GetErrorCode(const Napi::CallbackInfo& info); + Napi::Value GetErrorDetail(const Napi::CallbackInfo& info); void AddEventListener(const Napi::CallbackInfo& info); void RemoveEventListener(const Napi::CallbackInfo& info); 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/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index e0647092..20e17de6 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -64,6 +64,37 @@ describe("AbortController", function () { expect(controller.signal.aborted).to.equal(true); }); + + it("AbortSignal.abort() returns a signal already aborted with an AbortError reason", function () { + const signal = (AbortSignal as any).abort(); + expect(signal.aborted).to.equal(true); + expect(signal.reason).to.be.an.instanceof(Error); + expect(signal.reason.name).to.equal("AbortError"); + }); + + it("throwIfAborted() throws the reason only once aborted", function () { + const controller = new AbortController(); + // Not aborted yet: must not throw. + (controller.signal as any).throwIfAborted(); + + controller.abort(); + expect(() => (controller.signal as any).throwIfAborted()).to.throw(); + }); + + it("abort(reason) records the provided reason", function () { + const controller = new AbortController(); + const reason = new Error("custom reason"); + controller.abort(reason); + expect((controller.signal as any).reason).to.equal(reason); + }); + + it("abort() with no reason defaults to an AbortError", function () { + const controller = new AbortController(); + controller.abort(); + const reason = (controller.signal as any).reason; + expect(reason).to.be.an.instanceof(Error); + expect(reason.name).to.equal("AbortError"); + }); }); describe("XMLHTTPRequest", function () { @@ -153,6 +184,23 @@ describe("XMLHTTPRequest", function () { expect(result.readyState).to.equal(4); }); + it("should expose errorCode/errorDetail diagnostics after a transport failure", async function () { + this.timeout(30000); + const xhr: any = await createRequest("GET", "http://127.0.0.1:1/"); + expect(xhr.status).to.equal(0); + // Non-standard, additive diagnostics: always strings; populated on Apple/Linux and empty + // on Windows/Android until those backends populate UrlLib's accessors. Either way the + // standard error event + status===0 behavior (asserted above) is unchanged. + expect(xhr.errorCode).to.be.a("string"); + expect(xhr.errorDetail).to.be.a("string"); + }); + + it("should expose empty errorCode/errorDetail after a successful request", async function () { + const xhr: any = await createRequest("GET", "app:///Scripts/symlink_target.js"); + expect(xhr.errorCode).to.equal(""); + expect(xhr.errorDetail).to.equal(""); + }); + it("should throw something when opening //", async function () { function openDoubleSlash() { const xhr = new XMLHttpRequest(); @@ -331,6 +379,78 @@ describe("fetch", function () { } expect(rejected).to.equal(true); }); + + it("should reject a transport failure with a TypeError carrying detail on cause", async function () { + this.timeout(30000); + let error: any; + try { + // Nothing listens on this loopback port, so the connection is refused -- a transport + // failure (status 0), distinct from an HTTP error status. + await fetch("http://127.0.0.1:1/"); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + // Spec-conformant shape: network errors reject with a TypeError whose message is stable + // (browsers/Node/undici all keep it constant so crash-report grouping stays intact). + expect(error).to.be.an.instanceof(TypeError); + expect(error.message).to.equal("fetch failed"); + // The variable detail rides on `cause` (Node/undici shape), never on the stable message. + expect(error.cause, "error.cause should be populated").to.be.an("object"); + expect(error.cause.url).to.contain("127.0.0.1"); + expect(error.cause.status).to.equal(0); + // On backends where UrlLib populates transport detail (Apple/Linux) `code`/`detail` are + // present stable tokens; on backends that don't yet (Windows/Android) they are absent -- + // the stable observable shape above is preserved either way. + if (error.cause.code !== undefined) { + expect(error.cause.code).to.be.a("string").and.not.equal(""); + expect(error.cause.detail).to.be.a("string").and.not.equal(""); + } + }); + + it("should reject a missing app:// asset with a TypeError (distinct from a network failure)", async function () { + let error: any; + try { + await fetch("app:///does_not_exist.js"); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error).to.be.an.instanceof(TypeError); + expect(error.message).to.equal("fetch failed"); + expect(error.cause.url).to.contain("does_not_exist.js"); + }); + + it("should reject immediately with an AbortError when the signal is already aborted", async function () { + const controller = new AbortController(); + controller.abort(); + + let error: any; + try { + await fetch("https://github.com/", { signal: controller.signal } as any); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error.name).to.equal("AbortError"); + }); + + it("should reject with an AbortError when aborted in-flight", async function () { + this.timeout(30000); + const controller = new AbortController(); + const promise = fetch("https://github.com/", { signal: controller.signal } as any); + // Abort before the response can arrive. + controller.abort(); + + let error: any; + try { + await promise; + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error.name).to.equal("AbortError"); + }); }); describe("setTimeout", function () { 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