Skip to content

Commit a189cb4

Browse files
committed
docs: add error handling documentation
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).
1 parent f2ebdf5 commit a189cb4

3 files changed

Lines changed: 211 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Contains the source code for the NativeScript's Android Runtime. [NativeScript](
1111
- [Build Prerequisites](#build-prerequisites)
1212
- [How to build](#how-to-build)
1313
- [How to run tests](#how-to-run-tests)
14+
- [Documentation](#documentation)
1415
- [Misc](#misc)
1516
- [Get Help](#get-help)
1617

@@ -124,6 +125,10 @@ npx ns debug android --start
124125
We love PRs! Check out the [contributing guidelines](CONTRIBUTING.md). If you want to contribute, but you are not sure where to start - look for [issues labeled `help wanted`](https://github.com/NativeScript/android-runtime/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22).
125126

126127

128+
## Documentation
129+
130+
Runtime feature documentation lives in the [docs](docs/README.md) folder — see [Error handling](docs/error-handling.md) for the global error events, Java exception round-tripping and `interop.escapeException`.
131+
127132
## Misc
128133

129134
* [Implementing additional Chrome DevTools protocol Domains](docs/extending-inspector.md)

docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Runtime documentation
2+
3+
- [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration.
4+
- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md)

docs/error-handling.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Error handling
2+
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.
4+
5+
## Quick reference
6+
7+
| Situation | Default behavior |
8+
|---|---|
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()`. |
10+
| 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. |
11+
| `.catch()` added after the report | `rejectionhandled` event (non-cancelable), carrying the original reason. |
12+
| 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+
| `reportError(x)` | Routed through the same pipeline as an uncaught error; never crashes. |
15+
16+
## JavaScript API
17+
18+
### Global error events
19+
20+
```js
21+
globalThis.addEventListener("error", (e) => {
22+
// e is an ErrorEvent: { message, error, filename, lineno, colno }
23+
// (filename/lineno/colno are not populated yet)
24+
console.log(e.message, e.error);
25+
e.preventDefault(); // marks the error handled: no hook, no crash, no error activity
26+
});
27+
28+
globalThis.addEventListener("unhandledrejection", (e) => {
29+
// e is a PromiseRejectionEvent: { promise, reason }
30+
console.log(e.reason);
31+
e.preventDefault();
32+
});
33+
34+
globalThis.addEventListener("rejectionhandled", (e) => {
35+
// fired (as a task, on a following looper turn) when a handler is attached
36+
// to a promise whose rejection was already reported; carries the original
37+
// reason. Not cancelable.
38+
});
39+
```
40+
41+
Notes:
42+
43+
- `error` and `unhandledrejection` are `cancelable`; `preventDefault()` suppresses every downstream consequence (legacy hooks, logcat report, the error activity, the process crash).
44+
- Events fire even if app code overwrites `globalThis.dispatchEvent` — native dispatch goes through closures captured at startup.
45+
- A listener that throws does not stop the remaining listeners; the thrown value is routed to the fatal reporting tail directly (never recursively dispatched as another `error` event).
46+
- The events also fire on worker globals. A worker's unhandled rejection dispatches `unhandledrejection` on the worker's own global first; only when unprevented does it continue to the worker-global `onerror` and then to the parent's `worker.onerror`, mirroring uncaught worker errors.
47+
48+
### `reportError`
49+
50+
Routes a caught-but-fatal error through the exact same pipeline as an uncaught exception:
51+
52+
```js
53+
reportError(new Error("something unrecoverable"));
54+
```
55+
56+
### Event classes
57+
58+
`Event`, `EventTarget`, `ErrorEvent` and `PromiseRejectionEvent` are installed as global constructors. `Event`/`EventTarget` are general-purpose (registration order, `once`/`capture` options, `stopImmediatePropagation`, `handleEvent` objects) and usable for your own eventing:
59+
60+
```js
61+
const target = new EventTarget();
62+
target.addEventListener("tick", (e) => { /* ... */ }, { once: true });
63+
target.dispatchEvent(new Event("tick")); // returns !defaultPrevented
64+
```
65+
66+
### What lands on the events
67+
68+
The stacks live on the error/reason **value**, not on the event — and the thrown value can be anything, so shape-check before use:
69+
70+
| You wrote | `e.error` / `e.reason` is | JS stack | Native exception |
71+
|---|---|---|---|
72+
| `throw new Error("x")` | that `Error` | `e.error.stack` ||
73+
| 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) |
74+
75+
### Catching native exceptions
76+
77+
```js
78+
try {
79+
someJavaObject.methodThatThrowsIOException();
80+
} catch (e) {
81+
e.nativeException instanceof java.io.IOException; // true
82+
e.nativeException.getMessage(); // the Java message
83+
e.stackTrace; // combined JS + Java stack as a string
84+
}
85+
```
86+
87+
### Forwarding a throw to native: `interop.escapeException`
88+
89+
A plain JS throw inside a Java-invoked callback already escapes to the Java caller — as a `com.tns.NativeScriptException`. That is the right default, but when the caller is waiting for a *concrete* exception type, the wrapper doesn't match its `catch`. Branding the throw forwards the **original** Java exception instead:
90+
91+
```js
92+
const listener = new some.api.Listener({
93+
onEvent() {
94+
try {
95+
riskyJavaCall(); // throws java.io.IOException
96+
} catch (e) {
97+
throw interop.escapeException(e); // the Java caller catches the ORIGINAL IOException
98+
}
99+
},
100+
});
101+
```
102+
103+
Semantics:
104+
105+
- `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).
107+
- 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.
108+
- The `escapeException()` call site's stack is recorded too — for non-Error values (`escapeException("boom")`) it is the only stack available.
109+
- Branded escapes bypass `discardUncaughtJsExceptions` (an explicit forward request must reach the caller).
110+
111+
## Native (Java) API
112+
113+
### Catching escaped exceptions
114+
115+
```java
116+
try {
117+
listener.onEvent(); // implemented in JS
118+
} catch (java.io.IOException e) {
119+
// For rethrown originals: e is the very same object the JS code caught.
120+
// For synthesized escapes: catch com.tns.NativeScriptException instead -
121+
// its message is the JS error's message and its stack trace is the JS frames.
122+
}
123+
```
124+
125+
### JS stack traces on Java exceptions: `com.tns.JavaScriptStackTrace`
126+
127+
An escaped original exception carries its JavaScript journey as a suppressed throwable, so it renders automatically in `printStackTrace()`, logcat fatal logs and crash reporters:
128+
129+
```
130+
java.io.IOException: original-io-exception
131+
at com.example.SomeApi.riskyJavaCall(SomeApi.java:42)
132+
...
133+
Suppressed: com.tns.JavaScriptStackTrace: Error: original-io-exception
134+
at <js>.onEvent(main-view-model.js:17)
135+
...
136+
```
137+
138+
`JavaScriptStackTrace` is never thrown — only attached — and its stack trace elements are synthesized from the V8 frames. Crash-SDK integrations can look it up and read the raw stacks:
139+
140+
```java
141+
for (Throwable suppressed : caught.getSuppressed()) {
142+
if (suppressed instanceof com.tns.JavaScriptStackTrace) {
143+
com.tns.JavaScriptStackTrace jsTrace = (com.tns.JavaScriptStackTrace) suppressed;
144+
String originStack = jsTrace.getJavaScriptStack(); // where the JS error was created
145+
String escapeStack = jsTrace.getEscapeSiteStack(); // where interop.escapeException() was called
146+
}
147+
}
148+
```
149+
150+
| Exception | Where the JS stack lives |
151+
|---|---|
152+
| Rethrown original `Throwable` | suppressed `com.tns.JavaScriptStackTrace` (identity, stack and cause chain of the original are untouched) |
153+
| Synthesized escape (`com.tns.NativeScriptException`) | the exception's own stack trace elements are the JS frames; the message is the JS error's message |
154+
155+
`JavaScriptStackTrace` and its two accessors are the stable contract for crash-SDK integrations; other exception paths may adopt the carrier in the future.
156+
157+
## Configuration
158+
159+
| Flag (app `package.json`, default off) | Effect |
160+
|---|---|
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. |
162+
163+
There is no `crashOnUncaughtJsExceptions` flag (unlike iOS): crashing on truly-uncaught exceptions is already Android's default.
164+
165+
Terminal-path decision table:
166+
167+
| Condition | legacy hook called | process crash |
168+
|---|---|---|
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 |
173+
| unhandled rejection / `reportError`, `preventDefault()` | none | no |
174+
175+
## Crash reporter integration
176+
177+
JS side — attach both the JS and native exception from one listener:
178+
179+
```js
180+
globalThis.addEventListener("error", (e) => {
181+
const err = e.error;
182+
const native = err && err.nativeException;
183+
crashReporter.capture(err instanceof Error ? err : new Error(e.message), {
184+
nativeClass: native ? native.getClass().getName() : undefined,
185+
nativeMessage: native ? native.getMessage() : undefined,
186+
});
187+
// e.preventDefault(); // only if the reporter fully owns error handling
188+
});
189+
```
190+
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`).
192+
193+
## Legacy hooks (deprecated)
194+
195+
`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", ...)`.
196+
197+
## Behavior details
198+
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.
200+
- 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.

0 commit comments

Comments
 (0)