From 10ea29371a4f7fd769b301b2912965d1baba5853 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 21 Jul 2026 19:10:45 -0300 Subject: [PATCH 1/4] feat: track and report unhandled promise rejections Register SetPromiseRejectCallback and track rejected promises without handlers in a per-isolate PromiseRejectionTracker (owned by Caches). A kCFRunLoopBeforeWaiting observer drains the pending list once per runloop turn and reports each rejection through the same __onUncaughtError/__onDiscardedError machinery as uncaught sync exceptions (log-prefixed 'Unhandled promise rejection:'). A handler attached before the drain cancels the report. Worker isolates give the worker-global onerror a chance first, then forward to the main isolate's worker.onerror like uncaught worker errors. OnUncaughtError's reporting tail is refactored into the shared ReportToJsHandlersAndLog helper (behavior preserved); the observer polls an atomic pending count since rejections can arrive from any thread holding the v8::Locker, and the drain catches NSExceptions so they cannot unwind through the observer's live V8 scopes. --- NativeScript/runtime/Caches.cpp | 57 ++-- NativeScript/runtime/Caches.h | 274 ++++++++++-------- NativeScript/runtime/DataWrapper.h | 11 + NativeScript/runtime/NativeScriptException.h | 68 ++++- NativeScript/runtime/NativeScriptException.mm | 267 +++++++++++++---- NativeScript/runtime/Runtime.h | 5 + NativeScript/runtime/Runtime.mm | 40 +++ NativeScript/runtime/WorkerWrapper.mm | 16 + TestRunner/app/tests/Promises.js | 100 +++++++ 9 files changed, 640 insertions(+), 198 deletions(-) diff --git a/NativeScript/runtime/Caches.cpp b/NativeScript/runtime/Caches.cpp index 9810625f..3cff2d29 100644 --- a/NativeScript/runtime/Caches.cpp +++ b/NativeScript/runtime/Caches.cpp @@ -1,47 +1,54 @@ #include "Caches.h" + #include "Constants.h" +#include "NativeScriptException.h" using namespace v8; namespace tns { Caches::Caches(Isolate* isolate, const int& isolateId) - : isolate_(isolate), isolateId_(isolateId) { -} + : PromiseRejections(std::make_unique(isolate)), + isolate_(isolate), + isolateId_(isolateId) {} Caches::~Caches() { - this->Prototypes.clear(); - this->ClassPrototypes.clear(); - this->CtorFuncTemplates.clear(); - this->CtorFuncs.clear(); - this->ProtocolCtorFuncs.clear(); - this->StructConstructorFunctions.clear(); - this->PrimitiveInteropTypes.clear(); - this->CFunctions.clear(); - - this->Instances.clear(); - this->StructInstances.clear(); - this->PointerInstances.clear(); - this->cacheBoundObjects_.clear(); + this->Prototypes.clear(); + this->ClassPrototypes.clear(); + this->CtorFuncTemplates.clear(); + this->CtorFuncs.clear(); + this->ProtocolCtorFuncs.clear(); + this->StructConstructorFunctions.clear(); + this->PrimitiveInteropTypes.clear(); + this->CFunctions.clear(); + + this->Instances.clear(); + this->StructInstances.clear(); + this->PointerInstances.clear(); + this->cacheBoundObjects_.clear(); } void Caches::Remove(v8::Isolate* isolate) { - auto cache = isolate->GetData(Constants::CACHES_ISOLATE_SLOT); - isolate->SetData(Constants::CACHES_ISOLATE_SLOT, nullptr); - if (cache != nullptr) { - delete reinterpret_cast*>(cache); - } + auto cache = isolate->GetData(Constants::CACHES_ISOLATE_SLOT); + isolate->SetData(Constants::CACHES_ISOLATE_SLOT, nullptr); + if (cache != nullptr) { + delete reinterpret_cast*>(cache); + } } void Caches::SetContext(Local context) { - this->context_ = std::make_shared>(this->isolate_, context); + this->context_ = + std::make_shared>(this->isolate_, context); } Local Caches::GetContext() { - return this->context_->Get(this->isolate_); + return this->context_->Get(this->isolate_); } -std::shared_ptr> Caches::Metadata = std::make_shared>(); -std::shared_ptr>> Caches::Workers = std::make_shared>>(); +std::shared_ptr> Caches::Metadata = + std::make_shared>(); +std::shared_ptr>> + Caches::Workers = std::make_shared< + ConcurrentMap>>(); -} +} // namespace tns diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 2126410c..6decd98c 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -3,140 +3,178 @@ #include #include -#include "ConcurrentMap.h" -#include "robin_hood.h" + #include "Common.h" +#include "ConcurrentMap.h" #include "Metadata.h" +#include "robin_hood.h" namespace tns { struct StructInfo; +class PromiseRejectionTracker; struct pair_hash { - template - std::size_t operator() (const std::pair &pair) const { - return std::hash()(pair.first) ^ std::hash()(pair.second); - } + template + std::size_t operator()(const std::pair& pair) const { + return std::hash()(pair.first) ^ std::hash()(pair.second); + } }; class Caches { -public: - class WorkerState { - public: - WorkerState(v8::Isolate* isolate, std::shared_ptr> poWorker, void* userData) - : isolate_(isolate), - poWorker_(poWorker), - userData_(userData) { - } - - v8::Isolate* GetIsolate() { - return this->isolate_; - } - - std::shared_ptr> GetWorker() { - return this->poWorker_; - } - - void* UserData() { - return this->userData_; - } - private: - v8::Isolate* isolate_; - std::shared_ptr> poWorker_; - void* userData_; - }; - - Caches(v8::Isolate* isolate, const int& isolateId_ = -1); - ~Caches(); - - bool isWorker = false; - - static std::shared_ptr> Metadata; - static std::shared_ptr>> Workers; - - inline static std::shared_ptr Init(v8::Isolate* isolate, const int& isolateId) { - auto cache = std::make_shared(isolate, isolateId); - // create a new shared_ptr that will live until Remove is called - isolate->SetData(0, static_cast(new std::shared_ptr(cache))); - return cache; - } - inline static std::shared_ptr Get(v8::Isolate* isolate) { - auto cache = isolate->GetData(0); - if (cache != nullptr) { - return *reinterpret_cast*>(cache); - } - // this should only happen when an isolate is accessed after disposal - // so we return a dummy cache - return std::make_shared(isolate); - } - static void Remove(v8::Isolate* isolate); - - inline int getIsolateId() { - return isolateId_; - } - - inline void InvalidateIsolate() { - isolateId_ = -1; + public: + class WorkerState { + public: + WorkerState(v8::Isolate* isolate, + std::shared_ptr> poWorker, + void* userData) + : isolate_(isolate), poWorker_(poWorker), userData_(userData) {} + + v8::Isolate* GetIsolate() { return this->isolate_; } + + std::shared_ptr> GetWorker() { + return this->poWorker_; } - inline bool IsValid() { - return isolateId_ != -1; - } + void* UserData() { return this->userData_; } - void SetContext(v8::Local context); - v8::Local GetContext(); - - robin_hood::unordered_map>> Prototypes; - robin_hood::unordered_map>> ClassPrototypes; - robin_hood::unordered_map>> CtorFuncTemplates; - robin_hood::unordered_map>> CtorFuncs; - robin_hood::unordered_map>> ProtocolCtorFuncs; - robin_hood::unordered_map>> StructConstructorFunctions; - robin_hood::unordered_map>> PrimitiveInteropTypes; - robin_hood::unordered_map>> CFunctions; - - robin_hood::unordered_map>> Instances; - robin_hood::unordered_map, std::shared_ptr>, pair_hash> StructInstances; - robin_hood::unordered_map>> PointerInstances; - - std::function(v8::Local, const BaseClassMeta*, KnownUnknownClassPair, const std::vector&)> ObjectCtorInitializer; - std::function(v8::Local, StructInfo)> StructCtorInitializer; - robin_hood::unordered_map Timers; - robin_hood::unordered_map> Initializers; - - std::unique_ptr> EmptyObjCtorFunc = std::unique_ptr>(nullptr); - std::unique_ptr> EmptyStructCtorFunc = std::unique_ptr>(nullptr); - std::unique_ptr> SliceFunc = std::unique_ptr>(nullptr); - std::unique_ptr> OriginalExtendsFunc = std::unique_ptr>(nullptr); - std::unique_ptr> WeakRefGetterFunc = std::unique_ptr>(nullptr); - std::unique_ptr> WeakRefClearFunc = std::unique_ptr>(nullptr); - std::unique_ptr> SmartJSONStringifyFunc = std::unique_ptr>(nullptr); - std::unique_ptr> InteropReferenceCtorFunc = std::unique_ptr>(nullptr); - std::unique_ptr> PointerCtorFunc = std::unique_ptr>(nullptr); - std::unique_ptr> FunctionReferenceCtorFunc = std::unique_ptr>(nullptr); - std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); - - - using unique_void_ptr = std::unique_ptr; - template - auto unique_void(T * ptr) -> unique_void_ptr - { - return unique_void_ptr(ptr, [](void const * data) { - T const * p = static_cast(data); - delete p; - }); - } - std::vector cacheBoundObjects_; - template - void registerCacheBoundObject(T *ptr) { - this->cacheBoundObjects_.push_back(unique_void(ptr)); - } -private: + private: v8::Isolate* isolate_; - std::shared_ptr> context_; - int isolateId_; + std::shared_ptr> poWorker_; + void* userData_; + }; + + Caches(v8::Isolate* isolate, const int& isolateId_ = -1); + ~Caches(); + + bool isWorker = false; + + static std::shared_ptr> Metadata; + static std::shared_ptr< + ConcurrentMap>> + Workers; + + inline static std::shared_ptr Init(v8::Isolate* isolate, + const int& isolateId) { + auto cache = std::make_shared(isolate, isolateId); + // create a new shared_ptr that will live until Remove is called + isolate->SetData(0, static_cast(new std::shared_ptr(cache))); + return cache; + } + inline static std::shared_ptr Get(v8::Isolate* isolate) { + auto cache = isolate->GetData(0); + if (cache != nullptr) { + return *reinterpret_cast*>(cache); + } + // this should only happen when an isolate is accessed after disposal + // so we return a dummy cache + return std::make_shared(isolate); + } + static void Remove(v8::Isolate* isolate); + + inline int getIsolateId() { return isolateId_; } + + inline void InvalidateIsolate() { isolateId_ = -1; } + + inline bool IsValid() { return isolateId_ != -1; } + + void SetContext(v8::Local context); + v8::Local GetContext(); + + // Per-isolate unhandled promise rejection tracking. Fed by + // NativeScriptException::OnPromiseRejected and drained once per runloop turn. + std::unique_ptr PromiseRejections; + + robin_hood::unordered_map>> + Prototypes; + robin_hood::unordered_map>> + ClassPrototypes; + robin_hood::unordered_map< + const BaseClassMeta*, + std::unique_ptr>> + CtorFuncTemplates; + robin_hood::unordered_map>> + CtorFuncs; + robin_hood::unordered_map>> + ProtocolCtorFuncs; + robin_hood::unordered_map>> + StructConstructorFunctions; + robin_hood::unordered_map>> + PrimitiveInteropTypes; + robin_hood::unordered_map>> + CFunctions; + + robin_hood::unordered_map>> + Instances; + robin_hood::unordered_map, + std::shared_ptr>, + pair_hash> + StructInstances; + robin_hood::unordered_map>> + PointerInstances; + + std::function( + v8::Local, const BaseClassMeta*, KnownUnknownClassPair, + const std::vector&)> + ObjectCtorInitializer; + std::function(v8::Local, StructInfo)> + StructCtorInitializer; + robin_hood::unordered_map Timers; + robin_hood::unordered_map> + Initializers; + + std::unique_ptr> EmptyObjCtorFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> EmptyStructCtorFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> SliceFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> OriginalExtendsFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> WeakRefGetterFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> WeakRefClearFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> SmartJSONStringifyFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> InteropReferenceCtorFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> PointerCtorFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> FunctionReferenceCtorFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> UnmanagedTypeCtorFunc = + std::unique_ptr>(nullptr); + + using unique_void_ptr = std::unique_ptr; + template + auto unique_void(T* ptr) -> unique_void_ptr { + return unique_void_ptr(ptr, [](void const* data) { + T const* p = static_cast(data); + delete p; + }); + } + std::vector cacheBoundObjects_; + template + void registerCacheBoundObject(T* ptr) { + this->cacheBoundObjects_.push_back(unique_void(ptr)); + } + + private: + v8::Isolate* isolate_; + std::shared_ptr> context_; + int isolateId_; }; -} +} // namespace tns #endif /* Caches_h */ diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 2625acd0..266a77e9 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -524,6 +524,13 @@ class WorkerWrapper : public BaseDataWrapper { const std::string& source, const std::string& stackTrace, int lineNumber, bool async = true); + // Forwards a drained unhandled promise rejection to the main isolate's + // worker.onerror, guarded against teardown. Shares marshaling with the + // exception overload above. + void PassUncaughtRejectionToMain(const std::string& message, + const std::string& source, + const std::string& stackTrace, + int lineNumber, bool async = true); void PostMessage(std::shared_ptr message); void Close(); void Terminate(); @@ -561,6 +568,10 @@ class WorkerWrapper : public BaseDataWrapper { void BackgroundLooper(std::function func); void DrainPendingTasks(); + void ForwardErrorPayloadToMain(const std::string& message, + const std::string& source, + const std::string& stackTrace, int lineNumber, + bool async); v8::Local ConstructErrorObject(v8::Local context, std::string message, std::string source, diff --git a/NativeScript/runtime/NativeScriptException.h b/NativeScript/runtime/NativeScriptException.h index 48b05d58..84a91f0d 100644 --- a/NativeScript/runtime/NativeScriptException.h +++ b/NativeScript/runtime/NativeScriptException.h @@ -1,7 +1,9 @@ #ifndef NativeScriptException_h #define NativeScriptException_h +#include #include +#include #include "Common.h" @@ -20,12 +22,26 @@ class NativeScriptException { const std::string& getStackTrace() const { return stackTrace_; } static void OnUncaughtError(v8::Local message, v8::Local error); - static void ShowErrorModal(v8::Isolate* isolate, - const std::string& title, + // Isolate-level promise rejection callback (SetPromiseRejectCallback). Feeds + // the per-isolate PromiseRejectionTracker owned by Caches. + static void OnPromiseRejected(v8::PromiseRejectMessage message); + // Shared reporting tail used by both the uncaught-error message listener and + // the promise-rejection drain. `message` may be empty (rejections carry no + // v8::Message); `stackOverride` short-circuits stack derivation when + // non-empty; `logPrefix` distinguishes the log line (e.g. rejections) while + // preserving the fatal-exception framing. + static void ReportToJsHandlersAndLog(v8::Isolate* isolate, + v8::Local error, + v8::Local message, + const std::string& stackOverride = "", + const std::string& logPrefix = ""); + static std::string GetErrorStackTrace( + v8::Isolate* isolate, const v8::Local& stackTrace); + static void ShowErrorModal(v8::Isolate* isolate, const std::string& title, const std::string& message, const std::string& stackTrace); - static void SubmitConsoleErrorPayload(v8::Isolate* isolate, - const std::string& payload); + static void SubmitConsoleErrorPayload(v8::Isolate* isolate, + const std::string& payload); private: v8::Persistent* javascriptException_; @@ -33,8 +49,6 @@ class NativeScriptException { std::string message_; std::string stackTrace_; std::string fullMessage_; - static std::string GetErrorStackTrace( - v8::Isolate* isolate, const v8::Local& stackTrace); static std::string GetErrorMessage(v8::Isolate* isolate, v8::Local& error, const std::string& prependMessage = ""); @@ -46,6 +60,48 @@ class NativeScriptException { const std::string& jsExceptionMessage); }; +// A promise that was rejected without a handler at a microtask checkpoint. +// `reported` records that the rejection already reached the reporting tail; +// Phase 1 discards reported entries, but the flag is retained so Phase 2 can +// fire `rejectionhandled` for a handler added after reporting. +struct PendingRejection { + v8::Global promise; + v8::Global reason; + bool reported = false; +}; + +// Per-isolate tracker for unhandled promise rejections, owned by Caches. +// Accessed only from the isolate's own thread (the reject callback runs during +// the microtask checkpoint, the drain runs on the same runtime loop), so no +// locking is required. +class PromiseRejectionTracker { + public: + explicit PromiseRejectionTracker(v8::Isolate* isolate) : isolate_(isolate) {} + + void OnReject(v8::Local promise, v8::Local reason); + void OnHandlerAdded(v8::Local promise); + void Drain(v8::Local context); + // Safe to call without holding the isolate lock: OnReject can run on any + // thread that holds the v8::Locker (e.g. a background-thread rejection), so + // the runloop observer polls this atomic instead of touching pending_. + bool HasPending() const { + return pendingCount_.load(std::memory_order_acquire) != 0; + } + + private: + void SyncPendingCount() { + pendingCount_.store(pending_.size(), std::memory_order_release); + } + + v8::Isolate* isolate_; + std::vector pending_; + std::atomic pendingCount_{0}; + // Rejections that arrive while a drain is in progress are deferred to the + // next turn: Drain snapshots and clears `pending_` before iterating, so any + // OnReject during reporting accumulates into a fresh vector. + bool draining_ = false; +}; + } // namespace tns #endif /* NativeScriptException_h */ diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index e891d727..354c7cf4 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -12,6 +12,7 @@ #include #include #include "Caches.h" +#include "DataWrapper.h" #include "Helpers.h" #include "Runtime.h" #include "RuntimeConfig.h" @@ -92,21 +93,51 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol void NativeScriptException::OnUncaughtError(Local message, Local error) { @try { Isolate* isolate = message->GetIsolate(); - Local context = isolate->GetCurrentContext(); - Local global = context->Global(); - Local handler; - id value = Runtime::GetAppConfigValue("discardUncaughtJsExceptions"); - bool isDiscarded = value ? [value boolValue] : false; - - std::string cbName = isDiscarded ? "__onDiscardedError" : "__onUncaughtError"; - bool success = global->Get(context, tns::ToV8String(isolate, cbName)).ToLocal(&handler); + ReportToJsHandlersAndLog(isolate, error, message); + } @catch (NSException* exception) { + Log(@"OnUncaughtError: Caught exception during error handling: %@", exception); + @throw exception; + } +} - std::string stackTrace = tns::GetSmartStackTrace(isolate, nullptr, error); - if (stackTrace.empty()) { +void NativeScriptException::ReportToJsHandlersAndLog(Isolate* isolate, Local error, + Local message, + const std::string& stackOverride, + const std::string& logPrefix) { + Local context = isolate->GetCurrentContext(); + Local global = context->Global(); + Local handler; + id value = Runtime::GetAppConfigValue("discardUncaughtJsExceptions"); + bool isDiscarded = value ? [value boolValue] : false; + + std::string cbName = isDiscarded ? "__onDiscardedError" : "__onUncaughtError"; + bool success = global->Get(context, tns::ToV8String(isolate, cbName)).ToLocal(&handler); + + std::string stackTrace = stackOverride; + if (stackTrace.empty()) { + stackTrace = tns::GetSmartStackTrace(isolate, nullptr, error); + } + if (stackTrace.empty()) { + if (!message.IsEmpty()) { stackTrace = GetErrorStackTrace(isolate, message->GetStackTrace()); + } else { + // Rejections carry no v8::Message; fall back to the reason's own stack. + stackTrace = GetErrorStackTrace(isolate, Exception::GetStackTrace(error)); } - std::string fullMessage; + } + // Derive the human-readable message string, either from the v8::Message (sync + // exceptions) or from the reason value itself (rejections, no v8::Message). + auto messageOrReasonString = [&]() -> std::string { + if (!message.IsEmpty()) { + Local messageV8String = message->Get(); + return tns::ToString(isolate, messageV8String); + } + return tns::ToString(isolate, error); + }; + + std::string fullMessage; + if (error->IsObject()) { auto errObject = error.As(); auto fullMessageString = tns::ToV8String(isolate, "fullMessage"); if (errObject->HasOwnProperty(context, fullMessageString).ToChecked()) { @@ -117,56 +148,194 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol fullMessage = tns::ToString(isolate, fullMessage_); } else { // Fallback to regular message if fullMessage access fails - Local messageV8String = message->Get(); - fullMessage = tns::ToString(isolate, messageV8String); + fullMessage = messageOrReasonString(); } } else { - Local messageV8String = message->Get(); - std::string messageString = tns::ToString(isolate, messageV8String); - fullMessage = messageString + "\n at \n" + stackTrace; + fullMessage = messageOrReasonString() + "\n at \n" + stackTrace; } + } else { + fullMessage = messageOrReasonString() + "\n at \n" + stackTrace; + } - if (success && handler->IsFunction()) { - if (error->IsObject()) { - // Try to set stackTrace property, but don't crash if it fails - bool stackTraceSet = error.As() - ->Set(context, tns::ToV8String(isolate, "stackTrace"), - tns::ToV8String(isolate, stackTrace)) - .FromMaybe(false); - if (!stackTraceSet) { - Log(@"Warning: Failed to set stackTrace property on error object"); - } + if (success && handler->IsFunction()) { + if (error->IsObject()) { + // Try to set stackTrace property, but don't crash if it fails + bool stackTraceSet = error.As() + ->Set(context, tns::ToV8String(isolate, "stackTrace"), + tns::ToV8String(isolate, stackTrace)) + .FromMaybe(false); + if (!stackTraceSet) { + Log(@"Warning: Failed to set stackTrace property on error object"); } + } - Local errorHandlerFunc = handler.As(); - Local thiz = Object::New(isolate); - Local args[] = {error}; - Local result; - TryCatch tc(isolate); - success = errorHandlerFunc->Call(context, thiz, 1, args).ToLocal(&result); - if (tc.HasCaught()) { - tns::LogError(isolate, tc); - } + Local errorHandlerFunc = handler.As(); + Local thiz = Object::New(isolate); + Local args[] = {error}; + Local result; + TryCatch tc(isolate); + success = errorHandlerFunc->Call(context, thiz, 1, args).ToLocal(&result); + if (tc.HasCaught()) { + tns::LogError(isolate, tc); + } - // Don't crash if error handler call failed - just log it - if (!success) { - Log(@"Warning: Error handler function call failed"); - } + // Don't crash if error handler call failed - just log it + if (!success) { + Log(@"Warning: Error handler function call failed"); + } + } + + if (!isDiscarded) { + Log(@"***** Fatal JavaScript exception *****\n"); + if (!logPrefix.empty()) { + Log(@"%s", logPrefix.c_str()); + } + Log(@"%s", fullMessage.c_str()); + if (!stackTrace.empty()) { + Log(@"%s", stackTrace.c_str()); + } + } else { + if (!logPrefix.empty()) { + Log(@"%s", logPrefix.c_str()); + } + Log(@"NativeScript discarding uncaught JS exception!"); + } +} + +void NativeScriptException::OnPromiseRejected(v8::PromiseRejectMessage message) { + Local promise = message.GetPromise(); + Isolate* isolate = promise->GetIsolate(); + auto cache = Caches::Get(isolate); + if (cache == nullptr || cache->PromiseRejections == nullptr) { + return; + } + + switch (message.GetEvent()) { + case v8::kPromiseRejectWithNoHandler: + cache->PromiseRejections->OnReject(promise, message.GetValue()); + break; + case v8::kPromiseHandlerAddedAfterReject: + cache->PromiseRejections->OnHandlerAdded(promise); + break; + case v8::kPromiseResolveAfterResolved: + case v8::kPromiseRejectAfterResolved: + // Not relevant to unhandled-rejection tracking. + break; + } +} + +void PromiseRejectionTracker::OnReject(Local promise, Local reason) { + for (auto& entry : pending_) { + if (entry.promise.Get(isolate_)->SameValue(promise)) { + // Already tracked; refresh the reason for the latest rejection value. + entry.reason.Reset(isolate_, reason); + return; + } + } + PendingRejection entry; + entry.promise.Reset(isolate_, promise); + entry.reason.Reset(isolate_, reason); + entry.reported = false; + pending_.push_back(std::move(entry)); + SyncPendingCount(); +} + +void PromiseRejectionTracker::OnHandlerAdded(Local promise) { + for (auto it = pending_.begin(); it != pending_.end(); ++it) { + if (it->promise.Get(isolate_)->SameValue(promise)) { + // Phase 1: a handler attached before the rejection was drained cancels + // the report. (Phase 2 will fire `rejectionhandled` when it->reported.) + pending_.erase(it); + SyncPendingCount(); + return; } + } +} + +// Gives a worker's global `onerror` a chance to handle a rejected reason, +// mirroring WorkerWrapper::CallOnErrorHandlers. Returns true when the handler +// signalled it consumed the error (truthy return). +static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, + Local reason) { + Local global = context->Global(); + Local onErrorVal; + if (!global->Get(context, tns::ToV8String(isolate, "onerror")).ToLocal(&onErrorVal)) { + return false; + } + if (onErrorVal.IsEmpty() || !onErrorVal->IsFunction()) { + return false; + } + + Local onErrorFunc = onErrorVal.As(); + Local args[1] = {reason}; + Local result; + TryCatch tc(isolate); + bool success = onErrorFunc->Call(context, v8::Undefined(isolate), 1, args).ToLocal(&result); + return success && !result.IsEmpty() && result->BooleanValue(isolate); +} + +void PromiseRejectionTracker::Drain(Local context) { + if (draining_) { + return; + } + draining_ = true; + + std::vector snapshot; + snapshot.swap(pending_); + SyncPendingCount(); - if (!isDiscarded) { - Log(@"***** Fatal JavaScript exception *****\n"); - Log(@"%s", fullMessage.c_str()); - if (!stackTrace.empty()) { - Log(@"%s", stackTrace.c_str()); + auto cache = Caches::Get(isolate_); + bool isWorker = cache->isWorker; + + for (auto& entry : snapshot) { + if (entry.reported) { + continue; + } + entry.reported = true; + + // The observer calling us holds live V8 scopes, so an NSException from the + // reporting path must not unwind past this frame — catch and log instead. + @try { + Local reason = entry.reason.Get(isolate_); + + std::string stack = tns::GetSmartStackTrace(isolate_, nullptr, reason); + if (stack.empty()) { + stack = + NativeScriptException::GetErrorStackTrace(isolate_, Exception::GetStackTrace(reason)); } - } else { - Log(@"NativeScript discarding uncaught JS exception!"); + + if (isWorker) { + // Mirror the uncaught worker error channel: worker-global onerror first, + // then forward to the main isolate's worker.onerror. + if (GiveWorkerOnErrorAChance(isolate_, context, reason)) { + continue; + } + Runtime* runtime = Runtime::GetRuntime(isolate_); + if (runtime == nullptr) { + continue; + } + int workerId = runtime->WorkerId(); + bool found = false; + auto state = Caches::Workers->Get(workerId, found); + if (!found || state == nullptr) { + continue; + } + auto* worker = static_cast(state->UserData()); + if (worker == nullptr) { + continue; + } + std::string reasonMessage = tns::ToString(isolate_, reason); + worker->PassUncaughtRejectionToMain(reasonMessage, "Worker script", stack, 1); + } else { + NativeScriptException::ReportToJsHandlersAndLog(isolate_, reason, Local(), + stack, "Unhandled promise rejection:"); + } + } @catch (NSException* exception) { + Log(@"PromiseRejectionTracker: exception while reporting rejection: %@", exception); } - } @catch (NSException* exception) { - Log(@"OnUncaughtError: Caught exception during error handling: %@", exception); - @throw exception; } + + draining_ = false; } void NativeScriptException::ReThrowToV8(Isolate* isolate) { diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index 92d95dba..f8e1d677 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -78,10 +78,15 @@ class Runtime { static void PerformanceNowCallback( const v8::FunctionCallbackInfo& args); + static void DrainRejectionsObserver(CFRunLoopObserverRef observer, + CFRunLoopActivity activity, void* info); v8::Isolate* isolate_; std::unique_ptr moduleInternal_; int workerId_; CFRunLoopRef runtimeLoop_; + // Drains unhandled promise rejections once per runloop turn + // (kCFRunLoopBeforeWaiting). Torn down before isolate disposal in ~Runtime. + CFRunLoopObserverRef rejectionObserver_ = nullptr; double startTime; double realtimeOrigin; // TODO: refactor this. This is only needed because, during program diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 05df3e36..56a1fae6 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -183,6 +183,14 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { } Runtime::~Runtime() { + // Tear the rejection observer down before any isolate teardown: it references + // the isolate and reads Caches, both of which are invalidated below. + if (rejectionObserver_ != nullptr) { + CFRunLoopObserverInvalidate(rejectionObserver_); + CFRelease(rejectionObserver_); + rejectionObserver_ = nullptr; + } + auto currentIsolate = this->isolate_; { // make sure we remove the isolate from the list of active isolates first @@ -326,10 +334,20 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { isolate->SetHostInitializeImportMetaObjectCallback(InitializeImportMetaObject); isolate->AddMessageListener(NativeScriptException::OnUncaughtError); + isolate->SetPromiseRejectCallback(NativeScriptException::OnPromiseRejected); Local context = Context::New(isolate, nullptr, globalTemplate); context->Enter(); + // Drain tracked unhandled promise rejections once per runloop turn, just + // before the loop sleeps. The info pointer is the isolate; the callback + // re-validates liveness because the isolate may be torn down between turns. + CFRunLoopObserverContext observerContext = {0, isolate, nullptr, nullptr, nullptr}; + rejectionObserver_ = + CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, /*repeats*/ true, + /*order*/ 0, Runtime::DrainRejectionsObserver, &observerContext); + CFRunLoopAddObserver(runtimeLoop_, rejectionObserver_, kCFRunLoopCommonModes); + DefineGlobalObject(context, isWorker); DefineCollectFunction(context); PromiseProxy::Init(context); @@ -603,6 +621,28 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { drainMicrotaskTemplate); } +void Runtime::DrainRejectionsObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, + void* info) { + Isolate* isolate = static_cast(info); + if (!Runtime::IsAlive(isolate)) { + return; + } + auto cache = Caches::Get(isolate); + // HasPending is an atomic read: OnReject can run on any thread holding the + // v8::Locker, so pending_ itself must only be touched under the lock below. + if (!cache->IsValid() || cache->PromiseRejections == nullptr || + !cache->PromiseRejections->HasPending()) { + return; + } + + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + Local context = cache->GetContext(); + Context::Scope context_scope(context); + cache->PromiseRejections->Drain(context); +} + bool Runtime::IsAlive(const Isolate* isolate) { // speedup lookup by avoiding locking if thread locals match // note: this can be a problem when the Runtime is deleted in a different thread that it was diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 65f3855c..e8748388 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -321,6 +321,22 @@ const std::string& source, const std::string& stackTrace, int lineNumber, bool async) { + this->ForwardErrorPayloadToMain(message, source, stackTrace, lineNumber, async); +} + +void WorkerWrapper::PassUncaughtRejectionToMain(const std::string& message, + const std::string& source, + const std::string& stackTrace, int lineNumber, + bool async) { + if (this->isTerminating_ || this->isDisposed_) { + return; + } + this->ForwardErrorPayloadToMain(message, source, stackTrace, lineNumber, async); +} + +void WorkerWrapper::ForwardErrorPayloadToMain(const std::string& message, const std::string& source, + const std::string& stackTrace, int lineNumber, + bool async) { auto runtime = static_cast(mainIsolate_->GetData(Constants::RUNTIME_SLOT)); if (runtime == nullptr) { return; diff --git a/TestRunner/app/tests/Promises.js b/TestRunner/app/tests/Promises.js index 4d4f38cd..7c681f1e 100644 --- a/TestRunner/app/tests/Promises.js +++ b/TestRunner/app/tests/Promises.js @@ -151,3 +151,103 @@ describe("Promise scheduling", function () { }); }); }); + +describe("unhandled rejections", function () { + // Unhandled rejections are tracked per-isolate and reported once per runloop + // turn (kCFRunLoopBeforeWaiting) through the same uncaught-error machinery + // exposed via global.__onUncaughtError. Each test installs a temporary hook + // and restores the previous one in afterEach no matter what. + let previousHook; + let reported; + + beforeEach(function () { + previousHook = global.__onUncaughtError; + reported = []; + global.__onUncaughtError = function (error) { + reported.push(error); + }; + }); + + afterEach(function () { + global.__onUncaughtError = previousHook; + }); + + // The drain happens on a runloop turn, so poll across a few turns until the + // hook fires (or give up after a bounded number of turns). + function afterDrain(cb) { + let turns = 0; + (function poll() { + if (reported.length > 0 || turns >= 20) { + cb(); + return; + } + turns++; + setTimeout(poll, 10); + })(); + } + + // Wait a couple of runloop turns to confirm the hook did NOT fire. + function afterQuietTurns(cb) { + setTimeout(function () { + setTimeout(cb, 20); + }, 20); + } + + it("reports an unhandled Promise.reject", function (done) { + const reason = new Error("unhandled-promise-reject"); + Promise.reject(reason); + afterDrain(function () { + expect(reported.length).toBeGreaterThan(0); + expect(reported[0]).toBe(reason); + done(); + }); + }); + + it("does not report when .catch is attached synchronously in the same turn", function (done) { + const p = Promise.reject(new Error("handled-same-turn")); + p.catch(function () {}); + afterQuietTurns(function () { + expect(reported.length).toBe(0); + done(); + }); + }); + + it("reports an uncaught throw from an async function", function (done) { + const reason = new Error("async-function-throw"); + (async () => { + throw reason; + })(); + afterDrain(function () { + expect(reported.length).toBeGreaterThan(0); + expect(reported[0]).toBe(reason); + done(); + }); + }); + + it("reports an unhandled rejection thrown from a .then callback", function (done) { + const reason = new Error("then-callback-throw"); + Promise.resolve().then(() => { + throw reason; + }); + afterDrain(function () { + expect(reported.length).toBeGreaterThan(0); + expect(reported[0]).toBe(reason); + done(); + }); + }); + + it("ignores a late .catch attached after the rejection was already reported", function (done) { + const reason = new Error("late-catch"); + const p = Promise.reject(reason); + afterDrain(function () { + expect(reported.length).toBeGreaterThan(0); + // Attaching a handler after the report was already delivered must not + // crash (Phase 1 silently drops the already-drained entry). + p.catch(function () {}); + afterQuietTurns(function () { + expect(reported.length).toBe(1); + done(); + }); + }); + }); +}); From b0a869d1cd7669f52ab55ec8d6f86af791a67f3d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 21 Jul 2026 19:29:05 -0300 Subject: [PATCH 2/4] feat: WHATWG error events on globalThis Add a JS bootstrap (InitErrorEvents, evaluated for main and worker isolates right after PromiseProxy::Init) providing spec-shaped Event, EventTarget, ErrorEvent and PromiseRejectionEvent constructors, the EventTarget methods on globalThis, and a global reportError(). Uncaught exceptions now dispatch a cancelable 'error' ErrorEvent and unhandled rejections a cancelable 'unhandledrejection' PromiseRejectionEvent before reporting; preventDefault() fully handles the error (no __on* shim, no fatal log, no modal). Unprevented errors keep the existing behavior byte-for-byte, so NativeScript core's __onUncaughtError/__onDiscardedError hooks continue to work unchanged. A handler attached after reporting fires 'rejectionhandled' as a task on the next drain turn; already-reported promises are held phantom-weak so GC'd promises drop out. Native code dispatches through closures returned by the bootstrap IIFE (stashed as Persistents in Caches), so the events keep firing even if app code overwrites globalThis.dispatchEvent. Listener-thrown errors route to the fatal tail without recursive dispatch. --- NativeScript/runtime/Caches.h | 12 + NativeScript/runtime/NativeScriptException.h | 63 ++- NativeScript/runtime/NativeScriptException.mm | 438 +++++++++++++++++- NativeScript/runtime/Runtime.mm | 1 + TestRunner/app/tests/ErrorEventsTests.js | 288 ++++++++++++ TestRunner/app/tests/index.js | 3 + 6 files changed, 775 insertions(+), 30 deletions(-) create mode 100644 TestRunner/app/tests/ErrorEventsTests.js diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 6decd98c..81965408 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -155,6 +155,18 @@ class Caches { std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); + // Phase 2 WHATWG error-events dispatch closures returned by the bootstrap + // IIFE (NativeScriptException::InitErrorEvents). They close over the internal + // listener store, so native dispatch keeps working even if app code + // overwrites globalThis.dispatchEvent. Cleaned up with the other Persistent + // members when Caches is destroyed, before isolate disposal. + std::unique_ptr> DispatchErrorEventFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> DispatchUnhandledRejectionFunc = + std::unique_ptr>(nullptr); + std::unique_ptr> DispatchRejectionHandledFunc = + std::unique_ptr>(nullptr); + using unique_void_ptr = std::unique_ptr; template auto unique_void(T* ptr) -> unique_void_ptr { diff --git a/NativeScript/runtime/NativeScriptException.h b/NativeScript/runtime/NativeScriptException.h index 84a91f0d..4fb41c68 100644 --- a/NativeScript/runtime/NativeScriptException.h +++ b/NativeScript/runtime/NativeScriptException.h @@ -25,16 +25,53 @@ class NativeScriptException { // Isolate-level promise rejection callback (SetPromiseRejectCallback). Feeds // the per-isolate PromiseRejectionTracker owned by Caches. static void OnPromiseRejected(v8::PromiseRejectMessage message); - // Shared reporting tail used by both the uncaught-error message listener and - // the promise-rejection drain. `message` may be empty (rejections carry no - // v8::Message); `stackOverride` short-circuits stack derivation when - // non-empty; `logPrefix` distinguishes the log line (e.g. rejections) while - // preserving the fatal-exception framing. + // Installs the WHATWG error-events layer (Event/EventTarget/ErrorEvent/ + // PromiseRejectionEvent constructors, EventTarget methods on globalThis and + // global reportError). Evaluated once per isolate during Runtime::Init, right + // after PromiseProxy::Init, for both main and worker isolates. Stashes the + // three native dispatch closures in Caches. + static void InitErrorEvents(v8::Local context); + // Entry point for reporting an uncaught sync exception (message listener) and + // reportError. First dispatches an `error` ErrorEvent through the JS listener + // store; if a listener calls preventDefault() the report is fully handled and + // nothing else runs. Otherwise falls through to ReportFatalTail. `message` + // may be empty (rejections/reportError carry no v8::Message); `stackOverride` + // short-circuits stack derivation when non-empty; `logPrefix` distinguishes + // the log line while preserving the fatal-exception framing. static void ReportToJsHandlersAndLog(v8::Isolate* isolate, v8::Local error, v8::Local message, const std::string& stackOverride = "", const std::string& logPrefix = ""); + // Reports an unhandled promise rejection: dispatches a PromiseRejectionEvent + // (not an ErrorEvent) and, when unprevented, falls through to ReportFatalTail + // with the "Unhandled promise rejection:" prefix. Used by + // PromiseRejectionTracker::Drain on the main isolate. + static void ReportUnhandledRejection(v8::Isolate* isolate, + v8::Local promise, + v8::Local reason, + const std::string& stackOverride = ""); + // Dispatches the cancelable `unhandledrejection` PromiseRejectionEvent + // through the JS listener store. Returns true when a listener called + // preventDefault(). Never re-dispatches on failure: a dispatch that itself + // throws is logged and treated as unprevented so the error is never lost. + static bool DispatchUnhandledRejectionEvent(v8::Isolate* isolate, + v8::Local promise, + v8::Local reason); + // Fires the non-cancelable `rejectionhandled` PromiseRejectionEvent for a + // late-attached handler. `reason` may be undefined (Phase 2 does not retain + // the reason past reporting). + static void DispatchRejectionHandledEvent(v8::Isolate* isolate, + v8::Local promise, + v8::Local reason); + // The terminal tail shared by the uncaught-error path, the rejection path and + // the JS `nativeReportFatal` handshake: calls the __on* shim and emits the + // fatal-exception log. Does NOT dispatch any event (the caller has already + // done so, or is reportError dispatching from JS), preventing recursion. + static void ReportFatalTail(v8::Isolate* isolate, v8::Local error, + v8::Local message, + const std::string& stackOverride = "", + const std::string& logPrefix = ""); static std::string GetErrorStackTrace( v8::Isolate* isolate, const v8::Local& stackTrace); static void ShowErrorModal(v8::Isolate* isolate, const std::string& title, @@ -89,12 +126,26 @@ class PromiseRejectionTracker { } private: + // The observer polls this atomic to decide whether a drain is needed. Both + // the still-to-report rejections and the queued rejectionhandled events count + // as work; reportedOutstanding_ does not (it only waits for a late handler). void SyncPendingCount() { - pendingCount_.store(pending_.size(), std::memory_order_release); + pendingCount_.store(pending_.size() + pendingRejectionHandled_.size(), + std::memory_order_release); } + // Drop weak handles the GC has already cleared. + void PruneReportedOutstanding(); v8::Isolate* isolate_; std::vector pending_; + // Promises whose rejection was already reported (unhandledrejection fired, + // prevented or not) and are still outstanding. Held as phantom-weak globals + // (SetWeak) so a GC'd promise drops out automatically; a handler added later + // moves the promise into pendingRejectionHandled_. + std::vector> reportedOutstanding_; + // rejectionhandled events queued by OnHandlerAdded (which runs during a + // microtask checkpoint) to fire as a task on the next drain, per spec. + std::vector> pendingRejectionHandled_; std::atomic pendingCount_{0}; // Rejections that arrive while a drain is in progress are deferred to the // next turn: Drain snapshots and clears `pending_` before iterating, so any diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index 354c7cf4..244aa016 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -100,10 +100,113 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol } } +// Native function handed to the bootstrap IIFE as `nativeReportFatal(error, +// stackString)`. It runs the terminal tail (shim + fatal log) WITHOUT +// re-dispatching an event: reportError and listener-thrown errors have already +// gone through JS dispatch, so dispatching again here would recurse. +static void NativeReportFatalCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local error = info.Length() > 0 ? info[0] : v8::Undefined(isolate).As(); + std::string stack = info.Length() > 1 ? tns::ToString(isolate, info[1]) : ""; + NativeScriptException::ReportFatalTail(isolate, error, Local(), stack, ""); +} + +// Dispatches the cancelable `error` ErrorEvent through the JS listener store. +// Returns true when a listener called preventDefault(). A dispatch that itself +// throws is logged and treated as unprevented so an error is never lost. +static bool DispatchErrorEvent(Isolate* isolate, Local error, + const std::string& messageString, const std::string& stack) { + auto cache = Caches::Get(isolate); + if (cache == nullptr || cache->DispatchErrorEventFunc == nullptr) { + return false; + } + Local context = isolate->GetCurrentContext(); + Local dispatch = cache->DispatchErrorEventFunc->Get(isolate); + Local args[] = {error, tns::ToV8String(isolate, messageString), + tns::ToV8String(isolate, stack)}; + Local result; + TryCatch tc(isolate); + bool success = dispatch->Call(context, context->Global(), 3, args).ToLocal(&result); + if (tc.HasCaught()) { + tns::LogError(isolate, tc); + return false; + } + return success && !result.IsEmpty() && result->BooleanValue(isolate); +} + void NativeScriptException::ReportToJsHandlersAndLog(Isolate* isolate, Local error, Local message, const std::string& stackOverride, const std::string& logPrefix) { + // First: give `error` event listeners a chance. If one prevents the default, + // the report is fully handled — no shim, no fatal log. + std::string messageString; + if (!message.IsEmpty()) { + messageString = tns::ToString(isolate, message->Get()); + } else { + messageString = tns::ToString(isolate, error); + } + std::string stackForEvent = stackOverride; + if (stackForEvent.empty()) { + stackForEvent = tns::GetSmartStackTrace(isolate, nullptr, error); + } + if (DispatchErrorEvent(isolate, error, messageString, stackForEvent)) { + return; + } + + ReportFatalTail(isolate, error, message, stackOverride, logPrefix); +} + +bool NativeScriptException::DispatchUnhandledRejectionEvent(Isolate* isolate, + Local promise, + Local reason) { + auto cache = Caches::Get(isolate); + if (cache == nullptr || cache->DispatchUnhandledRejectionFunc == nullptr) { + return false; + } + Local context = isolate->GetCurrentContext(); + Local dispatch = cache->DispatchUnhandledRejectionFunc->Get(isolate); + Local args[] = {promise, reason}; + Local result; + TryCatch tc(isolate); + bool success = dispatch->Call(context, context->Global(), 2, args).ToLocal(&result); + if (tc.HasCaught()) { + tns::LogError(isolate, tc); + return false; + } + return success && !result.IsEmpty() && result->BooleanValue(isolate); +} + +void NativeScriptException::DispatchRejectionHandledEvent(Isolate* isolate, Local promise, + Local reason) { + auto cache = Caches::Get(isolate); + if (cache == nullptr || cache->DispatchRejectionHandledFunc == nullptr) { + return; + } + Local context = isolate->GetCurrentContext(); + Local dispatch = cache->DispatchRejectionHandledFunc->Get(isolate); + Local args[] = {promise, reason}; + Local result; + TryCatch tc(isolate); + if (!dispatch->Call(context, context->Global(), 2, args).ToLocal(&result) && tc.HasCaught()) { + tns::LogError(isolate, tc); + } +} + +void NativeScriptException::ReportUnhandledRejection(Isolate* isolate, Local promise, + Local reason, + const std::string& stackOverride) { + if (DispatchUnhandledRejectionEvent(isolate, promise, reason)) { + return; + } + ReportFatalTail(isolate, reason, Local(), stackOverride, + "Unhandled promise rejection:"); +} + +void NativeScriptException::ReportFatalTail(Isolate* isolate, Local error, + Local message, + const std::string& stackOverride, + const std::string& logPrefix) { Local context = isolate->GetCurrentContext(); Local global = context->Global(); Local handler; @@ -202,6 +305,240 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol } } +void NativeScriptException::InitErrorEvents(Local context) { + // WHATWG error-events layer. Plain (module-free) script, strict inside the + // IIFE, ES5-ish so it never depends on other runtime extensions. The IIFE is + // invoked with one argument — the native nativeReportFatal(error, stack) + // function that runs the terminal tail — and returns three closures bound to + // the internal listener store so native dispatch survives app code + // overwriting globalThis.dispatchEvent. + std::string source = R"( + (function (nativeReportFatal) { + "use strict"; + var g = globalThis; + + function Event(type, opts) { + opts = opts || {}; + this.type = String(type); + this.bubbles = !!opts.bubbles; + this.cancelable = !!opts.cancelable; + this.composed = !!opts.composed; + this.defaultPrevented = false; + this.target = null; + this.currentTarget = null; + this._stopPropagation = false; + this._stopImmediate = false; + } + Event.prototype.preventDefault = function () { + if (this.cancelable) { this.defaultPrevented = true; } + }; + Event.prototype.stopPropagation = function () { this._stopPropagation = true; }; + Event.prototype.stopImmediatePropagation = function () { + this._stopPropagation = true; + this._stopImmediate = true; + }; + + function ErrorEvent(type, opts) { + opts = opts || {}; + Event.call(this, type, opts); + this.message = opts.message !== undefined ? String(opts.message) : ""; + this.filename = opts.filename !== undefined ? String(opts.filename) : ""; + this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0; + this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0; + this.error = opts.error !== undefined ? opts.error : null; + } + ErrorEvent.prototype = Object.create(Event.prototype); + ErrorEvent.prototype.constructor = ErrorEvent; + + function PromiseRejectionEvent(type, opts) { + opts = opts || {}; + Event.call(this, type, opts); + this.promise = opts.promise; + this.reason = opts.reason; + } + PromiseRejectionEvent.prototype = Object.create(Event.prototype); + PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; + + // A listener that throws must not stop other listeners: route the thrown + // value to the native fatal tail instead of ever recursively dispatching + // another `error` event from inside dispatch. + function reportListenerError(e) { + try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} + } + + function EventTargetImpl() { this._listeners = Object.create(null); } + EventTargetImpl.prototype.addEventListener = function (type, callback, options) { + if (callback === null || callback === undefined) { return; } + type = String(type); + var capture = false, once = false; + if (typeof options === "boolean") { + capture = options; + } else if (options && typeof options === "object") { + capture = !!options.capture; + once = !!options.once; + } + var list = this._listeners[type]; + if (!list) { list = this._listeners[type] = []; } + for (var i = 0; i < list.length; i++) { + if (list[i].callback === callback && list[i].capture === capture) { return; } + } + list.push({ callback: callback, once: once, capture: capture }); + }; + EventTargetImpl.prototype.removeEventListener = function (type, callback, options) { + type = String(type); + var capture = false; + if (typeof options === "boolean") { + capture = options; + } else if (options && typeof options === "object") { + capture = !!options.capture; + } + var list = this._listeners[type]; + if (!list) { return; } + for (var i = 0; i < list.length; i++) { + if (list[i].callback === callback && list[i].capture === capture) { + list.splice(i, 1); + return; + } + } + }; + EventTargetImpl.prototype.dispatchEvent = function (event) { + event.target = this; + event.currentTarget = this; + var list = this._listeners[event.type]; + if (list) { + // Snapshot so listeners added during dispatch are not invoked and + // registration order is preserved. + var snapshot = list.slice(); + for (var i = 0; i < snapshot.length; i++) { + var entry = snapshot[i]; + var idx = list.indexOf(entry); + if (idx === -1) { continue; } // removed since snapshot + if (entry.once) { list.splice(idx, 1); } + var cb = entry.callback; + try { + if (typeof cb === "function") { + cb.call(this, event); + } else if (cb && typeof cb.handleEvent === "function") { + cb.handleEvent(event); + } + } catch (e) { + reportListenerError(e); + } + if (event._stopImmediate) { break; } + } + } + event.currentTarget = null; + return !event.defaultPrevented; + }; + + // Internal EventTarget instance backing the global. globalThis's prototype + // is intentionally NOT made an EventTarget; only the three methods are + // bound onto it. + var globalTarget = new EventTargetImpl(); + g.addEventListener = function (type, callback, options) { + return globalTarget.addEventListener(type, callback, options); + }; + g.removeEventListener = function (type, callback, options) { + return globalTarget.removeEventListener(type, callback, options); + }; + g.dispatchEvent = function (event) { + return globalTarget.dispatchEvent(event); + }; + + function EventTarget() { EventTargetImpl.call(this); } + EventTarget.prototype.addEventListener = EventTargetImpl.prototype.addEventListener; + EventTarget.prototype.removeEventListener = EventTargetImpl.prototype.removeEventListener; + EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; + + g.reportError = function (e) { + if (arguments.length === 0) { + throw new TypeError("Failed to execute 'reportError': 1 argument required, but only 0 present."); + } + var ev = new ErrorEvent("error", { + message: (e && e.message !== undefined && e.message !== null) ? String(e.message) : String(e), + error: e, + cancelable: true + }); + if (globalTarget.dispatchEvent(ev)) { + nativeReportFatal(e, (e && e.stack) || ""); + } + }; + + g.Event = Event; + g.EventTarget = EventTarget; + g.ErrorEvent = ErrorEvent; + g.PromiseRejectionEvent = PromiseRejectionEvent; + + // Closures called by C++. They never look up globalThis.dispatchEvent, so + // they keep working even if app code overwrites it. + function dispatchErrorEvent(error, message, stack) { + var ev = new ErrorEvent("error", { + message: message !== undefined && message !== null ? String(message) : "", + error: error, + cancelable: true + }); + globalTarget.dispatchEvent(ev); + return ev.defaultPrevented; + } + function dispatchUnhandledRejection(promise, reason) { + var ev = new PromiseRejectionEvent("unhandledrejection", { + promise: promise, + reason: reason, + cancelable: true + }); + globalTarget.dispatchEvent(ev); + return ev.defaultPrevented; + } + function dispatchRejectionHandled(promise, reason) { + var ev = new PromiseRejectionEvent("rejectionhandled", { + promise: promise, + reason: reason, + cancelable: false + }); + globalTarget.dispatchEvent(ev); + } + + return [dispatchErrorEvent, dispatchUnhandledRejection, dispatchRejectionHandled]; + }) + )"; + + Isolate* isolate = context->GetIsolate(); + + Local