1111#include < limits>
1212#include < mutex>
1313#include < sstream>
14+ #include < unordered_map>
1415#include " ArgConverter.h"
1516#include " Caches.h"
1617#include " DataWrapper.h"
4344static PendingErrorDisplay gPendingErrorDisplay ;
4445static uint64_t gNextErrorTicket = 1 ;
4546
47+ // Per-isolate uncaughtErrorPolicy "throw" handoff slot. The NSException lifetime
48+ // is managed by ARC (__strong) — fine in this .mm. Heap-allocated and never
49+ // destroyed: ~Runtime reaches this registry via OnIsolateTeardown, and a global
50+ // Runtime can be destructed during __cxa_finalize at process exit — after this
51+ // TU's file-scope statics would have been destroyed. Locking a destroyed
52+ // std::mutex throws std::system_error out of the noexcept destructor chain and
53+ // terminates the process, so the registry must outlive all static destructors.
54+ static std::mutex& PolicyThrowMutex () {
55+ static std::mutex* mutex = new std::mutex ();
56+ return *mutex;
57+ }
58+ static std::unordered_map<v8::Isolate*, NSException * __strong>& PendingPolicyThrows () {
59+ static auto * map = new std::unordered_map<v8::Isolate*, NSException * __strong>();
60+ return *map;
61+ }
62+
4663} // namespace
4764
4865namespace tns {
@@ -122,15 +139,47 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol
122139 } else {
123140 messageString = tns::ToString (isolate, error);
124141 }
142+ // Derive the combined stack ONCE, with the full fallback chain ReportFatalTail
143+ // uses (stackOverride → GetSmartStackTrace → v8::Message stack → the error's
144+ // own stack), so the `error` event listeners and the fatal tail observe the
145+ // same string.
125146 std::string stackForEvent = stackOverride;
126147 if (stackForEvent.empty ()) {
127148 stackForEvent = tns::GetSmartStackTrace (isolate, nullptr , error);
128149 }
150+ if (stackForEvent.empty ()) {
151+ if (!message.IsEmpty ()) {
152+ stackForEvent = GetErrorStackTrace (isolate, message->GetStackTrace ());
153+ } else {
154+ // Rejections/reportError carry no v8::Message; fall back to the error's
155+ // own stack.
156+ stackForEvent = GetErrorStackTrace (isolate, Exception::GetStackTrace (error));
157+ }
158+ }
159+
160+ // Android sets a combined `stackTrace` property on the error object BEFORE
161+ // dispatching the `error` event, so listeners can read `e.error.stackTrace`
162+ // (not only `e.error.stack`). Mirror that here (object errors only; guarded /
163+ // non-fatal on failure — same pattern ReportFatalTail uses). ReportFatalTail
164+ // sets it again with the same value (idempotent) for callers that reach it
165+ // directly (the nativeReportFatal handshake).
166+ if (error->IsObject ()) {
167+ Local<Context> context = isolate->GetCurrentContext ();
168+ bool stackTraceSet = error.As <Object>()
169+ ->Set (context, tns::ToV8String (isolate, " stackTrace" ),
170+ tns::ToV8String (isolate, stackForEvent))
171+ .FromMaybe (false );
172+ if (!stackTraceSet) {
173+ Log (@" Warning: Failed to set stackTrace property on error object" );
174+ }
175+ }
176+
129177 if (ErrorEvents::DispatchError (isolate, error, messageString, stackForEvent)) {
130178 return ;
131179 }
132180
133- ReportFatalTail (isolate, error, message, stackOverride, logPrefix);
181+ // Pass the already-derived stack down so ReportFatalTail does not re-derive it.
182+ ReportFatalTail (isolate, error, message, stackForEvent, logPrefix);
134183}
135184
136185void NativeScriptException::ReportUnhandledRejection (Isolate* isolate, Local<Promise> promise,
@@ -164,6 +213,38 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
164213 CFRunLoopWakeUp (loop);
165214}
166215
216+ void NativeScriptException::DepositPendingPolicyThrow (Isolate* isolate, id exception) {
217+ std::lock_guard<std::mutex> lock (PolicyThrowMutex ());
218+ PendingPolicyThrows ()[isolate] = (NSException *)exception; // overwrites any prior entry
219+ }
220+
221+ id NativeScriptException::ClaimPendingPolicyThrow (Isolate* isolate) {
222+ std::lock_guard<std::mutex> lock (PolicyThrowMutex ());
223+ auto it = PendingPolicyThrows ().find (isolate);
224+ if (it == PendingPolicyThrows ().end ()) {
225+ return nil ;
226+ }
227+ NSException * e = it->second ;
228+ PendingPolicyThrows ().erase (it);
229+ return e;
230+ }
231+
232+ id NativeScriptException::ClaimPendingPolicyThrowIfEqual (Isolate* isolate, id expected) {
233+ std::lock_guard<std::mutex> lock (PolicyThrowMutex ());
234+ auto it = PendingPolicyThrows ().find (isolate);
235+ if (it == PendingPolicyThrows ().end () || it->second != expected) {
236+ return nil ;
237+ }
238+ NSException * e = it->second ;
239+ PendingPolicyThrows ().erase (it);
240+ return e;
241+ }
242+
243+ void NativeScriptException::OnIsolateTeardown (Isolate* isolate) {
244+ std::lock_guard<std::mutex> lock (PolicyThrowMutex ());
245+ PendingPolicyThrows ().erase (isolate);
246+ }
247+
167248void NativeScriptException::ReportFatalTail (Isolate* isolate, Local<Value> error,
168249 Local<v8::Message> message,
169250 const std::string& stackOverride,
@@ -293,9 +374,14 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
293374 // uncaughtErrorPolicy — the cross-platform uncaught-error contract:
294375 // "report" (default): report and keep the app running;
295376 // "throw": after reporting, rethrow the unprevented error
296- // natively (deferred to a clean scope-free frame). This
297- // is a throw, not a crash guarantee — the app terminates
298- // only if no native handler catches it.
377+ // natively. If a JS→native boundary originated the error
378+ // it is rethrown synchronously there (catchable by a
379+ // native @try/@catch around that call, matching Android);
380+ // otherwise (loop-originated: timers, microtasks,
381+ // rejections) it is thrown from a clean, scope-free frame
382+ // on the runtime loop. Either way it is a throw, not a
383+ // crash guarantee — the app terminates only if nothing
384+ // catches it.
299385 // The deprecated discardUncaughtJsExceptions flag suppresses the throw (an
300386 // explicit "keep the app alive").
301387 if (!isDiscarded) {
@@ -315,7 +401,32 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
315401 if (!stackTrace.empty ()) {
316402 tns::SetJSStackOnException (fatal, tns::ToNSString (stackTrace));
317403 }
318- ScheduleDeferredThrow (isolate, fatal);
404+ // Claim-slot handoff: deposit the exception and schedule a FALLBACK
405+ // clean-frame throw. A JS→native boundary that originated this error
406+ // claims the slot while still under its V8 scopes and @throws `fatal`
407+ // synchronously after scope teardown, so a native @try/@catch around the
408+ // boundary can catch it (Android parity). Loop-originated errors (timers,
409+ // microtasks, rejections) are never claimed at a boundary, so the fallback
410+ // throws them from a clean, scope-free frame exactly as before.
411+ //
412+ // The fallback claims by pointer IDENTITY (ClaimPendingPolicyThrowIfEqual):
413+ // reporting is serialized under the isolate lock, but if a newer error is
414+ // deposited and then claimed (by its boundary or its own fallback) before
415+ // this block runs, the identity check fails and this now-stale block is a
416+ // no-op — it can never throw a different (newer) error than the one it was
417+ // scheduled for.
418+ DepositPendingPolicyThrow (isolate, fatal);
419+ Runtime* rt = Runtime::GetRuntime (isolate);
420+ CFRunLoopRef loop = rt != nullptr ? rt->RuntimeLoop () : nullptr ;
421+ if (loop != nullptr ) {
422+ CFRunLoopPerformBlock (loop, kCFRunLoopCommonModes , ^{
423+ id e = ClaimPendingPolicyThrowIfEqual (isolate, fatal);
424+ if (e != nil ) {
425+ @throw e;
426+ }
427+ });
428+ CFRunLoopWakeUp (loop);
429+ }
319430 } else if (policy != " report" ) {
320431 static std::once_flag warnedPolicy;
321432 std::call_once (warnedPolicy, [&policy]() {
@@ -477,6 +588,20 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context,
477588 NativeScriptException::GetErrorStackTrace (isolate_, Exception::GetStackTrace (reason));
478589 }
479590
591+ // Android parity (Fix A): populate `reason.stackTrace` BEFORE dispatching
592+ // the unhandledrejection event so listeners can read `e.reason.stackTrace`
593+ // (object reasons only; guarded / non-fatal). Covers both the worker and
594+ // main dispatch branches below.
595+ if (reason->IsObject () && !stack.empty ()) {
596+ bool ok = reason.As <Object>()
597+ ->Set (context, tns::ToV8String (isolate_, " stackTrace" ),
598+ tns::ToV8String (isolate_, stack))
599+ .FromMaybe (false );
600+ if (!ok) {
601+ Log (@" Warning: Failed to set stackTrace property on rejection reason" );
602+ }
603+ }
604+
480605 if (isWorker) {
481606 // Dispatch the rejection event on the worker's own global first;
482607 // preventDefault() there fully handles it. Only when unprevented fall
0 commit comments