Skip to content

Commit 029b155

Browse files
committed
feat!: contain uncaught JS errors by default (uncaughtErrorPolicy)
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.
1 parent a189cb4 commit 029b155

15 files changed

Lines changed: 630 additions & 40 deletions

docs/error-handling.md

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
# Error handling
22

3-
The runtime implements the WHATWG error model at the global level: uncaught JavaScript exceptions and unhandled promise rejections are dispatched as cancelable events on `globalThis`, Java exceptions round-trip into JavaScript with the original `Throwable` attached, and `interop.escapeException` forwards a JavaScript throw to the Java caller as the **original** Java exception. Unlike iOS, a truly-uncaught exception crashes the app by default (the Java default uncaught-exception handler ends the process) — `preventDefault()` and `discardUncaughtJsExceptions` are the opt-outs. Unhandled rejections and `reportError` only report; they never crash.
3+
The runtime implements the WHATWG error model at the global level: uncaught JavaScript exceptions and unhandled promise rejections are dispatched as cancelable events on `globalThis`, Java exceptions round-trip into JavaScript with the original `Throwable` attached, and `interop.escapeException` forwards a JavaScript throw to the Java caller as the **original** Java exception. Following the web's model — an erroring page doesn't crash the browser — **an uncaught error never crashes the app by default**: it is reported (event → hook → log) and execution continues. Crashing is opt-in via `uncaughtErrorPolicy: "throw"`; suppressing a report entirely is per-error via `preventDefault()`.
44

55
## Quick reference
66

77
| Situation | Default behavior |
88
|---|---|
9-
| Uncaught JS exception during a Java→JS call (overridden method, interface implementation) | Becomes a real `com.tns.NativeScriptException` thrown to the Java caller. If nothing catches it, the thread's uncaught-exception handler reports it (cancelable `error` event → `__onUncaughtError` hook → error activity in debug builds) and the process exits — unless a listener called `preventDefault()`. |
9+
| Uncaught JS exception in a **native-initiated** callback (the OS invoking an overridden method or interface implementation, a posted `Runnable`, a timer, `__runOnMainThread`, a frame callback) | **Contained at the boundary**: reported (cancelable `error` event → `__onUncaughtError` hook → logcat), the Java caller receives the default value for the return type, and the app keeps running. |
10+
| Uncaught JS exception in a **JS-initiated** chain (JS → Java → JS callback throws) | **Propagates back to the outer JS `catch`** as the very same JS error object — correct JavaScript semantics; each JS frame on the way gets its chance to catch. Only if it reaches an outermost native-initiated boundary is it contained. |
1011
| Unhandled promise rejection | Tracked per isolate, reported once per looper turn: cancelable `unhandledrejection` event → `__onUncaughtError` hook, logcat entry prefixed `Unhandled promise rejection:`. The app keeps running. |
1112
| `.catch()` added after the report | `rejectionhandled` event (non-cancelable), carrying the original reason. |
1213
| Java exception during a JS→Java call | Surfaced to JS as an `Error` carrying the original as `error.nativeException`. |
13-
| `throw interop.escapeException(x)` in JS called from Java | The original Java `Throwable` carried by `x` is rethrown **unwrapped** to the Java caller (JS trace attached as a suppressed `com.tns.JavaScriptStackTrace`); with no underlying `Throwable`, a `com.tns.NativeScriptException` whose stack trace is the JS frames. |
14+
| `throw interop.escapeException(x)` in JS called from Java | Never contained. The original Java `Throwable` carried by `x` is rethrown **unwrapped** to the Java caller (JS trace attached as a suppressed `com.tns.JavaScriptStackTrace`); with no underlying `Throwable`, a `com.tns.NativeScriptException` whose stack trace is the JS frames. |
1415
| `reportError(x)` | Routed through the same pipeline as an uncaught error; never crashes. |
16+
| `uncaughtErrorPolicy: "throw"` | Restores the pre-9.1 behavior: unprevented uncaught errors are thrown to the native layer as real Java exceptions (which typically ends the process via the default uncaught-exception handler). |
1517

1618
## JavaScript API
1719

@@ -69,8 +71,9 @@ The stacks live on the error/reason **value**, not on the event — and the thro
6971

7072
| You wrote | `e.error` / `e.reason` is | JS stack | Native exception |
7173
|---|---|---|---|
72-
| `throw new Error("x")` | that `Error` | `e.error.stack` ||
74+
| `throw new Error("x")` | that `Error` (the actual thrown value) | `e.error.stack` ||
7375
| called a Java method that threw, without try/catch | an `Error` with `message` from the Java exception's message | `e.error.stack` (the JS call site); `e.error.stackTrace` combines it with the Java frames | `e.error.nativeException` — the original `Throwable` (call `.getClass()`, `.getMessage()`, `.getCause()`, ... on it) |
76+
| `throw new java.io.IOException("x")` — a directly-thrown wrapped `Throwable` | the wrapped `Throwable` itself — not an `Error`, no `.stack` || `e.error` directly (`instanceof java.io.IOException`) |
7477

7578
### Catching native exceptions
7679

@@ -103,7 +106,17 @@ const listener = new some.api.Listener({
103106
Semantics:
104107

105108
- `escapeException(err)` returns a JS `Error` (message/stack copied), so it behaves like a normal throw in pure-JS paths; the brand is an isolate-private symbol that user code cannot forge. Passing an already-branded value is a no-op; calling with no argument throws `TypeError`.
106-
- If `err` is (or carries via `.nativeException`) a Java `Throwable`, the **original object** is rethrown at the boundary — a Java `catch (IOException e)` above the caller matches, and `Throwable` identity is preserved (same object, untouched class/stack/cause chain). The JS journey rides along as a suppressed `com.tns.JavaScriptStackTrace` (see the native section).
109+
- If `err` is (or carries via `.nativeException`) a Java `Throwable`, the **original object** is rethrown at the boundary — a Java `catch (IOException e)` above the caller matches, and `Throwable` identity is preserved (same object, untouched class/stack/cause chain). The JS journey rides along as a suppressed `com.tns.JavaScriptStackTrace` (see the native section). This includes directly-constructed exceptions:
110+
111+
```js
112+
// The caller catches THIS exact IOException - no wrapper. Without the brand,
113+
// a directly-thrown wrapped Throwable behaves like any other uncaught throw:
114+
// contained (reported, caller resumes) in a native-initiated callback, or -
115+
// in a JS-initiated chain - propagated to the outer JS catch (and, if it
116+
// reaches Java code with no JS below, wrapped in a com.tns.NativeScriptException
117+
// with the IOException as its cause).
118+
throw interop.escapeException(new java.io.IOException("x"));
119+
```
107120
- Otherwise a `com.tns.NativeScriptException` is thrown as usual, but with its stack trace replaced by frames synthesized from the JS stack, so crash reporters group it by where it actually happened in JS.
108121
- The `escapeException()` call site's stack is recorded too — for non-Error values (`escapeException("boom")`) it is the only stack available.
109122
- Branded escapes bypass `discardUncaughtJsExceptions` (an explicit forward request must reach the caller).
@@ -156,20 +169,29 @@ for (Throwable suppressed : caught.getSuppressed()) {
156169

157170
## Configuration
158171

159-
| Flag (app `package.json`, default off) | Effect |
172+
One policy key in the app's `package.json` (root level) governs what happens to an **unprevented** uncaught error or unhandled rejection:
173+
174+
| `uncaughtErrorPolicy` | Effect |
160175
|---|---|
161-
| `discardUncaughtJsExceptions` | JS exceptions escaping an overridden-method call are swallowed on the Java side and reported through `__onDiscardedError` instead of crashing the app. Branded `interop.escapeException` throws bypass it. |
176+
| `"report"` (default) | Report (event → hook → log) and continue. Never crashes. |
177+
| `"throw"` | After the (cancelable) event, the error is thrown to the native layer as a real Java exception — `com.tns.NativeScriptException` with the JS frames as its stack trace — and reported through the thread's uncaught-exception path. This typically ends the process (the pre-9.1 default), though a native catch or a custom `UncaughtExceptionHandler` above the boundary can still intercept it, which is why the policy names the mechanism, not a guaranteed crash. Unhandled rejections are thrown from a clean frame on the runtime's looper. |
178+
179+
Deprecated (kept for the transition, both emit a logcat warning):
162180

163-
There is no `crashOnUncaughtJsExceptions` flag (unlike iOS): crashing on truly-uncaught exceptions is already Android's default.
181+
| Flag | Behavior |
182+
|---|---|
183+
| `discardUncaughtJsExceptions: true` | Legacy quiet routing: contained reports call `__onDiscardedError` instead of `__onUncaughtError` and skip the logcat report. Branded `interop.escapeException` throws bypass it. |
184+
| `discardUncaughtJsExceptions: false` | Ignored (this used to mean "crash on uncaught errors" — set `uncaughtErrorPolicy: "throw"` for that). |
164185

165186
Terminal-path decision table:
166187

167188
| Condition | legacy hook called | process crash |
168189
|---|---|---|
169-
| uncaught exception, default | `__onUncaughtError` | yes |
170-
| uncaught exception, `discardUncaughtJsExceptions` | `__onDiscardedError` | no |
171-
| uncaught exception, listener called `preventDefault()` | none | no |
172-
| unhandled rejection / `reportError`, unprevented | `__onUncaughtError` | no |
190+
| uncaught error, default (`"report"`) | `__onUncaughtError` | no |
191+
| uncaught error, `"report"` + `discardUncaughtJsExceptions: true` | `__onDiscardedError` | no |
192+
| uncaught error, listener called `preventDefault()` | none | no |
193+
| uncaught error / unhandled rejection, `"throw"`, unprevented | `__onUncaughtError` (via the uncaught-exception path) | yes, normally |
194+
| unhandled rejection / `reportError`, `"report"`, unprevented | `__onUncaughtError` | no |
173195
| unhandled rejection / `reportError`, `preventDefault()` | none | no |
174196

175197
## Crash reporter integration
@@ -188,15 +210,17 @@ globalThis.addEventListener("error", (e) => {
188210
});
189211
```
190212

191-
Java side — for exceptions that never pass through the JS event layer (escaped originals crashing a thread), walk `getSuppressed()` for `com.tns.JavaScriptStackTrace` to attach the JS frames. For embedders with a custom `Thread.UncaughtExceptionHandler`: `Runtime.passUncaughtExceptionToJs(...)` returns `true` when a listener called `preventDefault()` — honor it by not killing the process (see `NativeScriptUncaughtExceptionHandler`).
213+
Java side — for exceptions that never pass through the JS event layer (escaped originals crashing a thread, `"throw"`-policy fatals), walk `getSuppressed()` for `com.tns.JavaScriptStackTrace` to attach the JS frames. For embedders with a custom `Thread.UncaughtExceptionHandler`: `Runtime.passUncaughtExceptionToJs(...)` returns `true` when a listener called `preventDefault()` — honor it by not killing the process (see `NativeScriptUncaughtExceptionHandler`).
192214

193215
## Legacy hooks (deprecated)
194216

195217
`global.__onUncaughtError` and `global.__onDiscardedError` keep working exactly as before and are what `@nativescript/core` currently installs (surfaced as `Application.uncaughtErrorEvent` / `discardedErrorEvent`). They are invoked only when no event listener called `preventDefault()`. New code should prefer `globalThis.addEventListener("error" | "unhandledrejection", ...)`.
196218

197219
## Behavior details
198220

199-
- Every error is reported exactly once: either the JS→Java boundary (synchronous throws during Java-invoked JS), the rejection drain (once per looper turn, scheduled on the runtime's `ALooper`), or `reportError` — never two of them for the same error.
221+
- **Containment is boundary-outermost.** The runtime tracks the depth of in-flight JS→Java calls; a throw with JS frames waiting below the boundary propagates (so `try { javaApi.call(cb) } catch` works, with the original JS error object restored across the crossing), and is contained only at an outermost native-initiated entry. `interop.escapeException` and `uncaughtErrorPolicy: "throw"` are the two ways an error crosses that outermost boundary.
222+
- **Contained callbacks return type defaults.** A throwing overridden method hands its Java caller `null` for reference types and `0`/`false` for primitives (the runtime substitutes the default before unboxing, so no `NullPointerException` from the binding). The error is loudly reported *before* the caller resumes, so logcat shows the real failure ahead of any downstream symptom. If the Java contract genuinely needs the exception, use `interop.escapeException`.
223+
- Every error is reported exactly once: either the containment point, the rejection drain (once per looper turn, scheduled on the runtime's `ALooper`), `reportError`, or — under `"throw"` — the thread's uncaught-exception path. Never two of them for the same error.
200224
- A rejection that gets a handler before the end-of-turn drain is never reported (and produces no `rejectionhandled` either).
201-
- The `error` event for uncaught exceptions fires when the exception is reported to JS (from the uncaught-exception handler via `passUncaughtExceptionToJs`, or the discard path) — after the Java stack has already unwound. `preventDefault()` prevents the crash, but on the main thread the app's looper has exited by then; for background and JS-only threads the process genuinely keeps running.
202-
- Worker isolates run the same machinery: each worker has its own tracker, drain, and event layer.
225+
- Module/script evaluation (`runModule`/`runScript`, app bootstrap) is not contained — an app whose main module fails to load still fails loudly.
226+
- Worker isolates run the same machinery: each worker has its own tracker, drain, event layer and containment.

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ require('./tests/testQueueMicrotask');
7777
require('./tests/testErrorEvents');
7878
require('./tests/testUnhandledRejections');
7979
require('./tests/testEscapeException');
80+
require('./tests/testUncaughtErrorPolicy');
8081
require("./tests/testConcurrentAccess");
8182

8283
require("./tests/testESModules.mjs");

test-app/app/src/main/assets/app/tests/testEscapeException.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,53 @@ describe("interop.escapeException", function () {
132132
expect(second.getSuppressed().length).toBe(1);
133133
});
134134

135+
it("escapes a directly-constructed Java exception (not wrapped in an Error)", function () {
136+
var original = new java.io.IOException("direct-io");
137+
var runnable = new java.lang.Runnable({
138+
run: function () {
139+
// The escaped value IS the wrapped Throwable - there is no JS
140+
// Error and no .nativeException property involved.
141+
throw interop.escapeException(original);
142+
}
143+
});
144+
var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable);
145+
146+
expect(ret).not.toBeNull();
147+
expect(ret.getClass().getName()).toBe("java.io.IOException");
148+
expect(ret.getMessage()).toBe("direct-io");
149+
expect(ret.equals(original)).toBe(true);
150+
151+
// A wrapped Throwable has no JS stack of its own, so the carrier
152+
// renders the escape site.
153+
var suppressed = ret.getSuppressed();
154+
expect(suppressed.length).toBe(1);
155+
expect(suppressed[0].getClass().getName()).toBe("com.tns.JavaScriptStackTrace");
156+
var frames = suppressed[0].getStackTrace();
157+
var sawThisFile = false;
158+
for (var i = 0; i < frames.length; i++) {
159+
var file = frames[i].getFileName();
160+
if (file && file.indexOf("testEscapeException.js") !== -1) {
161+
sawThisFile = true;
162+
break;
163+
}
164+
}
165+
expect(sawThisFile).toBe(true);
166+
});
167+
168+
it("an unbranded directly-thrown Java exception surfaces wrapped, original as cause", function () {
169+
var original = new java.io.IOException("direct-unbranded");
170+
var runnable = new java.lang.Runnable({
171+
run: function () {
172+
throw original;
173+
}
174+
});
175+
var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable);
176+
177+
expect(ret).not.toBeNull();
178+
expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException");
179+
expect(ret.getCause().equals(original)).toBe(true);
180+
});
181+
135182
it("an unbranded rethrow keeps today's wrapping semantics", function () {
136183
var caught = null;
137184
try {

0 commit comments

Comments
 (0)