Skip to content

feat: web-compliant error handling (unhandled rejections, error events, interop.escapeException)#409

Open
edusperoni wants to merge 9 commits into
mainfrom
feat/error-handling
Open

feat: web-compliant error handling (unhandled rejections, error events, interop.escapeException)#409
edusperoni wants to merge 9 commits into
mainfrom
feat/error-handling

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main after #408 (libffi 3.7.1) was squash-merged. The escapeException feature relies on that libffi update's ObjC exception unwinding through closure trampolines (validated on arm64 simulator against the shipped slices).

What this does

Adopts the WHATWG error model at the runtime level so @nativescript/core no 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:

  • Rejections without handlers are tracked per-isolate and drained once per runloop turn (kCFRunLoopBeforeWaiting observer).
  • Unhandled ones report through the same machinery as uncaught sync exceptions (__onUncaughtError/__onDiscardedError, honoring discardUncaughtJsExceptions), prefixed Unhandled promise rejection:.
  • A handler attached before the drain cancels the report. Worker rejections flow through the existing worker error channel (worker-global onerror → main isolate's worker.onerror).

2. WHATWG error events on globalThis

Spec-shaped Event, EventTarget, ErrorEvent, PromiseRejectionEvent constructors, addEventListener/removeEventListener/dispatchEvent on globalThis, and global reportError() — same surface as workers/Deno, no window needed.

  • Uncaught errors dispatch a cancelable error ErrorEvent; unhandled rejections a cancelable unhandledrejection PromiseRejectionEvent. preventDefault() = fully handled (no shim, no fatal log, no error modal).
  • Late-attached handlers fire rejectionhandled (as a task, per spec, carrying the original reason; already-reported promises held phantom-weak so GC'd ones drop out).
  • Native code dispatches through closures captured at init, so events keep firing even if app code overwrites globalThis.dispatchEvent.
  • Back-compat: unprevented errors behave byte-for-byte as before — __onUncaughtError/__onDiscardedError keep working, so current core needs zero changes (they're now deprecated shims; a follow-up core PR can migrate application-common.ts to globalThis.addEventListener).

3. interop.escapeException + boundary hardening

  • Native calls that throw now preserve the original NSException as error.nativeException (name/message from the exception) — re-enables the long-dormant Throw_ObjC_exceptions_to_JavaScript test, now on simulator too.
  • throw interop.escapeException(x) from a JS override/block converts to a real @throw into the native caller at the boundary — a parent native @try/@catch sees the original NSException (or one synthesized from the JS error, with the JS stack in userInfo). Throws happen strictly after every V8 scope has destructed; ObjC exceptions never unwind through live V8 frames.
  • The tns::Assert process-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.
  • New uncaughtErrorPolicy config — 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). discardUncaughtJsExceptions is 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

// BEFORE: this vanished — no log, no hook, no crash. The error object was
// simply garbage-collected.
Promise.reject(new Error("boom"));

// AFTER: logged as "Unhandled promise rejection:" through the same pipeline
// as uncaught errors (Application.uncaughtErrorEvent still fires via the
// __onUncaughtError shim), and observable the standard web way:
globalThis.addEventListener("unhandledrejection", (e) => {
  console.log(e.reason.message); // "boom"
  console.log(e.promise);        // the rejected promise
  e.preventDefault();            // handled: no shim, no fatal log, no modal
});

// Fires when a handler is attached after the rejection was already reported,
// carrying the original reason:
globalThis.addEventListener("rejectionhandled", (e) => {
  console.log("late catch for", e.reason);
});

Error events and reportError

// AFTER (new surface — before, the only hooks were the private
// __onUncaughtError/__onDiscardedError globals that core had to install):
globalThis.addEventListener("error", (e) => {
  crashReporter.record(e.error);  // any uncaught JS exception
  e.preventDefault();             // fully handles it
});

// Route a caught-but-fatal error through the exact same pipeline:
reportError(new Error("something unrecoverable"));

Catching native exceptions in JS

try {
  NSArray.alloc().init().objectAtIndex(3);
} catch (e) {
  // BEFORE: e was a generic Error holding only [exception description];
  // the original NSException object was lost.
  // AFTER:
  e.nativeException instanceof NSException; // true
  e.nativeException.name;                   // "NSRangeException"
  e.nativeException.reason;                 // "... index 3 beyond bounds ..."
}

Forwarding exceptions to a native caller (interop.escapeException)

// BEFORE: a throw inside a JS override was swallowed — the native caller
// resumed with a zeroed return value and no way to observe the failure.
// AFTER: brand the throw and the boundary converts it to a real @throw that
// a native @try/@catch above the caller can handle (or that crashes with a
// real native report if nothing catches it):
const Delegate = NSObject.extend({
  someNativeMethod() {
    throw interop.escapeException(new Error("propagate me natively"));
  },
}, { protocols: [SomeProtocol] });

// Rethrowing a caught native exception preserves the ORIGINAL NSException
// object, so a parent @catch (NSRangeException*) still matches:
try {
  riskyNativeCall();
} catch (e) {
  throw interop.escapeException(e);
}

// Plain (unbranded) throws keep today's semantics: reported through the
// uncaught path, native caller gets a defined default value.

Boundary hardening

// BEFORE: a JS throw inside a property getter/setter override or a
// Dictionary/Array adapter callback hit tns::Assert and ABORTED THE PROCESS.
// AFTER: the error is reported (error event + __onUncaughtError) and the
// native caller receives a defined default (nil/0/NO) — no abort.
const Obj = NSObject.extend({
  get someProperty() { throw new Error("oops"); }, // no longer kills the app
});

Uncaught error policy

// app/package.json — unified across NativeScript runtimes (9.1+):
{ "uncaughtErrorPolicy": "report" } // default: report and keep running
{ "uncaughtErrorPolicy": "throw" }  // unprevented errors are rethrown natively

With "throw", an error that no listener preventDefault()s is rethrown as a real NSException (JS stack in the reason and userInfo["JavaScriptStack"]). Boundaries that report within their own frame (property accessors, adapter reads invoked from native) rethrow it synchronously at that boundary — a native @try/@catch around 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. discardUncaughtJsExceptions is deprecated (still honored in full for now, with a one-time warning) — migrate to preventDefault() per error, or the policy.

What lands on the events (crash-reporter integration guide)

Both events also carry a combined stackTrace string on the error/reason object, populated before dispatch (matching Android): e.error.stackTrace / e.reason.stackTrace.

The error event is a spec-shaped ErrorEvent: { type: "error", cancelable: true, message: string, error: <the thrown value>, filename: "", lineno: 0, colno: 0 } (file/line/col are not populated yet). The unhandledrejection event is a PromiseRejectionEvent: { type: "unhandledrejection", cancelable: true, promise, reason: <the rejection value> }. The stacks live on the error/reason value, not on the event — and error/reason can be any thrown value, so shape-check before use. The three cases:

You wrote e.error / e.reason is JS stack Native stack
throw new Error("x") (uncaught) that Error e.error.stack (throw site)
someNativeMethodThatThrows() without try/catch an Error with name = the NSException name, message = its reason e.error.stack (the JS call site of the native method) e.error.nativeException — the original NSException: .name, .reason, .userInfo, .callStackSymbols (captured at the native @throw)
throw NSException.exceptionWithNameReasonUserInfo(...) (throwing a wrapped native object directly) the wrapped NSException itself — not an Error, so no .stack — (the event's message is its description) e.error directly (instanceof NSException); note .callStackSymbols is only populated if it was actually thrown natively at some point, not when merely constructed in JS

A Sentry-style listener that attaches both sides:

function nativeExceptionOf(value) {
  if (value instanceof NSException) return value;
  return (value && value.nativeException instanceof NSException) ? value.nativeException : null;
}

function nativeStackString(ex) {
  if (!ex || !ex.callStackSymbols) return undefined;
  const symbols = ex.callStackSymbols;
  const frames = [];
  for (let i = 0; i < symbols.count; i++) frames.push(symbols.objectAtIndex(i));
  return frames.join("\n");
}

globalThis.addEventListener("error", (e) => {
  const err = e.error;
  const native = nativeExceptionOf(err);
  Sentry.captureException(err instanceof Error ? err : new Error(e.message), {
    contexts: native && {
      nativeException: {
        name: native.name,
        reason: native.reason,
        stack: nativeStackString(native),
      },
    },
  });
  // e.preventDefault(); // only if Sentry fully owns reporting — suppresses the
  //                     // fatal log, the __onUncaughtError shim and the modal
});

globalThis.addEventListener("unhandledrejection", (e) => {
  const reason = e.reason; // same recipes: reason.stack / nativeExceptionOf(reason)
  Sentry.captureException(reason instanceof Error ? reason : new Error(String(reason)));
});

The native side of the same story (for the NSUncaughtExceptionHandler / mach-signal layer of crash SDKs): every NSException produced by this PR's machinery carries the JavaScript context. Synthesized escapes and the uncaughtErrorPolicy: "throw" fatal exception have it in userInfo[@"JavaScriptStack"] (plus @"JavaScriptEscapeStack" when the escapeException call site differs from the error's origin); rethrown original NSExceptions keep their identity and userInfo untouched and carry a combined "JS stack / Escaped at" string as an associated object. One accessor covers all cases:

#import <NativeScript/NSExceptionSupport.h>
NSString* jsStack = exception.tns_javascriptStackTrace; // nil when no JS involvement

TNSJavaScriptStackTraceKey / TNSJavaScriptEscapeStackTraceKey and tns_javascriptStackTrace are the stable integration contract.

Behavior changes (default config)

  • Unhandled rejections are now reported (previously silent). Everything else is opt-in (preventDefault(), the new config flag) or identical.
  • JS throws in property accessors/adapters no longer abort the process.

Testing

  • Full suite: 900 tests, 0 failures (was 869) — 31 new specs: unhandled rejections (5), WHATWG events (17), escapeException/boundary hardening (8+), plus the re-enabled ObjC-exception test.
  • Guards kept green: ApiTests NSError shape (code/domain/nativeException), ExceptionHandlingTests, Promise thread-affinity, TNS Workers.
  • New ObjC fixtures (TNSTestNativeCallbacks) exercise catch-in-native round trips.
  • Outstanding: a device-hardware pass before release; uncaughtErrorPolicy: "throw" manual smoke (documented in EscapeExceptionTests.js).

Summary by CodeRabbit

  • New Features
    • Added interop.escapeException() to intentionally transfer JavaScript errors across native boundaries.
    • Added WHATWG-compatible error, unhandledrejection, and rejectionhandled event dispatch with preventDefault() semantics.
    • Added infrastructure for tracking and draining unhandled promise rejections per run loop turn.
  • Bug Fixes
    • Hardened JS↔native boundary behavior for property, collection, and iterator access by translating boundary failures into native exceptions and delaying rethrow until scopes complete.
    • Improved uncaught error and exception propagation fidelity, including JavaScript stack attachment.
  • Documentation
    • Documented runtime error-handling behavior and interop details.
  • Tests
    • Added/expanded suites for error events, promise rejection reporting, escape/round-trip behavior, and adapter boundary hardening.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Exception and error event boundaries

Layer / File(s) Summary
Contracts and runtime state
NativeScript/runtime/ArgConverter.h, NativeScript/runtime/Caches.h, NativeScript/runtime/Caches.cpp, NativeScript/runtime/NativeScriptException.h, NativeScript/runtime/Runtime.h, NativeScript/runtime/Interop.h, NativeScript/runtime/DataWrapper.h
Public ArgConverter methods for escape brand access and exception extraction; Caches adds members for promise rejection tracking, cached event dispatch functions, and escape brand storage; NativeScriptException extends with rejection-tracking classes, error-reporting APIs, and policy-throw handoff; Runtime adds rejection-observer member.
Escape exception extraction and conversion
NativeScript/runtime/ArgConverter.mm, NativeScript/runtime/InteropTypes.mm, NativeScript/runtime/Interop.mm, NativeScript/runtime/NSExceptionSupport.h, NativeScript/runtime/NSExceptionSupport.mm
Branded JavaScript errors are extracted from branded private values and converted to NSException with JS stack attachment; native exceptions in callback boundaries are wrapped as JS Error objects with attached nativeException property.
Adapter and FFI boundary hardening
NativeScript/runtime/ArrayAdapter.mm, NativeScript/runtime/DictionaryAdapter.mm, NativeScript/runtime/FastEnumerationAdapter.mm, NativeScript/runtime/ClassBuilder.mm, NativeScript/runtime/MetadataBuilder.mm
V8 boundary failures are captured using TryCatch and converted to NSException via ArgConverter::HandleBoundaryException; native rethrow is deferred until after all V8 scopes close to prevent exception unwinding through live frames.
Error events and WHATWG integration
NativeScript/runtime/Events.h, NativeScript/runtime/Events.cpp, NativeScript/runtime/ErrorEvents.h, NativeScript/runtime/ErrorEvents.cpp
Events installs a global WHATWG-compatible EventTarget polyfill with listener registration and dispatch; ErrorEvents defines error, unhandledrejection, and rejectionhandled event constructors and dispatch helpers with preventDefault support.
Promise rejection tracking and lifecycle
NativeScript/runtime/NativeScriptException.mm (PromiseRejectionTracker implementation)
Per-isolate PromiseRejectionTracker records rejected promises and handler additions, drains pending rejections with event dispatch, dispatches late rejectionhandled callbacks, and forwards worker rejections to the main isolate with optional worker onerror interception.
Uncaught error reporting and policy
NativeScript/runtime/NativeScriptException.mm (ReportToJsHandlersAndLog, ReportUnhandledRejection, ReportFatalTail)
Uncaught errors and unhandled rejections are routed through error-event dispatch with cancelable preventDefault; when not prevented, fatal-tail reporting applies configuration-driven uncaughtErrorPolicy ("report" logs, "throw" deposits native exception for deferred rethrow).
Runtime integration and run-loop draining
NativeScript/runtime/Runtime.h, NativeScript/runtime/Runtime.mm, NativeScript/runtime/WorkerWrapper.mm
Runtime registers V8 promise-rejection callback, creates a CFRunLoopObserver to drain pending rejections on each run-loop turn before waiting, initializes Events and ErrorEvents on context setup; WorkerWrapper forwards unhandled rejections to the main isolate.
Test fixtures and integration validation
TestFixtures/TNSTestNativeCallbacks.h, TestFixtures/TNSTestNativeCallbacks.m, TestRunner/app/tests/index.js, TestRunner/app/tests/*.js
Native helpers enable exception catching, JS stack retrieval, and value extraction; test suites validate escaped exceptions, adapter boundary safety, error-event mechanics, promise rejection timing, and native exception metadata propagation.
Documentation
README.md, docs/README.md, docs/error-handling.md
Implementation guide covers the new error-handling model, WHATWG events, promise rejection lifecycle, native exception round-tripping, interop.escapeException boundary semantics, configuration policies, and crash reporter integration patterns.
Build configuration
v8ios.xcodeproj/project.pbxproj
Xcode project updated to compile and link three new runtime modules: ErrorEvents, Events, and NSExceptionSupport.

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
Loading

Possibly related PRs

  • NativeScript/ios#375: Overlaps with native-to-JavaScript exception propagation and uncaught-error reporting logic.
  • NativeScript/ios#384: Touches the same ArgConverter::MethodCallback boundary-handling area.

Suggested reviewers: nathanwalker

Poem

I'm a rabbit guarding the bridge,
Where JS and native meet.
Escaped errors hop safely through,
Rejections drain on beat.
Events bloom, workers call—
No V8 scope shall make us fall! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change set: web-compliant error handling, including unhandled rejections, error events, and interop.escapeException.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f41d7e5 and b36ab05.

📒 Files selected for processing (25)
  • NativeScript/runtime/ArgConverter.h
  • NativeScript/runtime/ArgConverter.mm
  • NativeScript/runtime/ArrayAdapter.mm
  • NativeScript/runtime/Caches.cpp
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/ClassBuilder.mm
  • NativeScript/runtime/DataWrapper.h
  • NativeScript/runtime/DictionaryAdapter.mm
  • NativeScript/runtime/FastEnumerationAdapter.mm
  • NativeScript/runtime/Interop.h
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/InteropTypes.mm
  • NativeScript/runtime/MetadataBuilder.mm
  • NativeScript/runtime/NativeScriptException.h
  • NativeScript/runtime/NativeScriptException.mm
  • NativeScript/runtime/Runtime.h
  • NativeScript/runtime/Runtime.mm
  • NativeScript/runtime/WorkerWrapper.mm
  • TestFixtures/TNSTestNativeCallbacks.h
  • TestFixtures/TNSTestNativeCallbacks.m
  • TestRunner/app/tests/ErrorEventsTests.js
  • TestRunner/app/tests/EscapeExceptionTests.js
  • TestRunner/app/tests/MethodCallsTests.js
  • TestRunner/app/tests/Promises.js
  • TestRunner/app/tests/index.js

Comment thread NativeScript/runtime/ArgConverter.h
Comment thread NativeScript/runtime/MetadataBuilder.mm Outdated
Comment thread NativeScript/runtime/NativeScriptException.h Outdated
@edusperoni

Copy link
Copy Markdown
Collaborator Author

Addressed all three review findings in 0d1e372 (full suite re-run: 900/900 green):

  1. GetEscapeExceptionBrand liveness — now checks cache->IsValid(); Caches::Get returns a dummy cache (never null) after isolate disposal, so the previous nullptr check was dead. Invalid caches return the documented empty handle, which every caller already handles.
  2. Setter wrapper leak (MetadataBuilder.mm) — the fallback ObjCDataWrapper in the property setter is now released via DeleteWrapperIfUnused, mirroring the getter. Note this asymmetry pre-dates this PR (it existed before the boundary rework), but it lives in code this PR touches so it's fixed here.
  3. rejectionhandled reason — the tracker now retains {promise, reason} pairs; the promise handle stays phantom-weak so a GC'd promise still drops the entry (reason released with it), and a late handler gets the original reason on the event per PromiseRejectionEvent semantics. The test now asserts e.reason identity.

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.
@edusperoni
edusperoni force-pushed the feat/error-handling branch from 0d1e372 to 259a740 Compare July 23, 2026 18:18
@edusperoni
edusperoni changed the base branch from feat/update-libffi to main July 23, 2026 18:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
TestRunner/app/tests/ErrorEventsTests.js (1)

34-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract 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 generic pollUntil(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 local pollUntil with the shared helper.
  • TestRunner/app/tests/Promises.js#L177-L194: replace afterDrain/afterQuietTurns with the shared pollUntil/afterQuietTurns, passing () => reported.length > 0 as 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0d1e372 and 259a740.

📒 Files selected for processing (25)
  • NativeScript/runtime/ArgConverter.h
  • NativeScript/runtime/ArgConverter.mm
  • NativeScript/runtime/ArrayAdapter.mm
  • NativeScript/runtime/Caches.cpp
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/ClassBuilder.mm
  • NativeScript/runtime/DataWrapper.h
  • NativeScript/runtime/DictionaryAdapter.mm
  • NativeScript/runtime/FastEnumerationAdapter.mm
  • NativeScript/runtime/Interop.h
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/InteropTypes.mm
  • NativeScript/runtime/MetadataBuilder.mm
  • NativeScript/runtime/NativeScriptException.h
  • NativeScript/runtime/NativeScriptException.mm
  • NativeScript/runtime/Runtime.h
  • NativeScript/runtime/Runtime.mm
  • NativeScript/runtime/WorkerWrapper.mm
  • TestFixtures/TNSTestNativeCallbacks.h
  • TestFixtures/TNSTestNativeCallbacks.m
  • TestRunner/app/tests/ErrorEventsTests.js
  • TestRunner/app/tests/EscapeExceptionTests.js
  • TestRunner/app/tests/MethodCallsTests.js
  • TestRunner/app/tests/Promises.js
  • TestRunner/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.
edusperoni added a commit to NativeScript/android that referenced this pull request Jul 23, 2026
…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.
edusperoni added a commit to NativeScript/android that referenced this pull request Jul 23, 2026
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.
edusperoni added a commit to NativeScript/android that referenced this pull request Jul 23, 2026
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Ensure Drain only observes after rejecting threads are serialized or defer the snapshot.

OnReject accesses pending_, reportedOutstanding_, and pendingRejectionHandled_ while Drain swaps the same containers after polling pendingCount_. The atomic signals work to schedule a drain, but it does not serialize the container reads/writes; a rejection callback that observes pendingCount_ as non-zero can still race with the Drain snapshot unless the embedder guarantees rejection callbacks are serialized separately from the runloop drain or OnReject enqueues work to be handled under the same lock from Drain.

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 259a740 and 30f5079.

📒 Files selected for processing (20)
  • NativeScript/runtime/ArgConverter.mm
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/ErrorEvents.cpp
  • NativeScript/runtime/ErrorEvents.h
  • NativeScript/runtime/Events.cpp
  • NativeScript/runtime/Events.h
  • NativeScript/runtime/InteropTypes.mm
  • NativeScript/runtime/NSExceptionSupport.h
  • NativeScript/runtime/NSExceptionSupport.mm
  • NativeScript/runtime/NativeScriptException.h
  • NativeScript/runtime/NativeScriptException.mm
  • NativeScript/runtime/Runtime.mm
  • README.md
  • TestFixtures/TNSTestNativeCallbacks.h
  • TestFixtures/TNSTestNativeCallbacks.m
  • TestRunner/app/tests/ErrorEventsTests.js
  • TestRunner/app/tests/EscapeExceptionTests.js
  • docs/README.md
  • docs/error-handling.md
  • v8ios.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

Comment thread docs/error-handling.md
Comment on lines +119 to +121
- `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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread docs/error-handling.md
Comment on lines +191 to +200
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
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread docs/error-handling.md
});
```

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"`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread docs/error-handling.md
- 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant