Skip to content

feat: web-compliant error handling#1985

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

feat: web-compliant error handling#1985
edusperoni wants to merge 7 commits into
mainfrom
feat/error-handling

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Description

Ports the web-compliant error model from NativeScript/ios#409 to the Android runtime: unhandled promise rejection tracking, WHATWG error events, and interop.escapeException — with the same file layout as the iOS implementation (Events.{h,cpp} for the generic primitives, ErrorEvents.{h,cpp} for the error layer, reporting machinery and the rejection tracker in NativeScriptException).

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 looper turn (a LooperTasks task — the Android analogue of the iOS runloop observer), so a handler attached in the same turn always cancels the report.
  • Unhandled ones report through the existing __onUncaughtError hook, prefixed Unhandled promise rejection: in logcat.
  • Late-attached handlers fire rejectionhandled (as a task, per spec, carrying the original reason; already-reported promises are held weakly so GC'd ones drop out).
  • Worker rejections dispatch unhandledrejection on the worker global first, then flow through the existing worker error channel (worker-global onerror → parent Worker.onerror).

WHATWG error events on globalThis

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

  • Uncaught errors dispatch a cancelable error ErrorEvent; unhandled rejections a cancelable unhandledrejection PromiseRejectionEvent. preventDefault() = fully handled (no __onUncaughtError/__onDiscardedError hook, no crash): passExceptionToJsNative now returns the handled flag, and NativeScriptUncaughtExceptionHandler skips the error activity and the default (crashing) handler when it is set.
  • Native code dispatches through closures captured at init, so events keep firing even if app code overwrites globalThis.dispatchEvent.
  • Back-compat: unprevented errors behave exactly as before — __onUncaughtError/__onDiscardedError keep working, so current core needs zero changes.

interop.escapeException

New interop global (mirroring iOS) with interop.escapeException(x). The escape direction 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: the boundary wrapped it, so a native catch of the concrete type never matched. Now:

try {
    riskyJavaCall(); // throws java.io.IOException
} catch (e) {
    throw interop.escapeException(e);
}
// a Java `catch (IOException e)` above the caller now catches the ORIGINAL
// IOException object - not a com.tns.NativeScriptException wrapper
  • Branded errors carrying a Java throwable are rethrown unwrapped at the boundary, preserving Throwable identity; branding is idempotent, TypeError with no arguments.
  • Branded escapes bypass discardUncaughtJsExceptions (an explicit forward request, matching iOS where branded escapes ignore the discard flag).
  • The JS trace rides along. A new com.tns.JavaScriptStackTrace (never thrown; API kept generic so other exception paths can adopt it) is attached to the escaped original via Throwable.addSuppressed, with the JS frames synthesized as real StackTraceElements — 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 across nested re-escapes. escapeException() also records its own call-site stack (previously lost; for non-Error values it's the only stack there is), exposed with the origin stack via getJavaScriptStack()/getEscapeSiteStack() for crash-SDK integrations.
  • Synthesized escapes (branded plain JS errors) throw com.tns.NativeScriptException as before, but with its stack trace replaced by the synthesized JS frames — so crash reporters group them by where they actually happened in JS instead of bucketing everything under the identical JNI boundary machinery. Unbranded throws keep today's semantics exactly.
  • Not needed from the iOS PR: crashOnUncaughtJsExceptions — Android already crashes by default on truly-uncaught exceptions (discardUncaughtJsExceptions and preventDefault() are the opt-outs).

Containment by default + uncaughtErrorPolicy (the 9.1 model)

Adopts the web's model as the default: an erroring page doesn't crash the browser, and an uncaught JS error no longer crashes the app.

  • An uncaught throw in a native-initiated callback (the OS invoking an override, a posted Runnable, a timer, __runOnMainThread, a frame callback) is contained at the boundary: reported through the pipeline (cancelable error event → __onUncaughtError → logcat) and the native caller resumes with the return type's default value (primitives get 0/false — the dispatch layer substitutes before unboxing, fixing the NPE the old discard path produced). preventDefault() suppresses the report entirely — and since the report now happens synchronously at the boundary, it is fully meaningful on every thread.
  • A JS-initiated chain (JS → Java → JS callback throws) keeps propagating to the outer JS catch with the original JS error object — correct JS semantics, tracked via a per-isolate JS→Java call depth. Containment applies only at an outermost native-initiated boundary.
  • uncaughtErrorPolicy (app package.json, root): "report" (default) or "throw" — the full report (cancelable event → hook → log) runs first at the decision point (preventDefault() fully contains the error even under "throw", matching iOS), then the error is thrown to the native layer as a real Java exception with the JS frames as its stack trace, marked isReportedToJs() so the uncaught-exception path never reports the same failure twice — one event per failure on both platforms. Restores the pre-9.1 behavior (typically a crash; named for the mechanism, not a guaranteed outcome). Unhandled rejections under "throw" are thrown from a clean Java frame on the runtime's looper. error.stackTrace/reason.stackTrace are populated before the events dispatch, matching iOS's pre-dispatch fix.
  • discardUncaughtJsExceptions is deprecated (logcat warning): true keeps its legacy quiet routing (__onDiscardedError) on top of the new default; false — which used to mean "crash" — is ignored rather than repurposed. Branded escapes are now exempt from discard handling.
  • Migration one-liner: the new default ≡ old discardUncaughtJsExceptions: true plus the event layer; uncaughtErrorPolicy: "throw" ≡ the old default with better crash-reporter grouping.

nativeuncaughterror — the native-layer death notification

Native crashes get their own event, completing the invariant that error/unhandledrejection fire only while the failure is still containable (preventDefault() honest, app fully alive). Everything reaching the uncaught-exception handler un-marked — pure-native Java crashes, uncaught escapeException forwards, bootstrap failures — dispatches a cancelable nativeuncaughterror (synchronously, on the crashing thread, entering the isolate under the v8::Locker), with e.error.nativeException carrying the original Throwable. preventDefault() is best-effort (skips the error activity and the killing handler — meaningful for background threads; ignored on iOS where termination is unavoidable). Crashes on threads with no runtime of their own — previously invisible to JS entirely, since getCurrentRuntime()/isInitialized() are thread-local — now fall back to the new Runtime.getMainRuntime(). Already-reported "throw"-policy exceptions short-circuit the handler with zero work. Legacy __onUncaughtError keeps firing for unprevented native crashes (back-compat).

Docs: the full model — events, escapeException, JavaScriptStackTrace, config decision table, crash-reporter integration — is documented in docs/error-handling.md, mirroring the docs on the iOS PR.

Behavior changes (default config): uncaught JS errors in native-initiated callbacks no longer crash the app — they are reported and contained (set uncaughtErrorPolicy: "throw" for the old behavior); a throwing override hands its Java caller the return type's default value, so null-hostile callers may fail downstream (the real error is logged first; interop.escapeException is the out for contracts that need the exception). Unhandled rejections are now reported (previously silent). JS-initiated chains, module/script bootstrap errors, preventDefault() and escapeException semantics are as described above.

⚠️ Release coupling: this PR is 9.1-only and should merge in lockstep with the iOS counterpart (NativeScript/ios#409) so the runtimes never ship divergent defaults.

Related Pull Requests

Does your pull request have unit tests?

Yes — 40 new jasmine specs: testErrorEvents.js (ErrorEvent/PromiseRejectionEvent constructors, EventTarget semantics, reportError, preventDefault() suppressing the __onUncaughtError/__onDiscardedError hooks, dispatch resilience to globalThis.dispatchEvent being overwritten), testUnhandledRejections.js (reporting of Promise.reject/async throws/.then throws, same-turn .catch cancelling the report, unhandledrejection + preventDefault(), rejectionhandled for late handlers), and testEscapeException.js (brand semantics plus JS→Java boundary round-trips through a new EscapeExceptionTest fixture: the native caller catches the original java.io.IOException by identity, the suppressed JavaScriptStackTrace carrier renders frames pointing at the spec file, duplicate carriers are not stacked across nested re-escapes, synthesized escapes carry JS frames, non-Error escapes fall back to the escape-site stack, and unbranded behavior is unchanged). A new testUncaughtErrorPolicy.js suite covers containment (app liveness + event/hook with thrown-value identity), preventDefault() suppression, timer containment, the primitive-default return, and JS-initiated-chain identity; uncaughtErrorPolicy: "throw" is a documented manual smoke (a real crash would kill the runner). Full suite green on an API 35 arm64 emulator — every pre-existing suite passes unchanged under the new default thanks to the JS-initiated-chain rule.

Summary by CodeRabbit

  • New Features
    • Added WHATWG-style global error events (error, unhandledrejection, rejectionhandled) with correct dispatch, cancellation, and listener options behavior.
    • Added interop.escapeException for JS→Java exception escaping with preserved identity and stack context.
    • Added uncaughtErrorPolicy app configuration ("report"/"throw").
  • Bug Fixes
    • Native uncaught/promise-rejection handling now respects JavaScript preventDefault() consumption and delivers rejectionhandled reliably.
  • Documentation
    • Documented the global error model, interop.escapeException, and policy behavior in docs/error-handling.md.
  • Tests
    • Added/expanded test suites covering error-event semantics, unhandled rejections, escape behavior, and uncaught-error policy.

…r events)

Ports the error model from NativeScript/ios#409 to the Android runtime.

Unhandled promise rejection tracking:
- Register SetPromiseRejectCallback (previously rejections vanished with
  no log, no callback). Rejections are tracked per-isolate and drained
  once per looper turn via a LooperTasks task, so a handler attached in
  the same turn always cancels the report.
- Unhandled ones report through the existing __onUncaughtError hook with
  an always-on "Unhandled promise rejection:" logcat entry; late-attached
  handlers fire `rejectionhandled` (as a task, per spec, carrying the
  original reason; already-reported promises are held weakly).
- Worker isolates dispatch `unhandledrejection` on the worker global
  first, then fall through to the existing worker onerror channel.

WHATWG error events on globalThis:
- Events.{h,cpp}: generic Event/EventTarget constructors, the
  addEventListener/removeEventListener/dispatchEvent mixin, and the
  internal backing target (stashed natively for future consumers).
- ErrorEvents.{h,cpp}: ErrorEvent/PromiseRejectionEvent, reportError()
  and the native dispatch closures, layered on the generic primitives
  via a one-shot _installListenerErrorReporter handoff. Dispatch
  closures are captured at init so events keep firing even if app code
  overwrites globalThis.dispatchEvent.
- NativeScriptException keeps the reporting machinery and the rejection
  tracker, mirroring the iOS file layout.

preventDefault() semantics:
- Uncaught errors dispatch a cancelable `error` ErrorEvent before the
  __onUncaughtError/__onDiscardedError hooks; preventDefault() fully
  handles the report. passExceptionToJsNative now returns that handled
  flag and NativeScriptUncaughtExceptionHandler skips the error activity
  and the default (crashing) handler when set.
- Back-compat: unprevented errors behave exactly as before.

Not ported (already native to Android): interop.escapeException - a JS
throw in a method override already surfaces as a real
com.tns.NativeScriptException to the Java caller, and caught Java
exceptions already carry error.nativeException. crashOnUncaughtJsExceptions
is likewise unnecessary - Android crashes by default on truly-uncaught
exceptions.

Testing: 30 new jasmine specs (WHATWG events/EventTarget semantics,
reportError, unhandled rejections incl. rejectionhandled); full suite
green on an API 35 arm64 emulator with no changes to existing suites.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The runtime adds WHATWG-style event primitives, native error-event dispatch, unhandled promise rejection tracking, configurable uncaught-error handling, Java exception escaping with JavaScript stack preservation, and corresponding tests and documentation.

Changes

Runtime Error Handling

Layer / File(s) Summary
Event primitives
test-app/runtime/src/main/cpp/Events.*, test-app/runtime/src/main/cpp/Runtime.*
Bootstraps Event and EventTarget, listener behavior, cancellation, propagation control, and global event-target storage.
Error and rejection dispatch
test-app/runtime/src/main/cpp/ErrorEvents.*, test-app/runtime/src/main/cpp/NativeScriptException.*, test-app/runtime/src/main/java/com/tns/*.java
Routes uncaught errors and promise rejections through cancelable events, rejection tracking, legacy hooks, policy handling, and JNI return values.
Java exception escaping
test-app/runtime/src/main/cpp/Interop.*, test-app/runtime/src/main/java/com/tns/JavaScriptStackTrace.java, test-app/runtime/src/main/cpp/NativeScriptException.cpp
Brands escaped exceptions, restores original Java throwables when available, and preserves JavaScript stack data on Java exceptions.
Policy, validation, and documentation
test-app/runtime/src/main/java/com/tns/AppConfig.java, test-app/runtime/src/main/java/com/tns/Runtime.java, test-app/app/src/main/assets/app/tests/*, docs/error-handling.md, README.md
Adds uncaught-error policy configuration, validates event, rejection, escape, and containment behavior, registers the suites, and documents the runtime contracts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Java
  participant Runtime
  participant ErrorEvents
  participant EventTarget
  Java->>Runtime: pass uncaught exception
  Runtime->>ErrorEvents: dispatch error event
  ErrorEvents->>EventTarget: notify error listeners
  EventTarget-->>ErrorEvents: return preventDefault result
  ErrorEvents-->>Runtime: return handled status
  Runtime-->>Java: return handling result
Loading

Possibly related PRs

Poem

A rabbit hops through events so neat,
With errors dancing on tiny feet.
Promises pause, then tell their tale,
Handlers hush the crashing gale.
Escaped exceptions leave no scar—
“Hop-hop!” says Bunny, “back they are!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.93% 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 is concise and accurately summarizes the main change: web-compliant error handling.
✨ 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.

🧹 Nitpick comments (1)
test-app/app/src/main/assets/app/tests/testErrorEvents.js (1)

38-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared onGlobal/afterQuietTurns/pollUntil test helpers.

Both new spec files reimplement the same global-listener bookkeeping and quiet-turn waiting logic, with afterQuietTurns using different (25ms vs 20ms) delays in each file. Moving these into a shared test-utility module would remove duplication and prevent timing drift between suites.

  • test-app/app/src/main/assets/app/tests/testErrorEvents.js#L38-L48: extract onGlobal and afterQuietTurns into a shared helper module imported by both spec files.
  • test-app/app/src/main/assets/app/tests/testUnhandledRejections.js#L30-L58: replace the local onGlobal/afterQuietTurns (and optionally pollUntil) with the shared helper module, reconciling the differing timing constants.
🤖 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 `@test-app/app/src/main/assets/app/tests/testErrorEvents.js` around lines 38 -
48, Extract the duplicated onGlobal and afterQuietTurns helpers into a shared
test utility module, using one reconciled quiet-turn delay for both suites. In
test-app/app/src/main/assets/app/tests/testErrorEvents.js:38-48, replace the
local implementations with imports from that module; in
test-app/app/src/main/assets/app/tests/testUnhandledRejections.js:30-58,
likewise remove the local onGlobal/afterQuietTurns implementations and
optionally reuse pollUntil through the shared module. Update both suites to use
the shared helper consistently.
🤖 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 `@test-app/app/src/main/assets/app/tests/testErrorEvents.js`:
- Around line 38-48: Extract the duplicated onGlobal and afterQuietTurns helpers
into a shared test utility module, using one reconciled quiet-turn delay for
both suites. In test-app/app/src/main/assets/app/tests/testErrorEvents.js:38-48,
replace the local implementations with imports from that module; in
test-app/app/src/main/assets/app/tests/testUnhandledRejections.js:30-58,
likewise remove the local onGlobal/afterQuietTurns implementations and
optionally reuse pollUntil through the shared module. Update both suites to use
the shared helper consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 51a14569-4f23-4a62-8ac5-f6f380f301bd

📥 Commits

Reviewing files that changed from the base of the PR and between e6d0d3d and 2044ebf.

📒 Files selected for processing (15)
  • test-app/app/src/main/assets/app/mainpage.js
  • test-app/app/src/main/assets/app/tests/testErrorEvents.js
  • test-app/app/src/main/assets/app/tests/testUnhandledRejections.js
  • test-app/app/src/main/java/com/tns/NativeScriptUncaughtExceptionHandler.java
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/ErrorEvents.cpp
  • test-app/runtime/src/main/cpp/ErrorEvents.h
  • test-app/runtime/src/main/cpp/Events.cpp
  • test-app/runtime/src/main/cpp/Events.h
  • test-app/runtime/src/main/cpp/NativeScriptException.cpp
  • test-app/runtime/src/main/cpp/NativeScriptException.h
  • test-app/runtime/src/main/cpp/Runtime.cpp
  • test-app/runtime/src/main/cpp/Runtime.h
  • test-app/runtime/src/main/cpp/com_tns_Runtime.cpp
  • test-app/runtime/src/main/java/com/tns/Runtime.java

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test-app/runtime/src/main/cpp/NativeScriptException.cpp (1)

472-560: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

draining_ can get stuck true forever if a non-NativeScriptException escapes Drain().

Both loops only catch NativeScriptException&, and draining_ = false (line 559) plus PruneReportedOutstanding() (line 557) only run if nothing throws past the catch blocks. If ErrorEvents::DispatchRejectionHandled/DispatchUnhandledRejection, GetStackTraceOfValue, ToDetailString, GiveWorkerOnErrorAChance, PassUncaughtExceptionFromWorkerToParent, or PruneReportedOutstanding() throws anything other than NativeScriptException (e.g. a JNI/std exception), it propagates out of Drain() uncaught, leaving draining_ permanently true. Since Drain() early-returns whenever draining_ is true (line 474-476), this silently and permanently disables all future unhandledrejection/rejectionhandled dispatch for the isolate.

Use an RAII guard so draining_ is always reset regardless of how the function exits, and widen the catches so no exception type can escape this loop:

🛡️ Proposed fix
 void PromiseRejectionTracker::Drain() {
   drainScheduled_ = false;
   if (draining_) {
     return;
   }
   draining_ = true;
+  struct DrainGuard {
+    bool& flag;
+    ~DrainGuard() { flag = false; }
+  } drainGuard{draining_};
 
   auto isolate = runtime_->GetIsolate();
   auto context = isolate->GetCurrentContext();
   ...
     } catch (NativeScriptException& ex) {
       DEBUG_WRITE_FORCE(
           "PromiseRejectionTracker: exception while firing rejectionhandled: %s",
           ex.GetErrorMessage().c_str());
+    } catch (...) {
+      DEBUG_WRITE_FORCE(
+          "PromiseRejectionTracker: unknown exception while firing rejectionhandled");
     }
   ...
     } catch (NativeScriptException& ex) {
       DEBUG_WRITE_FORCE(
           "PromiseRejectionTracker: exception while reporting rejection: %s",
           ex.GetErrorMessage().c_str());
+    } catch (...) {
+      DEBUG_WRITE_FORCE(
+          "PromiseRejectionTracker: unknown exception while reporting rejection");
     }
   }
 
   PruneReportedOutstanding();
-
-  draining_ = false;
 }
🤖 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 `@test-app/runtime/src/main/cpp/NativeScriptException.cpp` around lines 472 -
560, Update PromiseRejectionTracker::Drain to use an RAII guard that resets
draining_ on every exit, including exceptions from PruneReportedOutstanding.
Widen both loop-level catches, including the rejectionhandled dispatch and
unhandled rejection reporting paths, so non-NativeScriptException failures
cannot escape Drain; preserve the existing diagnostic logging and dispatch
behavior.
🧹 Nitpick comments (1)
test-app/runtime/src/main/cpp/Interop.cpp (1)

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

Reuse NativeScriptException::THROWABLE_CLASS for the throwable IsAssignableFrom check.

GetWrappedJavaThrowable and Interop::ExtractEscapedJavaException both call env.FindClass("java/lang/Throwable"), while TryGetJavaThrowableObject already uses the cached NativeScriptException::THROWABLE_CLASS for the same check. Reusing the cached reference avoids duplicate JNI lookups and keeps a single throwable class reference in use.

Also applies to: 179-216

🤖 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 `@test-app/runtime/src/main/cpp/Interop.cpp` around lines 34 - 65, Update
GetWrappedJavaThrowable and Interop::ExtractEscapedJavaException to use the
cached NativeScriptException::THROWABLE_CLASS reference for their
IsAssignableFrom checks instead of calling env.FindClass("java/lang/Throwable").
Preserve the existing throwable detection behavior and apply the same reuse
consistently in both methods.
🤖 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.

Outside diff comments:
In `@test-app/runtime/src/main/cpp/NativeScriptException.cpp`:
- Around line 472-560: Update PromiseRejectionTracker::Drain to use an RAII
guard that resets draining_ on every exit, including exceptions from
PruneReportedOutstanding. Widen both loop-level catches, including the
rejectionhandled dispatch and unhandled rejection reporting paths, so
non-NativeScriptException failures cannot escape Drain; preserve the existing
diagnostic logging and dispatch behavior.

---

Nitpick comments:
In `@test-app/runtime/src/main/cpp/Interop.cpp`:
- Around line 34-65: Update GetWrappedJavaThrowable and
Interop::ExtractEscapedJavaException to use the cached
NativeScriptException::THROWABLE_CLASS reference for their IsAssignableFrom
checks instead of calling env.FindClass("java/lang/Throwable"). Preserve the
existing throwable detection behavior and apply the same reuse consistently in
both methods.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 106e6980-5592-4b35-92f3-1a57c6bc9883

📥 Commits

Reviewing files that changed from the base of the PR and between 2044ebf and 302691c.

📒 Files selected for processing (8)
  • test-app/app/src/main/assets/app/mainpage.js
  • test-app/app/src/main/assets/app/tests/testEscapeException.js
  • test-app/app/src/main/java/com/tns/tests/EscapeExceptionTest.java
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/Interop.cpp
  • test-app/runtime/src/main/cpp/Interop.h
  • test-app/runtime/src/main/cpp/NativeScriptException.cpp
  • test-app/runtime/src/main/cpp/Runtime.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/Runtime.cpp

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

@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 `@docs/error-handling.md`:
- Line 43: Update the cancelable `error` and `unhandledrejection` documentation
to state that `preventDefault()` suppresses downstream hooks, error activity,
and process crashes, but does not necessarily suppress native pre-dispatch
logging such as `System.err` or logger output.
- Around line 141-143: Update the Java example around the suppressed-exception
loop to declare and initialize caught before calling caught.getSuppressed(), or
move the loop into the preceding catch block where caught is already in scope,
ensuring the copied example compiles.
- Around line 129-136: Update the fenced code block containing the Java
stack-trace example to declare the text language identifier, changing the
opening fence to use text while preserving the stack-trace content unchanged.
🪄 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: 3945e59d-d2cd-4ec0-b4d8-e907a05f318c

📥 Commits

Reviewing files that changed from the base of the PR and between f2ebdf5 and a189cb4.

📒 Files selected for processing (3)
  • README.md
  • docs/README.md
  • docs/error-handling.md

Comment thread docs/error-handling.md
Comment thread docs/error-handling.md
Comment thread docs/error-handling.md
Comment on lines +141 to +143
for (Throwable suppressed : caught.getSuppressed()) {
if (suppressed instanceof com.tns.JavaScriptStackTrace) {
com.tns.JavaScriptStackTrace jsTrace = (com.tns.JavaScriptStackTrace) suppressed;

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

Define caught before using it.

The Java example calls caught.getSuppressed() without declaring caught, so copied code does not compile. Use a declared exception variable or place the loop inside the preceding catch block.

🤖 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 141 - 143, Update the Java example
around the suppressed-exception loop to declare and initialize caught before
calling caught.getSuppressed(), or move the loop into the preceding catch block
where caught is already in scope, ensuring the copied example compiles.

Adopts the web's model as the default: an erroring page doesn't crash
the browser, and an uncaught JS error no longer crashes the app. This is
the Android half of the unified NativeScript 9.1 error-handling spec.

Containment:
- An uncaught throw in a NATIVE-initiated callback (the OS invoking an
  overridden method or interface implementation, a posted Runnable, a
  timer, __runOnMainThread, a frame callback) is contained at the
  boundary: reported through the WHATWG pipeline (cancelable `error`
  event -> __onUncaughtError -> logcat) and the native caller resumes
  with the return type's default value. preventDefault() suppresses the
  report entirely.
- A JS-INITIATED chain (JS -> Java -> JS callback throws) keeps
  propagating to the outer JS catch with the original JS error object -
  correct JavaScript semantics, tracked via a per-isolate JS->Java call
  depth around CallJavaMethod/RegisterInstance. Containment applies only
  at an outermost native-initiated boundary.
- Contained primitive-returning overrides yield the type's default
  (dispatchCallJSMethodNative substitutes it before unboxing), fixing
  the NPE the old discard path produced.
- Branded interop.escapeException throws are never contained; module/
  script evaluation (bootstrap) is not contained.

uncaughtErrorPolicy (app package.json, root level):
- "report" (default): report and continue, never crash.
- "throw": unprevented errors are thrown to the native layer as real
  Java exceptions - com.tns.NativeScriptException with the JS frames as
  its stack trace - restoring the pre-9.1 behavior (typically a crash,
  via the thread's uncaught-exception path, which performs the single
  report). Unhandled rejections are thrown from a clean Java frame
  posted to the runtime's looper. Named for the mechanism, not a
  guaranteed crash: native catch-alls above the boundary can still
  intercept.

discardUncaughtJsExceptions is deprecated (logcat warning): `true` keeps
its legacy quiet routing (__onDiscardedError, no log) on top of the new
default; `false` - which used to mean "crash" - is ignored. Branded
escapes are now exempt from the discard handling (escapedFromJs marker).

Testing: 5 new specs (containment + app liveness with event/hook
identity, preventDefault suppression, timer containment, primitive
default instead of NPE, JS-initiated chain identity) plus the
direct-throw escape specs; "throw" policy is a documented manual smoke.
Full suite green on an API 35 arm64 emulator - every pre-existing suite
passes unchanged thanks to the JS-initiated-chain rule.
… parity)

Parity round with NativeScript/ios#409's claim-slot commit, closing the
review's cross-platform divergences:

- Single report per failure under uncaughtErrorPolicy: "throw": the full
  report (cancelable event -> hook -> log) now runs at the decision
  point BEFORE the native throw, for both the sync boundary and the
  rejection drain, and the thrown com.tns.NativeScriptException is
  marked reportedToJs (isolate-private brand carried to a Java field by
  ReThrowToJava; set directly for the rejection looper throw) so
  NativeScriptUncaughtExceptionHandler skips the post-mortem
  re-report. Previously a "throw"-policy rejection fired
  unhandledrejection AND a second post-mortem error event. The public
  NativeScriptException.isReportedToJs() lets custom
  Thread.UncaughtExceptionHandler implementations honor the same
  contract.
- preventDefault() now beats the "throw" policy at the sync boundary
  too: the event dispatches before the propagation decision, so a
  prevented error is fully contained with the app alive (iOS behavior).
  The deprecated discard flag disables the throw, matching iOS.
- error.stackTrace / reason.stackTrace (the combined legacy stack, a
  NativeScript extension) are populated BEFORE the error /
  unhandledrejection events dispatch - including the worker branch of
  the rejection drain - so listeners see the same shape the hooks do;
  pinned by new spec assertions mirroring the iOS ones.
- Docs: unified "throw" sequencing and the cross-platform catchability
  note (Android sync at the method boundary; iOS sync only at
  own-frame-reporting boundaries, deferred for block/method callbacks
  and loop-originated errors; escapeException is the portable per-call
  interception tool), plus the Android-only native-crash event
  asymmetry and the interop feature-detection note.

Full suite green on an API 35 arm64 emulator: 604 specs, 0 failures.
Separates native-layer deaths from the WHATWG `error` event, completing
the invariant that error/unhandledrejection only fire while the failure
is still containable (the app fully alive, preventDefault() honest).

- New cancelable `nativeuncaughterror` ErrorEvent, dispatched
  synchronously from the uncaught-exception handler for everything that
  reaches it un-marked: pure-native Java crashes, uncaught
  interop.escapeException forwards, bootstrap failures. The handler
  classifies nothing - the simple rule IS the spec. e.error carries the
  wrapped original Throwable via nativeException. preventDefault() is
  best-effort: it skips the error activity and the default (killing)
  handler - meaningful for background-thread crashes (this is what
  keeps the new spec's test runner alive). The legacy __onUncaughtError
  hook still fires for unprevented native crashes (deprecated
  back-compat - it is what Application.uncaughtErrorEvent has received
  for years). This also removes the blanket-preventDefault footgun: a
  generic `error` listener can no longer accidentally spare a process
  whose crashed thread is already gone.

- Main-runtime fallback: crashes on threads with no runtime of their
  own (plugin/executor threads) previously got NO JS reporting at all -
  Runtime.isInitialized() and getCurrentRuntime() are thread-local, so
  the handler silently skipped them. The handler now resolves current
  -> main (new Runtime.getMainRuntime(), set when the workerId-0
  runtime initializes) and enters the main isolate cross-thread (the
  JNI layer takes the v8::Locker).

- Already-reported fast path: an uncaughtErrorPolicy "throw" exception
  (isReportedToJs) now short-circuits the handler before any work -
  no JS roundtrip and no eager stack rendering; the message strings are
  built lazily, only when the logger, debug print or error activity
  actually consumes them.

Testing: new spec crashes a fresh runtime-less Java thread and asserts
nativeuncaughterror arrives via the main-runtime fallback carrying the
original Throwable, `error` never fires (invariant pin), the prevented
report skips the legacy hook, and the process survives. Full suite
green on an API 35 arm64 emulator: 605 specs, 0 failures.
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