feat: web-compliant error handling (unhandled rejections, error events, interop.escapeException)#409
feat: web-compliant error handling (unhandled rejections, error events, interop.escapeException)#409edusperoni wants to merge 9 commits into
Conversation
📝 WalkthroughWalkthroughThe runtime adds branded JavaScript exception escaping, deferred native exception propagation across V8 boundaries, WHATWG-style error events, per-isolate unhandled rejection tracking, worker forwarding, and comprehensive integration tests. ChangesException and error event boundaries
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant JavaScript
participant Runtime
participant V8
participant PromiseRejectionTracker
participant NativeScriptException
participant ErrorEvents
JavaScript->>Runtime: Reject promise or throw uncaught error
Runtime->>PromiseRejectionTracker: Record/forward rejection or error
Runtime->>PromiseRejectionTracker: Drain at kCFRunLoopBeforeWaiting
PromiseRejectionTracker->>NativeScriptException: Report event details
NativeScriptException->>ErrorEvents: Dispatch cancelable error/unhandledrejection event
ErrorEvents->>V8: Call JS event listeners with preventDefault()
alt Event prevented
V8-->>NativeScriptException: preventDefault() → return true
NativeScriptException-->>Runtime: Suppress fatal tail
else Event not prevented
V8-->>NativeScriptException: preventDefault() → return false
NativeScriptException->>NativeScriptException: ReportFatalTail (log, modal, or policy throw)
end
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@NativeScript/runtime/ArgConverter.h`:
- Around line 68-85: Update GetEscapeExceptionBrand to validate the cache with
cache->IsValid() before calling v8::Private::New or creating any persistent
state. Preserve the documented empty-handle return for both null and invalid
caches, including the dummy cache returned after removal.
In `@NativeScript/runtime/MetadataBuilder.mm`:
- Around line 1166-1170: Update the fallback wrapper creation in the setter path
around self_ to retain the newly created ObjCDataWrapper and invoke
tns::DeleteWrapperIfUnused(...) after creating the JavaScript wrapper, mirroring
the getter cleanup. Preserve the cached-instance path and ensure cleanup applies
only when thiz is not already cached.
In `@NativeScript/runtime/NativeScriptException.h`:
- Around line 66-71: Update the rejection tracking state around
DispatchRejectionHandledEvent and reportedOutstanding_ so each reported promise
retains its original rejection reason alongside the promise. When moving entries
into pendingRejectionHandled_, carry that stored reason through and dispatch
rejectionhandled with it instead of allowing reason to become undefined.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: f59b87c7-4598-4217-955d-2ba7d862ab38
📒 Files selected for processing (25)
NativeScript/runtime/ArgConverter.hNativeScript/runtime/ArgConverter.mmNativeScript/runtime/ArrayAdapter.mmNativeScript/runtime/Caches.cppNativeScript/runtime/Caches.hNativeScript/runtime/ClassBuilder.mmNativeScript/runtime/DataWrapper.hNativeScript/runtime/DictionaryAdapter.mmNativeScript/runtime/FastEnumerationAdapter.mmNativeScript/runtime/Interop.hNativeScript/runtime/Interop.mmNativeScript/runtime/InteropTypes.mmNativeScript/runtime/MetadataBuilder.mmNativeScript/runtime/NativeScriptException.hNativeScript/runtime/NativeScriptException.mmNativeScript/runtime/Runtime.hNativeScript/runtime/Runtime.mmNativeScript/runtime/WorkerWrapper.mmTestFixtures/TNSTestNativeCallbacks.hTestFixtures/TNSTestNativeCallbacks.mTestRunner/app/tests/ErrorEventsTests.jsTestRunner/app/tests/EscapeExceptionTests.jsTestRunner/app/tests/MethodCallsTests.jsTestRunner/app/tests/Promises.jsTestRunner/app/tests/index.js
|
Addressed all three review findings in 0d1e372 (full suite re-run: 900/900 green):
|
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.
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.
Preserve the original NSException when a native call throws: the JS error now carries name/message from the exception and the wrapped original as error.nativeException (re-enables the long-dormant Throw_ObjC_exceptions_to_JavaScript test everywhere - libffi 3.7.1 fixed simulator unwinding). Add interop.escapeException(x): returns a JS Error branded with an isolate-private symbol carrying either the original NSException or synthesis info. When a branded throw reaches a JS-to-native boundary (overridden methods and JS-backed blocks via ArgConverter's MethodCallback, property accessors, adapters), the boundary converts it to a real @throw into the native caller - always after every V8 scope has destructed (pendingThrow captured before the scopes, thrown after the inner block). Unbranded throws keep today's semantics: ReThrow to the message listener in MethodCallback, and report-once-plus-defined- default at the former tns::Assert abort sites (property getters/ setters, Array/Dictionary/FastEnumeration adapters no longer abort the process on a JS throw). Add opt-in crashOnUncaughtJsExceptions (default off): unprevented fatal errors schedule an uncaught @throw on the runtime loop from a clean, V8-scope-free frame so crash reporters capture a real native crash with the JS stack in userInfo. Branded escapes reaching the fatal tail (e.g. thrown from an error-event listener) always take that path.
…jectionhandled reason)
- GetEscapeExceptionBrand now checks Caches::IsValid() — Caches::Get
returns a dummy cache (never null) after isolate disposal, so the
nullptr check could not honor the documented empty-handle contract.
- The property setter's fallback ObjCDataWrapper is now released via
DeleteWrapperIfUnused, mirroring the getter (pre-existing asymmetry).
- rejectionhandled now carries the original rejection reason:
reportedOutstanding_/pendingRejectionHandled_ retain {promise, reason}
pairs (promise phantom-weak, so a GC'd promise still drops the entry
and its reason), per PromiseRejectionEvent semantics.
0d1e372 to
259a740
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
TestRunner/app/tests/ErrorEventsTests.js (1)
34-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the runloop-turn polling helpers into a shared test utility. All three suites hand-roll the same "poll every ~10ms until predicate or turn limit, otherwise wait N quiet turns" pattern, with only cosmetic differences (turn counts, delays, generic predicate vs. hardcoded check). One shared helper module would reduce triplicated logic and make future timing tweaks (e.g., CI flakiness fixes) a single-file change.
TestRunner/app/tests/ErrorEventsTests.js#L34-L52: keep the genericpollUntil(predicate, cb)/afterQuietTurns(cb)pair here (or move to a shared helper file) as the canonical implementation.TestRunner/app/tests/EscapeExceptionTests.js#L31-L41: replace the localpollUntilwith the shared helper.TestRunner/app/tests/Promises.js#L177-L194: replaceafterDrain/afterQuietTurnswith the sharedpollUntil/afterQuietTurns, passing() => reported.length > 0as the predicate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@TestRunner/app/tests/ErrorEventsTests.js` around lines 34 - 52, Extract the canonical pollUntil(predicate, cb) and afterQuietTurns(cb) implementations from ErrorEventsTests.js into a shared test utility, preserving their existing timing and turn-limit behavior. Update EscapeExceptionTests.js to import and use the shared pollUntil instead of its local implementation. Update Promises.js to replace afterDrain/afterQuietTurns with the shared pollUntil and afterQuietTurns, using reported.length > 0 as the polling predicate; apply these changes at TestRunner/app/tests/ErrorEventsTests.js lines 34-52, TestRunner/app/tests/EscapeExceptionTests.js lines 31-41, and TestRunner/app/tests/Promises.js lines 177-194.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@TestRunner/app/tests/ErrorEventsTests.js`:
- Around line 34-52: Extract the canonical pollUntil(predicate, cb) and
afterQuietTurns(cb) implementations from ErrorEventsTests.js into a shared test
utility, preserving their existing timing and turn-limit behavior. Update
EscapeExceptionTests.js to import and use the shared pollUntil instead of its
local implementation. Update Promises.js to replace afterDrain/afterQuietTurns
with the shared pollUntil and afterQuietTurns, using reported.length > 0 as the
polling predicate; apply these changes at
TestRunner/app/tests/ErrorEventsTests.js lines 34-52,
TestRunner/app/tests/EscapeExceptionTests.js lines 31-41, and
TestRunner/app/tests/Promises.js lines 177-194.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 633730f5-34d1-4d1b-95dd-ff5020c1369a
📒 Files selected for processing (25)
NativeScript/runtime/ArgConverter.hNativeScript/runtime/ArgConverter.mmNativeScript/runtime/ArrayAdapter.mmNativeScript/runtime/Caches.cppNativeScript/runtime/Caches.hNativeScript/runtime/ClassBuilder.mmNativeScript/runtime/DataWrapper.hNativeScript/runtime/DictionaryAdapter.mmNativeScript/runtime/FastEnumerationAdapter.mmNativeScript/runtime/Interop.hNativeScript/runtime/Interop.mmNativeScript/runtime/InteropTypes.mmNativeScript/runtime/MetadataBuilder.mmNativeScript/runtime/NativeScriptException.hNativeScript/runtime/NativeScriptException.mmNativeScript/runtime/Runtime.hNativeScript/runtime/Runtime.mmNativeScript/runtime/WorkerWrapper.mmTestFixtures/TNSTestNativeCallbacks.hTestFixtures/TNSTestNativeCallbacks.mTestRunner/app/tests/ErrorEventsTests.jsTestRunner/app/tests/EscapeExceptionTests.jsTestRunner/app/tests/MethodCallsTests.jsTestRunner/app/tests/Promises.jsTestRunner/app/tests/index.js
🚧 Files skipped from review as they are similar to previous changes (17)
- TestRunner/app/tests/index.js
- TestFixtures/TNSTestNativeCallbacks.h
- NativeScript/runtime/Runtime.h
- NativeScript/runtime/Caches.cpp
- NativeScript/runtime/ArgConverter.h
- NativeScript/runtime/InteropTypes.mm
- NativeScript/runtime/Runtime.mm
- NativeScript/runtime/MetadataBuilder.mm
- NativeScript/runtime/ClassBuilder.mm
- NativeScript/runtime/Interop.h
- TestFixtures/TNSTestNativeCallbacks.m
- NativeScript/runtime/FastEnumerationAdapter.mm
- NativeScript/runtime/WorkerWrapper.mm
- NativeScript/runtime/ArrayAdapter.mm
- NativeScript/runtime/NativeScriptException.h
- NativeScript/runtime/Caches.h
- NativeScript/runtime/ArgConverter.mm
…tException
Event and EventTarget are general-purpose web primitives, not exception
machinery. The generic bootstrap (Event, EventTarget, the globalThis
mixin and the internal backing target, now stashed in
Caches::GlobalEventTarget for future native consumers like AbortSignal)
moves to Events.{h,cpp}; the error layer (ErrorEvent,
PromiseRejectionEvent, reportError and the native dispatch closures)
moves to ErrorEvents.{h,cpp}. NativeScriptException keeps only the
reporting machinery and the rejection tracker.
Pure code motion aside from the cross-layer handoff: the error layer
installs the listener-error reporter into the Events closure through a
one-shot _installListenerErrorReporter hook (the backing target is
reachable from app code via event.target, so the hook removes itself
after runtime init). No behavior change; both new .cpp files are
registered in the Xcode project.
…tive callers) Ports the identity-preservation half of iOS's interop.escapeException (NativeScript/ios#409). The escape direction itself is already Android's default - a JS throw in a native-invoked override surfaces to the Java caller as a real com.tns.NativeScriptException - but a rethrown *Java* exception lost its identity: ReThrowToJava wrapped it, so the caller's catch of the concrete type (e.g. IOException) never matched. Now, mirroring iOS: - New `interop` global (per isolate, workers included) with interop.escapeException(x): returns a fresh JS Error branded via a private symbol whose payload carries the original Java throwable when x is/carries one (nativeException), or {name, message, stack} synthesis info otherwise. Idempotent; TypeError with no arguments. - At the JS->Java boundary, a branded error carrying a Java throwable is rethrown UNWRAPPED (env.Throw of the original object), so a native `catch (IOException e)` above the caller matches and Throwable identity is preserved. JNI does not enforce checked-exception declarations, so any Throwable propagates. - Branded errors bypass discardUncaughtJsExceptions (which only handles com.tns.NativeScriptException) - an explicit forward request, matching iOS semantics where branded escapes ignore the discard flag. - Unbranded throws keep today's semantics exactly (wrapper with the original as cause); branded plain JS errors keep the default NativeScriptException escape shape. Testing: 7 new specs (brand semantics + boundary round-trips through a new EscapeExceptionTest fixture, asserting the caller catches the original java.io.IOException by identity, and that unbranded/plain behavior is unchanged). Full suite green on an API 35 arm64 emulator.
interop.escapeException now captures the escape-site JS stack alongside the wrapping error's own stack, and every NSException leaving the escape/crash machinery carries the JavaScript context: - synthesized escapes keep the stack in the reason and under the documented userInfo keys (JavaScriptStack, plus JavaScriptEscapeStack when the escape site differs from the origin); - rethrown ORIGINAL NSExceptions get a combined 'JS stack / Escaped at' string attached as an associated object, preserving the exception's identity and userInfo for parent @catch matching; - the crashOnUncaughtJsExceptions fatal exception is attached the same way. New NSException (NativeScript) category exposes it uniformly to native code and crash-SDK hooks via -[NSException tns_javascriptStackTrace] (associated object first, userInfo fallback); the userInfo keys are exported as TNSJavaScriptStackTraceKey/TNSJavaScriptEscapeStackTraceKey and are the stable integration contract. Non-Error escapes (escapeException("boom")) now carry the escape-site stack instead of nothing.
Companion to the iOS runtime's JS-stack DX work on NativeScript/ios#409, using the JVM primitive iOS doesn't have: Throwable.addSuppressed. - New com.tns.JavaScriptStackTrace: a never-thrown Throwable carrying a JS stack, with StackTraceElements synthesized from the V8 frames (its own Java capture is suppressed). Attached generically via attach() so future exception paths can adopt it; SDK integrations read the raw stacks through getJavaScriptStack()/getEscapeSiteStack(). - Original-throwable escapes: the boundary attaches the carrier as a suppressed exception before rethrowing, so the JS journey renders automatically in printStackTrace(), logcat fatal logs and crash reporters ("Suppressed: com.tns.JavaScriptStackTrace: ...") while the original object's identity, class, stack and cause chain stay untouched. Idempotent: the same throwable escaping twice through nested overrides gets one carrier. - Escape-site capture: escapeException() now records the stack of its own call site (the branded Error's natural V8 stack, grabbed before the origin stack overwrites it) alongside the origin stack. For non-Error values it is the only stack available and was previously lost. - Synthesized escapes: the com.tns.NativeScriptException thrown for a branded error with no underlying Java throwable gets its stack trace replaced with the synthesized JS frames, so crash reporters group these by where they actually happened in JS instead of bucketing everything under the identical JNI boundary machinery. Testing: 3 new specs (suppressed carrier contents + escape-site accessor, duplicate-carrier guard across nested escapes, non-Error escape-site fallback) plus JS-frame assertions on the synthesized path. Full suite green on an API 35 arm64 emulator.
New docs folder with the first runtime feature doc: the WHATWG error model (error/unhandledrejection/rejectionhandled events, reportError), what lands on the events for each throw shape, catching native exceptions in JS via error.nativeException, forwarding JS throws to native with interop.escapeException, JS stacks on NSException (tns_javascriptStackTrace and the userInfo keys), configuration flags, crash-reporter integration on both layers, legacy hook status and the behavioral invariants.
Documents the web-compliant error model: global error/unhandledrejection/ rejectionhandled events and reportError, Java exception round-tripping (error.nativeException), interop.escapeException and the com.tns.JavaScriptStackTrace carrier, configuration flags with the terminal-path decision table, and crash-reporter integration on both the JS and Java sides. Adds a docs/ index and links it from the README, mirroring the docs added on the iOS PR (NativeScript/ios#409).
Replace the never-released crashOnUncaughtJsExceptions flag with the
cross-runtime uncaughtErrorPolicy contract:
{ "uncaughtErrorPolicy": "report" } // default: report, keep running
{ "uncaughtErrorPolicy": "throw" } // rethrow unprevented errors natively
"throw" keeps the same mechanics (deferred NativeScriptFatalJSException
from a clean scope-free frame, JS stack in reason/userInfo) under an
honest name: it is a throw, not a crash guarantee - the app terminates
only if no native handler catches it. Unknown values warn once and fall
back to "report".
discardUncaughtJsExceptions is deprecated but still honored in full for
now (routes to __onDiscardedError, skips the fatal log, suppresses the
throw policy); it logs a one-time deprecation warning pointing at
preventDefault() and uncaughtErrorPolicy.
…tackTrace Android parity round for the 9.1 error unification: - error.stackTrace / reason.stackTrace are now populated (combined smart stack) BEFORE the error/unhandledrejection events dispatch, so listeners can read them like on Android, and the fatal tail reuses the derived stack instead of recomputing it. - uncaughtErrorPolicy 'throw' uses a claim-slot handoff: the fatal tail deposits the NativeScriptFatalJSException in a per-isolate registry and schedules an identity-checked clean-frame fallback. Boundaries that report within their own frame (property accessors, adapter reads) claim the deposit under their V8 scopes and @throw it after scope teardown, so a native @try/@catch around that call catches it (matching Android). Block/overridden-method callbacks surface the throw to V8 (so JS-initiated chains keep propagating to JS catchers) and are covered by the deferred fallback, as are loop-originated errors. Double-throw is impossible: single-consumer claim with pointer-identity check on the fallback. - The registry is heap-allocated and never destroyed: a global Runtime can destruct during __cxa_finalize at process exit, and locking a destroyed file-static mutex from ~Runtime would std::terminate.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
NativeScript/runtime/NativeScriptException.h (1)
119-143: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winEnsure
Drainonly observes after rejecting threads are serialized or defer the snapshot.
OnRejectaccessespending_,reportedOutstanding_, andpendingRejectionHandled_whileDrainswaps the same containers after pollingpendingCount_. The atomic signals work to schedule a drain, but it does not serialize the container reads/writes; a rejection callback that observespendingCount_as non-zero can still race with theDrainsnapshot unless the embedder guarantees rejection callbacks are serialized separately from the runloop drain orOnRejectenqueues work to be handled under the same lock fromDrain.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@NativeScript/runtime/NativeScriptException.h` around lines 119 - 143, Synchronize access to pending_, reportedOutstanding_, and pendingRejectionHandled_ between PromiseRejectionTracker::OnReject, OnHandlerAdded, and Drain; pendingCount_ only schedules work and does not protect container operations. Either ensure Drain runs only after rejection callbacks are serialized by the embedder, or defer the snapshot and perform all queue mutations under a shared lock, preserving the existing per-isolate behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/error-handling.md`:
- Line 214: Clarify the boundary throw-timing documentation to distinguish
synchronous rethrows for policy throws from property and DictionaryAdapter
boundaries after V8 scope teardown, while reserving deferred throwing for
loop-originated and applicable callback paths. Preserve the invariant that ObjC
exceptions never unwind through live V8 frames.
- Around line 119-121: Update the escapeException documentation to limit its
native `@throw` conversion guarantee to supported boundaries, explicitly excluding
fast enumeration over JS objects and DictionaryAdapter.getProperties. Keep the
existing limitation and ordinary-JS-exception behavior clear, and avoid claiming
the escape works at every boundary.
- Around line 191-200: Update the crash-reporter example around the global error
listener to also handle unhandled promise rejections: extract the shared
reporting logic into a helper, register a globalThis unhandledrejection listener
that passes the rejection reason through it, and preserve the existing native
exception metadata handling for error events.
- Line 203: Update the NSUncaughtExceptionHandler/signal-layer description in
the documentation to distinguish escaped exceptions from uncaughtErrorPolicy:
"throw": escaped exceptions bypass the JS event layer, while policy throws
dispatch JS events and reporting hooks before the later native uncaught throw.
Retain the guidance to read exception.tns_javascriptStackTrace or userInfo keys
at the native crash boundary.
---
Outside diff comments:
In `@NativeScript/runtime/NativeScriptException.h`:
- Around line 119-143: Synchronize access to pending_, reportedOutstanding_, and
pendingRejectionHandled_ between PromiseRejectionTracker::OnReject,
OnHandlerAdded, and Drain; pendingCount_ only schedules work and does not
protect container operations. Either ensure Drain runs only after rejection
callbacks are serialized by the embedder, or defer the snapshot and perform all
queue mutations under a shared lock, preserving the existing per-isolate
behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f389a9b7-ebf7-4773-8ef9-9d2f2f2dc65c
📒 Files selected for processing (20)
NativeScript/runtime/ArgConverter.mmNativeScript/runtime/Caches.hNativeScript/runtime/ErrorEvents.cppNativeScript/runtime/ErrorEvents.hNativeScript/runtime/Events.cppNativeScript/runtime/Events.hNativeScript/runtime/InteropTypes.mmNativeScript/runtime/NSExceptionSupport.hNativeScript/runtime/NSExceptionSupport.mmNativeScript/runtime/NativeScriptException.hNativeScript/runtime/NativeScriptException.mmNativeScript/runtime/Runtime.mmREADME.mdTestFixtures/TNSTestNativeCallbacks.hTestFixtures/TNSTestNativeCallbacks.mTestRunner/app/tests/ErrorEventsTests.jsTestRunner/app/tests/EscapeExceptionTests.jsdocs/README.mddocs/error-handling.mdv8ios.xcodeproj/project.pbxproj
🚧 Files skipped from review as they are similar to previous changes (4)
- NativeScript/runtime/Runtime.mm
- NativeScript/runtime/ArgConverter.mm
- NativeScript/runtime/InteropTypes.mm
- NativeScript/runtime/Caches.h
| - `escapeException` is the per-call tool and works **regardless of `uncaughtErrorPolicy`** and **at every boundary** (including native→JS blocks and overridden methods): it bypasses JS-side reporting entirely and converts *that one throw* into a native `@throw`. `uncaughtErrorPolicy: "throw"` is the global policy — it applies only to *unprevented, unbranded* errors, only *after* they are reported, and only rethrows synchronously at boundaries that report within their own frame (property accessors / adapters); block and overridden-method boundaries fall back to the deferred clean-frame throw. When you need a native handler around a specific block/method call to catch a JS failure, use `escapeException`, not the policy. | ||
|
|
||
| Limitations: two boundaries execute under the *caller's* live V8 scopes and therefore cannot convert a branded escape into a native throw — fast enumeration (`for...in` over a JS object from native) and `DictionaryAdapter.getProperties`. There, a branded escape surfaces as an ordinary JS exception. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Scope the “every boundary” guarantee.
Line 119 promises native conversion at every boundary, but line 121 documents fast enumeration and DictionaryAdapter.getProperties as unsupported. Narrow the guarantee to supported boundaries so callers do not expect a native @try/@catch to observe an exception in those unsupported paths.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/error-handling.md` around lines 119 - 121, Update the escapeException
documentation to limit its native `@throw` conversion guarantee to supported
boundaries, explicitly excluding fast enumeration over JS objects and
DictionaryAdapter.getProperties. Keep the existing limitation and
ordinary-JS-exception behavior clear, and avoid claiming the escape works at
every boundary.
| globalThis.addEventListener("error", (e) => { | ||
| const err = e.error; | ||
| const native = nativeExceptionOf(err); | ||
| crashReporter.capture(err instanceof Error ? err : new Error(e.message), { | ||
| nativeName: native?.name, | ||
| nativeReason: native?.reason, | ||
| nativeStack: native?.callStackSymbols, // NSArray of symbolicated frames | ||
| }); | ||
| // e.preventDefault(); // only if the reporter fully owns error handling | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Include unhandled rejections in the crash-reporter example.
The documented reporting model includes unhandledrejection, but this sample only listens for error. Add a shared capture helper and an unhandledrejection listener, or explicitly scope the example to uncaught exceptions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/error-handling.md` around lines 191 - 200, Update the crash-reporter
example around the global error listener to also handle unhandled promise
rejections: extract the shared reporting logic into a helper, register a
globalThis unhandledrejection listener that passes the rejection reason through
it, and preserve the existing native exception metadata handling for error
events.
| }); | ||
| ``` | ||
|
|
||
| Native side — for the `NSUncaughtExceptionHandler`/signal layer, read `exception.tns_javascriptStackTrace` (or the `userInfo` keys) to attach the JS stack to crashes that never pass through the JS event layer (escaped exceptions, `uncaughtErrorPolicy: "throw"`). |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the policy-throw event-path description.
uncaughtErrorPolicy: "throw" still dispatches the JS event and reporting hooks before depositing the native exception. Only the later uncaught native throw reaches NSUncaughtExceptionHandler; “never pass through the JS event layer” is accurate for escaped exceptions, not policy throws.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/error-handling.md` at line 203, Update the
NSUncaughtExceptionHandler/signal-layer description in the documentation to
distinguish escaped exceptions from uncaughtErrorPolicy: "throw": escaped
exceptions bypass the JS event layer, while policy throws dispatch JS events and
reporting hooks before the later native uncaught throw. Retain the guidance to
read exception.tns_javascriptStackTrace or userInfo keys at the native crash
boundary.
| - Every error is reported exactly once: either the V8 message listener (synchronous throws), the rejection drain (once per run-loop turn, `kCFRunLoopBeforeWaiting`), or the boundary handler — never two of them for the same error. | ||
| - A rejection that gets a handler before the end-of-turn drain is never reported (and produces no `rejectionhandled` either). | ||
| - Rejection events carry the underlying V8 promise. `Promise.reject(...)` returns a `PromiseProxy`-wrapped object, so compare promises between events (`unhandledrejection` ↔ `rejectionhandled`), not against your proxy handle. | ||
| - ObjC exceptions never unwind through live V8 frames: boundaries catch on the JS side and rethrow after scope teardown, and crash-mode throws are deferred to a clean run-loop frame. Preserve this invariant when touching the boundary code. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Document boundary-dependent throw timing.
This says crash-mode throws are deferred to a clean run-loop frame, but policy throws from property and DictionaryAdapter boundaries are rethrown synchronously after V8 scopes unwind. Clarify that only loop-originated and certain callback paths use deferred throwing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/error-handling.md` at line 214, Clarify the boundary throw-timing
documentation to distinguish synchronous rethrows for policy throws from
property and DictionaryAdapter boundaries after V8 scope teardown, while
reserving deferred throwing for loop-originated and applicable callback paths.
Preserve the invariant that ObjC exceptions never unwind through live V8 frames.
What this does
Adopts the WHATWG error model at the runtime level so
@nativescript/coreno longer needs bespoke error hooks, closes the silently-swallowed-rejections hole, and adds an explicit escape hatch for forwarding real native exceptions to native callers.1. Unhandled promise rejection tracking
The runtime never registered
SetPromiseRejectCallback, so unhandled rejections vanished with no log, no callback, no crash. Now:kCFRunLoopBeforeWaitingobserver).__onUncaughtError/__onDiscardedError, honoringdiscardUncaughtJsExceptions), prefixedUnhandled promise rejection:.onerror→ main isolate'sworker.onerror).2. WHATWG error events on globalThis
Spec-shaped
Event,EventTarget,ErrorEvent,PromiseRejectionEventconstructors,addEventListener/removeEventListener/dispatchEventonglobalThis, and globalreportError()— same surface as workers/Deno, nowindowneeded.errorErrorEvent; unhandled rejections a cancelableunhandledrejectionPromiseRejectionEvent.preventDefault()= fully handled (no shim, no fatal log, no error modal).rejectionhandled(as a task, per spec, carrying the original reason; already-reported promises held phantom-weak so GC'd ones drop out).globalThis.dispatchEvent.__onUncaughtError/__onDiscardedErrorkeep working, so current core needs zero changes (they're now deprecated shims; a follow-up core PR can migrateapplication-common.tstoglobalThis.addEventListener).3.
interop.escapeException+ boundary hardeningNSExceptionaserror.nativeException(name/message from the exception) — re-enables the long-dormantThrow_ObjC_exceptions_to_JavaScripttest, now on simulator too.throw interop.escapeException(x)from a JS override/block converts to a real@throwinto the native caller at the boundary — a parent native@try/@catchsees the originalNSException(or one synthesized from the JS error, with the JS stack inuserInfo). Throws happen strictly after every V8 scope has destructed; ObjC exceptions never unwind through live V8 frames.tns::Assertprocess-aborts on JS throws in property getters/setters and the Array/Dictionary/FastEnumeration adapters are gone — those boundaries now report through the uncaught path and return a defined default.uncaughtErrorPolicyconfig — the cross-runtime (9.1) uncaught-error contract:"report"(default, report and keep running) or"throw"(unprevented errors are rethrown natively from a clean frame — a throw, not a crash guarantee; terminates only if no native handler catches it, in which case crash reporters capture it with the JS stack attached).discardUncaughtJsExceptionsis deprecated but still honored in full (routes to__onDiscardedError, skips the fatal log, suppresses the throw) with a one-time deprecation warning.Before / after examples
Unhandled rejections
Error events and reportError
Catching native exceptions in JS
Forwarding exceptions to a native caller (
interop.escapeException)Boundary hardening
Uncaught error policy
With
"throw", an error that no listenerpreventDefault()s is rethrown as a realNSException(JS stack in the reason anduserInfo["JavaScriptStack"]). Boundaries that report within their own frame (property accessors, adapter reads invoked from native) rethrow it synchronously at that boundary — a native@try/@catcharound the call can catch it, matching Android; loop-originated errors and block/overridden-method callbacks are thrown from a clean frame on the runtime loop. Either way it's a throw, not a crash guarantee: the app terminates only if nothing catches it, in which case crash reporters capture it properly.discardUncaughtJsExceptionsis deprecated (still honored in full for now, with a one-time warning) — migrate topreventDefault()per error, or the policy.What lands on the events (crash-reporter integration guide)
Both events also carry a combined
stackTracestring on the error/reason object, populated before dispatch (matching Android):e.error.stackTrace/e.reason.stackTrace.The
errorevent is a spec-shapedErrorEvent:{ type: "error", cancelable: true, message: string, error: <the thrown value>, filename: "", lineno: 0, colno: 0 }(file/line/col are not populated yet). Theunhandledrejectionevent is aPromiseRejectionEvent:{ type: "unhandledrejection", cancelable: true, promise, reason: <the rejection value> }. The stacks live on the error/reason value, not on the event — anderror/reasoncan be any thrown value, so shape-check before use. The three cases:e.error/e.reasonisthrow new Error("x")(uncaught)Errore.error.stack(throw site)someNativeMethodThatThrows()without try/catchErrorwithname= the NSException name,message= its reasone.error.stack(the JS call site of the native method)e.error.nativeException— the originalNSException:.name,.reason,.userInfo,.callStackSymbols(captured at the native@throw)throw NSException.exceptionWithNameReasonUserInfo(...)(throwing a wrapped native object directly)NSExceptionitself — not anError, so no.stackmessageis its description)e.errordirectly (instanceof NSException); note.callStackSymbolsis only populated if it was actually thrown natively at some point, not when merely constructed in JSA Sentry-style listener that attaches both sides:
The native side of the same story (for the NSUncaughtExceptionHandler / mach-signal layer of crash SDKs): every
NSExceptionproduced by this PR's machinery carries the JavaScript context. Synthesized escapes and theuncaughtErrorPolicy: "throw"fatal exception have it inuserInfo[@"JavaScriptStack"](plus@"JavaScriptEscapeStack"when theescapeExceptioncall site differs from the error's origin); rethrown originalNSExceptions keep their identity anduserInfountouched and carry a combined "JS stack / Escaped at" string as an associated object. One accessor covers all cases:TNSJavaScriptStackTraceKey/TNSJavaScriptEscapeStackTraceKeyandtns_javascriptStackTraceare the stable integration contract.Behavior changes (default config)
preventDefault(), the new config flag) or identical.Testing
ApiTestsNSError shape (code/domain/nativeException),ExceptionHandlingTests, Promise thread-affinity,TNS Workers.TNSTestNativeCallbacks) exercise catch-in-native round trips.uncaughtErrorPolicy: "throw"manual smoke (documented inEscapeExceptionTests.js).Summary by CodeRabbit
interop.escapeException()to intentionally transfer JavaScript errors across native boundaries.error,unhandledrejection, andrejectionhandledevent dispatch withpreventDefault()semantics.