diff --git a/README.md b/README.md index b41749a16..e4170a6f6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Contains the source code for the NativeScript's Android Runtime. [NativeScript]( - [Build Prerequisites](#build-prerequisites) - [How to build](#how-to-build) - [How to run tests](#how-to-run-tests) +- [Documentation](#documentation) - [Misc](#misc) - [Get Help](#get-help) @@ -124,6 +125,10 @@ npx ns debug android --start 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). +## Documentation + +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`. + ## Misc * [Implementing additional Chrome DevTools protocol Domains](docs/extending-inspector.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..761f8d702 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,4 @@ +# Runtime documentation + +- [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. +- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 000000000..de84aa6cc --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,245 @@ +# Error handling + +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()`. + +## Quick reference + +| Situation | Default behavior | +|---|---| +| 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. | +| 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. | +| 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. | +| `.catch()` added after the report | `rejectionhandled` event (non-cancelable), carrying the original reason. | +| Java exception during a JS→Java call | Surfaced to JS as an `Error` carrying the original as `error.nativeException`. | +| `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. | +| `reportError(x)` | Routed through the same pipeline as an uncaught error; never crashes. | +| `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). | +| Uncaught **native** exception (a Java thread crashing — pure-native errors, uncaught `escapeException` forwards, bootstrap failures) | Cancelable `nativeuncaughterror` event, dispatched synchronously from the uncaught-exception handler (falling back to the **main runtime** for threads with no runtime of their own) → `__onUncaughtError` hook → error activity in debug → process exits. `preventDefault()` skips the error activity and the killing handler — meaningful for background-thread crashes. | + +## JavaScript API + +### Global error events + +```js +globalThis.addEventListener("error", (e) => { + // e is an ErrorEvent: { message, error, filename, lineno, colno } + // (filename/lineno/colno are not populated yet) + console.log(e.message, e.error); + e.preventDefault(); // marks the error handled: no hook, no crash, no error activity +}); + +globalThis.addEventListener("unhandledrejection", (e) => { + // e is a PromiseRejectionEvent: { promise, reason } + console.log(e.reason); + e.preventDefault(); +}); + +globalThis.addEventListener("rejectionhandled", (e) => { + // fired (as a task, on a following looper turn) when a handler is attached + // to a promise whose rejection was already reported; carries the original + // reason. Not cancelable. +}); + +globalThis.addEventListener("nativeuncaughterror", (e) => { + // The native-layer death notification: an uncaught NATIVE exception (a Java + // thread crashing) - not a JS error. e.error.nativeException is the original + // Throwable. Dispatched synchronously from the uncaught-exception handler; + // crashes on threads with no runtime of their own report through the main + // runtime. preventDefault() is BEST-EFFORT: on Android it skips the error + // activity and the default (killing) handler - realistic for background + // threads (the crashed thread itself is gone; a main-thread crash has + // already lost its looper). On iOS termination is unavoidable and the + // cancel is ignored. + crashReporter.capture(e.error); +}); +``` + +Notes: + +- `error` and `unhandledrejection` are `cancelable`; `preventDefault()` suppresses every downstream consequence (legacy hooks, logcat report, the error activity, the process crash). +- Events fire even if app code overwrites `globalThis.dispatchEvent` — native dispatch goes through closures captured at startup. +- 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). +- 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. + +### `reportError` + +Routes a caught-but-fatal error through the exact same pipeline as an uncaught exception: + +```js +reportError(new Error("something unrecoverable")); +``` + +### Event classes + +`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: + +```js +const target = new EventTarget(); +target.addEventListener("tick", (e) => { /* ... */ }, { once: true }); +target.dispatchEvent(new Event("tick")); // returns !defaultPrevented +``` + +### What lands on the events + +The stacks live on the error/reason **value**, not on the event — and the thrown value can be anything, so shape-check before use: + +| You wrote | `e.error` / `e.reason` is | JS stack | Native exception | +|---|---|---|---| +| `throw new Error("x")` | that `Error` (the actual thrown value) | `e.error.stack` | — | +| 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) | +| `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`) | + +### Catching native exceptions + +```js +try { + someJavaObject.methodThatThrowsIOException(); +} catch (e) { + e.nativeException instanceof java.io.IOException; // true + e.nativeException.getMessage(); // the Java message + e.stackTrace; // combined JS + Java stack as a string +} +``` + +### Forwarding a throw to native: `interop.escapeException` + +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: + +```js +const listener = new some.api.Listener({ + onEvent() { + try { + riskyJavaCall(); // throws java.io.IOException + } catch (e) { + throw interop.escapeException(e); // the Java caller catches the ORIGINAL IOException + } + }, +}); +``` + +Semantics: + +- `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`. +- 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: + +```js +// The caller catches THIS exact IOException - no wrapper. Without the brand, +// a directly-thrown wrapped Throwable behaves like any other uncaught throw: +// contained (reported, caller resumes) in a native-initiated callback, or - +// in a JS-initiated chain - propagated to the outer JS catch (and, if it +// reaches Java code with no JS below, wrapped in a com.tns.NativeScriptException +// with the IOException as its cause). +throw interop.escapeException(new java.io.IOException("x")); +``` +- 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. +- The `escapeException()` call site's stack is recorded too — for non-Error values (`escapeException("boom")`) it is the only stack available. +- Branded escapes bypass `discardUncaughtJsExceptions` (an explicit forward request must reach the caller). +- The `interop` global is new in this release with `escapeException` as its only member — shared code targeting older runtimes should feature-detect: `global.interop?.escapeException`. + +## Native (Java) API + +### Catching escaped exceptions + +```java +try { + listener.onEvent(); // implemented in JS +} catch (java.io.IOException e) { + // For rethrown originals: e is the very same object the JS code caught. + // For synthesized escapes: catch com.tns.NativeScriptException instead - + // its message is the JS error's message and its stack trace is the JS frames. +} +``` + +### JS stack traces on Java exceptions: `com.tns.JavaScriptStackTrace` + +An escaped original exception carries its JavaScript journey as a suppressed throwable, so it renders automatically in `printStackTrace()`, logcat fatal logs and crash reporters: + +``` +java.io.IOException: original-io-exception + at com.example.SomeApi.riskyJavaCall(SomeApi.java:42) + ... + Suppressed: com.tns.JavaScriptStackTrace: Error: original-io-exception + at .onEvent(main-view-model.js:17) + ... +``` + +`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: + +```java +for (Throwable suppressed : caught.getSuppressed()) { + if (suppressed instanceof com.tns.JavaScriptStackTrace) { + com.tns.JavaScriptStackTrace jsTrace = (com.tns.JavaScriptStackTrace) suppressed; + String originStack = jsTrace.getJavaScriptStack(); // where the JS error was created + String escapeStack = jsTrace.getEscapeSiteStack(); // where interop.escapeException() was called + } +} +``` + +| Exception | Where the JS stack lives | +|---|---| +| Rethrown original `Throwable` | suppressed `com.tns.JavaScriptStackTrace` (identity, stack and cause chain of the original are untouched) | +| Synthesized escape (`com.tns.NativeScriptException`) | the exception's own stack trace elements are the JS frames; the message is the JS error's message | + +`JavaScriptStackTrace` and its two accessors are the stable contract for crash-SDK integrations; other exception paths may adopt the carrier in the future. + +## Configuration + +One policy key in the app's `package.json` (root level) governs what happens to an **unprevented** uncaught error or unhandled rejection: + +| `uncaughtErrorPolicy` | Effect | +|---|---| +| `"report"` (default) | Report (event → hook → log) and continue. Never crashes. | +| `"throw"` | The full report runs first, at the decision point — cancelable event (`preventDefault()` still fully contains the error, same as iOS), then hook and log — and the error is *then* thrown to the native layer as a real Java exception: `com.tns.NativeScriptException` with the JS frames as its stack trace, marked `isReportedToJs()` so the uncaught-exception path does not report the same failure twice. This typically ends the process (the pre-9.1 default), though the policy names the mechanism, not a guaranteed crash. Unhandled rejections are thrown from a clean frame on the runtime's looper. **Cross-platform note:** on Android the sync throw unwinds through the native caller at the method boundary, so a Java `try/catch` above it can intercept; on iOS the rethrow is synchronous (catchable) only at boundaries that report within their own frame (property accessors, adapter reads) — block/overridden-method callbacks and loop-originated errors fall back to a deferred clean-frame throw. Portable code that needs a native-interceptable exception for a *specific* call should use `interop.escapeException`, which behaves identically on both platforms at every boundary; the policy governs unprevented, already-reported errors only. | + +Deprecated (kept for the transition, both emit a logcat warning): + +| Flag | Behavior | +|---|---| +| `discardUncaughtJsExceptions: true` | Legacy quiet routing: contained reports call `__onDiscardedError` instead of `__onUncaughtError` and skip the logcat report. Branded `interop.escapeException` throws bypass it. | +| `discardUncaughtJsExceptions: false` | Ignored (this used to mean "crash on uncaught errors" — set `uncaughtErrorPolicy: "throw"` for that). | + +Terminal-path decision table: + +| Condition | legacy hook called | process crash | +|---|---|---| +| uncaught error, default (`"report"`) | `__onUncaughtError` | no | +| uncaught error, `"report"` + `discardUncaughtJsExceptions: true` | `__onDiscardedError` | no | +| uncaught error, listener called `preventDefault()` (either policy) | none | no | +| uncaught error / unhandled rejection, `"throw"`, unprevented | `__onUncaughtError` (at the decision point, before the throw) | yes, normally | +| uncaught error / unhandled rejection, `"throw"` + `discardUncaughtJsExceptions: true` | `__onDiscardedError` | no (discard disables the throw, matching iOS) | +| unhandled rejection / `reportError`, `"report"`, unprevented | `__onUncaughtError` | no | +| unhandled rejection / `reportError`, `preventDefault()` | none | no | + +## Crash reporter integration + +JS side — two listeners with distinct roles: `error` for recoverable JS failures, `nativeuncaughterror` for native-layer deaths. Blanket `preventDefault()` belongs only on the former; suppressing the latter keeps a process alive whose crashed thread is already gone. + +```js +globalThis.addEventListener("error", (e) => { + const err = e.error; + const native = err && err.nativeException; + crashReporter.capture(err instanceof Error ? err : new Error(e.message), { + nativeClass: native ? native.getClass().getName() : undefined, + nativeMessage: native ? native.getMessage() : undefined, + }); + // e.preventDefault(); // only if the reporter fully owns error handling +}); +``` + +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`). + +## Legacy hooks (deprecated) + +`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", ...)`. + +## Behavior details + +- **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. +- **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`. +- Every error is reported exactly once, at its decision point: the containment boundary, the rejection drain (once per looper turn, scheduled on the runtime's `ALooper`), or `reportError`. Under `"throw"` the report still happens at the decision point and the thrown `NativeScriptException` carries `isReportedToJs()`, which the uncaught-exception handler honors by not reporting again — one event per failure on both platforms. +- The legacy `stackTrace` property (combined JS + Java frames, a NativeScript extension) is set on the error/reason **before** the event dispatches, so listeners and hooks see the same shape. The standard `e.error.stack` is always there for spec-shaped code. +- A rejection that gets a handler before the end-of-turn drain is never reported (and produces no `rejectionhandled` either). +- **`error` never lies about recoverability.** The invariant across the whole model: `error`/`unhandledrejection` fire only while the failure is still containable (the app is fully alive, `preventDefault()` really means "handled, continue"); `nativeuncaughterror` fires when the native layer is already dying. The uncaught-exception handler classifies nothing: *everything* that reaches it un-marked — pure-native crashes, uncaught `escapeException` forwards, bootstrap failures — dispatches `nativeuncaughterror`. Exceptions already reported at a `"throw"`-policy decision point (`NativeScriptException.isReportedToJs()`) are skipped entirely. +- **Runtime-less threads report through the main runtime.** `NativeScriptUncaughtExceptionHandler` falls back to `Runtime.getMainRuntime()` when the crashing thread has no runtime of its own, entering the main isolate cross-thread (the JNI layer takes the `v8::Locker`), so plugin/executor-thread crashes are no longer invisible to JS. The legacy `__onUncaughtError` hook keeps firing for unprevented native crashes (deprecated back-compat — it is what `Application.uncaughtErrorEvent` has received for years). +- Module/script evaluation (`runModule`/`runScript`, app bootstrap) is not contained — an app whose main module fails to load still fails loudly. +- Worker isolates run the same machinery: each worker has its own tracker, drain, event layer and containment. diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 8184e7553..2ff19f79d 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -74,6 +74,10 @@ require('./tests/testURLImpl.js'); require('./tests/testURLSearchParamsImpl.js'); require('./tests/testPerformanceNow'); require('./tests/testQueueMicrotask'); +require('./tests/testErrorEvents'); +require('./tests/testUnhandledRejections'); +require('./tests/testEscapeException'); +require('./tests/testUncaughtErrorPolicy'); require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/testErrorEvents.js b/test-app/app/src/main/assets/app/tests/testErrorEvents.js new file mode 100644 index 000000000..2e1cbd988 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testErrorEvents.js @@ -0,0 +1,245 @@ +describe("WHATWG error events", function () { + // Many tests exercise the global error path, which ends in the + // __onUncaughtError / __onDiscardedError hooks when a listener does not + // preventDefault(). Install spies for every test and restore the previous + // hooks in afterEach. Also track listeners added on the global target so + // they never leak into other suites (the internal EventTarget backing the + // global is process-wide). + var previousUncaughtHook; + var previousDiscardedHook; + var uncaught; + var discarded; + var addedGlobalListeners; + + beforeEach(function () { + previousUncaughtHook = global.__onUncaughtError; + previousDiscardedHook = global.__onDiscardedError; + uncaught = []; + discarded = []; + global.__onUncaughtError = function (error) { + uncaught.push(error); + }; + global.__onDiscardedError = function (error) { + discarded.push(error); + }; + addedGlobalListeners = []; + }); + + afterEach(function () { + global.__onUncaughtError = previousUncaughtHook; + global.__onDiscardedError = previousDiscardedHook; + for (var i = 0; i < addedGlobalListeners.length; i++) { + var l = addedGlobalListeners[i]; + global.removeEventListener(l.type, l.handler); + } + addedGlobalListeners = []; + }); + + function onGlobal(type, handler) { + global.addEventListener(type, handler); + addedGlobalListeners.push({ type: type, handler: handler }); + } + + // Wait a couple of quiet looper turns before asserting a NON-event. + function afterQuietTurns(cb) { + setTimeout(function () { + setTimeout(cb, 25); + }, 25); + } + + it("reportError fires an 'error' listener with an ErrorEvent carrying error and message", function (done) { + var err = new Error("x"); + var received = null; + onGlobal("error", function (e) { + received = e; + e.preventDefault(); + }); + + global.reportError(err); + + expect(received).not.toBeNull(); + expect(received instanceof ErrorEvent).toBe(true); + expect(received.type).toBe("error"); + expect(received.error).toBe(err); + expect(received.message).toBe("x"); + // preventDefault() in the listener must suppress the __onUncaughtError hook. + afterQuietTurns(function () { + expect(uncaught.length).toBe(0); + done(); + }); + }); + + it("reportError without preventDefault still invokes __onUncaughtError (back-compat)", function () { + var err = new Error("back-compat"); + var received = null; + onGlobal("error", function (e) { + received = e; + }); + + global.reportError(err); + + expect(received).not.toBeNull(); + expect(received.error).toBe(err); + expect(uncaught.length).toBe(1); + expect(uncaught[0]).toBe(err); + }); + + it("reportError throws TypeError when called with no arguments", function () { + expect(function () { + global.reportError(); + }).toThrowError(TypeError); + }); + + it("a discarded Java exception dispatches an 'error' event before __onDiscardedError", function () { + var received = null; + onGlobal("error", function (e) { + received = e; + }); + + var test = new com.tns.tests.DiscardedExceptionTest(); + test.reportSupressedException(); + + expect(received).not.toBeNull(); + expect(received instanceof ErrorEvent).toBe(true); + expect(received.error).not.toBeNull(); + expect(received.error.message).toBe("Exception to suppress"); + // Unprevented, so the existing hook still fires (back-compat). + expect(discarded.length).toBe(1); + expect(discarded[0]).toBe(received.error); + }); + + it("preventDefault() on the 'error' event suppresses __onDiscardedError", function () { + var received = null; + onGlobal("error", function (e) { + received = e; + e.preventDefault(); + }); + + var test = new com.tns.tests.DiscardedExceptionTest(); + test.reportSupressedException(); + + expect(received).not.toBeNull(); + expect(discarded.length).toBe(0); + }); + + describe("constructors and EventTarget semantics", function () { + it("Event is spec-sane and cancelable via preventDefault", function () { + var e = new Event("x", { cancelable: true }); + expect(e.type).toBe("x"); + expect(e.cancelable).toBe(true); + expect(e.bubbles).toBe(false); + expect(e.defaultPrevented).toBe(false); + e.preventDefault(); + expect(e.defaultPrevented).toBe(true); + }); + + it("a non-cancelable Event ignores preventDefault", function () { + var e = new Event("x"); + e.preventDefault(); + expect(e.defaultPrevented).toBe(false); + }); + + it("ErrorEvent exposes message/error/filename/lineno/colno", function () { + var err = new Error("boom"); + var e = new ErrorEvent("error", { message: "m", error: err }); + expect(e instanceof Event).toBe(true); + expect(e.message).toBe("m"); + expect(e.error).toBe(err); + expect(e.filename).toBe(""); + expect(e.lineno).toBe(0); + expect(e.colno).toBe(0); + }); + + it("PromiseRejectionEvent exposes promise/reason", function () { + var p = Promise.reject(1); + p.catch(function () {}); + var r = { some: "reason" }; + var e = new PromiseRejectionEvent("unhandledrejection", { promise: p, reason: r }); + expect(e instanceof Event).toBe(true); + expect(e.promise).toBe(p); + expect(e.reason).toBe(r); + }); + + it("dispatchEvent returns !defaultPrevented", function () { + var target = new EventTarget(); + target.addEventListener("t", function (e) { e.preventDefault(); }); + expect(target.dispatchEvent(new Event("t", { cancelable: true }))).toBe(false); + + var target2 = new EventTarget(); + target2.addEventListener("t", function () {}); + expect(target2.dispatchEvent(new Event("t", { cancelable: true }))).toBe(true); + }); + + it("once:true listener fires exactly once", function () { + var target = new EventTarget(); + var count = 0; + target.addEventListener("t", function () { count++; }, { once: true }); + target.dispatchEvent(new Event("t")); + target.dispatchEvent(new Event("t")); + expect(count).toBe(1); + }); + + it("removeEventListener stops future dispatches", function () { + var target = new EventTarget(); + var count = 0; + var handler = function () { count++; }; + target.addEventListener("t", handler); + target.dispatchEvent(new Event("t")); + target.removeEventListener("t", handler); + target.dispatchEvent(new Event("t")); + expect(count).toBe(1); + }); + + it("listeners run in registration order", function () { + var target = new EventTarget(); + var order = []; + target.addEventListener("t", function () { order.push(1); }); + target.addEventListener("t", function () { order.push(2); }); + target.addEventListener("t", function () { order.push(3); }); + target.dispatchEvent(new Event("t")); + expect(order).toEqual([1, 2, 3]); + }); + + it("stopImmediatePropagation stops remaining listeners", function () { + var target = new EventTarget(); + var order = []; + target.addEventListener("t", function (e) { order.push(1); e.stopImmediatePropagation(); }); + target.addEventListener("t", function () { order.push(2); }); + target.dispatchEvent(new Event("t")); + expect(order).toEqual([1]); + }); + + it("a throwing listener does not stop later listeners", function () { + var target = new EventTarget(); + var order = []; + target.addEventListener("t", function () { order.push(1); throw new Error("listener boom"); }); + target.addEventListener("t", function () { order.push(2); }); + target.dispatchEvent(new Event("t")); + expect(order).toEqual([1, 2]); + }); + }); + + it("reportError still fires listeners after globalThis.dispatchEvent is overwritten", function (done) { + var err = new Error("resilient"); + var received = null; + onGlobal("error", function (e) { + received = e; + e.preventDefault(); + }); + + var originalDispatch = globalThis.dispatchEvent; + globalThis.dispatchEvent = function () { return true; }; + try { + global.reportError(err); + } finally { + globalThis.dispatchEvent = originalDispatch; + } + + expect(received).not.toBeNull(); + expect(received.error).toBe(err); + afterQuietTurns(function () { + expect(uncaught.length).toBe(0); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testEscapeException.js b/test-app/app/src/main/assets/app/tests/testEscapeException.js new file mode 100644 index 000000000..cc459a85f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEscapeException.js @@ -0,0 +1,262 @@ +describe("interop.escapeException", function () { + it("exists on the interop global", function () { + expect(typeof interop).toBe("object"); + expect(typeof interop.escapeException).toBe("function"); + }); + + it("returns a throwable Error preserving the message", function () { + var wrapped = interop.escapeException(new Error("boom")); + expect(wrapped instanceof Error).toBe(true); + expect(wrapped.message).toBe("boom"); + + var caught = null; + try { + throw wrapped; + } catch (e) { + caught = e; + } + expect(caught).toBe(wrapped); + }); + + it("throws TypeError when called with no arguments", function () { + expect(function () { + interop.escapeException(); + }).toThrowError(TypeError); + }); + + it("is idempotent (double-wrap returns the same object)", function () { + var once = interop.escapeException(new Error("once")); + var twice = interop.escapeException(once); + expect(twice).toBe(once); + }); + + it("rethrows the ORIGINAL Java exception to a native caller", function () { + var caught = null; + try { + com.tns.tests.EscapeExceptionTest.throwIOException(); + } catch (e) { + caught = e; + } + expect(caught).not.toBeNull(); + expect(caught.nativeException).toBeDefined(); + + var runnable = new java.lang.Runnable({ + run: function () { + throw interop.escapeException(caught); + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + // The native caller caught the original java.io.IOException - not a + // com.tns.NativeScriptException wrapper - so a concrete + // `catch (IOException e)` in native code would match. + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("java.io.IOException"); + expect(ret.getMessage()).toBe("original-io-exception"); + expect(ret.equals(caught.nativeException)).toBe(true); + + // JS is still alive after the escape round-trip. + expect(1 + 1).toBe(2); + }); + + it("carries the JS trace on the original exception as a suppressed JavaScriptStackTrace", function () { + var caught = null; + try { + com.tns.tests.EscapeExceptionTest.throwIOException(); + } catch (e) { + caught = e; + } + + var runnable = new java.lang.Runnable({ + run: function () { + throw interop.escapeException(caught); + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + var suppressed = ret.getSuppressed(); + expect(suppressed.length).toBe(1); + var carrier = suppressed[0]; + expect(carrier.getClass().getName()).toBe("com.tns.JavaScriptStackTrace"); + + // The carrier renders the JS frames as real StackTraceElements + // pointing at this spec file. + var frames = carrier.getStackTrace(); + expect(frames.length).toBeGreaterThan(0); + var sawThisFile = false; + for (var i = 0; i < frames.length; i++) { + var file = frames[i].getFileName(); + if (file && file.indexOf("testEscapeException.js") !== -1) { + sawThisFile = true; + break; + } + } + expect(sawThisFile).toBe(true); + + // The escape call site is recorded too, for SDK integrations. + expect(carrier.getEscapeSiteStack()).toContain("testEscapeException.js"); + }); + + it("does not stack duplicate carriers when the same throwable escapes twice", function () { + var caught = null; + try { + com.tns.tests.EscapeExceptionTest.throwIOException(); + } catch (e) { + caught = e; + } + + var escapeOnce = new java.lang.Runnable({ + run: function () { + throw interop.escapeException(caught); + } + }); + var first = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(escapeOnce); + expect(first.getSuppressed().length).toBe(1); + + // Re-escape the SAME original throwable (re-caught and re-forwarded, + // as through nested overrides). + var reCaught = null; + try { + throw interop.escapeException(caught); + } catch (e) { + reCaught = e; + } + var escapeAgain = new java.lang.Runnable({ + run: function () { + throw reCaught; + } + }); + var second = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(escapeAgain); + + expect(second.equals(first)).toBe(true); + expect(second.getSuppressed().length).toBe(1); + }); + + it("escapes a directly-constructed Java exception (not wrapped in an Error)", function () { + var original = new java.io.IOException("direct-io"); + var runnable = new java.lang.Runnable({ + run: function () { + // The escaped value IS the wrapped Throwable - there is no JS + // Error and no .nativeException property involved. + throw interop.escapeException(original); + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("java.io.IOException"); + expect(ret.getMessage()).toBe("direct-io"); + expect(ret.equals(original)).toBe(true); + + // A wrapped Throwable has no JS stack of its own, so the carrier + // renders the escape site. + var suppressed = ret.getSuppressed(); + expect(suppressed.length).toBe(1); + expect(suppressed[0].getClass().getName()).toBe("com.tns.JavaScriptStackTrace"); + var frames = suppressed[0].getStackTrace(); + var sawThisFile = false; + for (var i = 0; i < frames.length; i++) { + var file = frames[i].getFileName(); + if (file && file.indexOf("testEscapeException.js") !== -1) { + sawThisFile = true; + break; + } + } + expect(sawThisFile).toBe(true); + }); + + it("an unbranded directly-thrown Java exception surfaces wrapped, original as cause", function () { + var original = new java.io.IOException("direct-unbranded"); + var runnable = new java.lang.Runnable({ + run: function () { + throw original; + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException"); + expect(ret.getCause().equals(original)).toBe(true); + }); + + it("an unbranded rethrow keeps today's wrapping semantics", function () { + var caught = null; + try { + com.tns.tests.EscapeExceptionTest.throwIOException(); + } catch (e) { + caught = e; + } + expect(caught).not.toBeNull(); + + var runnable = new java.lang.Runnable({ + run: function () { + throw caught; + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + // Without the brand the caller receives the NativeScriptException + // wrapper, with the original exception preserved as its cause. + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException"); + expect(ret.getCause().equals(caught.nativeException)).toBe(true); + }); + + it("a branded plain JS error escapes with the default NativeScriptException shape", function () { + var runnable = new java.lang.Runnable({ + run: function () { + throw interop.escapeException(new Error("plain-escape")); + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + // No underlying Java throwable to unwrap, so the standard escape path + // applies: the caller gets a com.tns.NativeScriptException carrying + // the JS message. + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException"); + expect(ret.getMessage()).toContain("plain-escape"); + + // Its Java stack is replaced with frames synthesized from the JS + // stack, so crash reporters group by where it actually happened + // instead of the (identical) JNI boundary machinery. + var frames = ret.getStackTrace(); + expect(frames.length).toBeGreaterThan(0); + var sawThisFile = false; + for (var i = 0; i < frames.length; i++) { + var file = frames[i].getFileName(); + if (file && file.indexOf("testEscapeException.js") !== -1) { + sawThisFile = true; + break; + } + } + expect(sawThisFile).toBe(true); + }); + + it("a non-Error escape carries the escape-site stack", function () { + var runnable = new java.lang.Runnable({ + run: function () { + throw interop.escapeException("boom-string"); + } + }); + var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable); + + expect(ret).not.toBeNull(); + expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException"); + expect(ret.getMessage()).toContain("boom-string"); + + // A string has no stack of its own, so the escapeException() call + // site - the only stack available - provides the frames. + var frames = ret.getStackTrace(); + expect(frames.length).toBeGreaterThan(0); + var sawThisFile = false; + for (var i = 0; i < frames.length; i++) { + var file = frames[i].getFileName(); + if (file && file.indexOf("testEscapeException.js") !== -1) { + sawThisFile = true; + break; + } + } + expect(sawThisFile).toBe(true); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testUncaughtErrorPolicy.js b/test-app/app/src/main/assets/app/tests/testUncaughtErrorPolicy.js new file mode 100644 index 000000000..655bd76fb --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testUncaughtErrorPolicy.js @@ -0,0 +1,267 @@ +describe("uncaughtErrorPolicy (default: report)", function () { + // Under the default "report" policy, an uncaught JS throw in a + // native-initiated callback is contained: reported through the `error` + // event and the __onUncaughtError hook, while the native caller resumes + // with a default value and the app keeps running. JS-initiated chains + // (JS -> Java -> JS) keep propagating to the outer JS catch. + var previousUncaughtHook; + var uncaught; + var addedGlobalListeners; + + beforeEach(function () { + previousUncaughtHook = global.__onUncaughtError; + uncaught = []; + global.__onUncaughtError = function (error) { + uncaught.push(error); + }; + addedGlobalListeners = []; + }); + + afterEach(function () { + global.__onUncaughtError = previousUncaughtHook; + for (var i = 0; i < addedGlobalListeners.length; i++) { + var l = addedGlobalListeners[i]; + global.removeEventListener(l.type, l.handler); + } + addedGlobalListeners = []; + }); + + function onGlobal(type, handler) { + global.addEventListener(type, handler); + addedGlobalListeners.push({ type: type, handler: handler }); + } + + function uncaughtSeen(err) { + return uncaught.indexOf(err) !== -1; + } + + function pollUntil(predicate, cb) { + var turns = 0; + (function poll() { + if (predicate() || turns >= 25) { + cb(); + return; + } + turns++; + setTimeout(poll, 10); + })(); + } + + function afterQuietTurns(cb) { + setTimeout(function () { + setTimeout(cb, 20); + }, 20); + } + + it("contains an uncaught throw in a native-initiated callback and keeps the app alive", function (done) { + var reason = new Error("contained-post-throw"); + var received = null; + onGlobal("error", function (e) { + if (e.error === reason) { + received = e; + } + }); + + var runnable = new java.lang.Runnable({ + run: function () { + throw reason; + } + }); + new android.os.Handler(android.os.Looper.myLooper()).post(runnable); + + pollUntil(function () { return received !== null; }, function () { + expect(received).not.toBeNull(); + // The event carries the actual thrown value. + expect(received.error).toBe(reason); + // The combined `stackTrace` string is populated BEFORE dispatch, + // so listeners can read it (not only e.error.stack). + expect(typeof received.error.stackTrace).toBe("string"); + expect(received.error.stackTrace.length).toBeGreaterThan(0); + // Unprevented, so the legacy hook fired too. + expect(uncaughtSeen(reason)).toBe(true); + // And the app is still running. + expect(1 + 1).toBe(2); + done(); + }); + }); + + it("preventDefault() on the error event suppresses the legacy hook", function (done) { + var reason = new Error("contained-prevented-throw"); + var ran = false; + onGlobal("error", function (e) { + if (e.error === reason) { + e.preventDefault(); + } + }); + + var runnable = new java.lang.Runnable({ + run: function () { + ran = true; + throw reason; + } + }); + new android.os.Handler(android.os.Looper.myLooper()).post(runnable); + + pollUntil(function () { return ran; }, function () { + afterQuietTurns(function () { + expect(uncaughtSeen(reason)).toBe(false); + done(); + }); + }); + }); + + it("contains an uncaught throw in a setTimeout callback", function (done) { + var reason = new Error("contained-timer-throw"); + setTimeout(function () { + throw reason; + }, 1); + + pollUntil(function () { return uncaughtSeen(reason); }, function () { + expect(uncaughtSeen(reason)).toBe(true); + expect(1 + 1).toBe(2); + done(); + }); + }); + + it("a contained throw in a primitive-returning override yields the type's default", function (done) { + var reason = new Error("contained-compare-throw"); + var doneRan = false; + var comparator = new java.util.Comparator({ + compare: function () { + throw reason; + } + }); + var doneCb = new java.lang.Runnable({ + run: function () { + doneRan = true; + } + }); + com.tns.tests.UncaughtErrorPolicyTest.compareOnLooper(comparator, doneCb); + + pollUntil(function () { return doneRan; }, function () { + // The Java caller received int's default instead of an NPE. + expect(com.tns.tests.UncaughtErrorPolicyTest.lastCompareResult).toBe(0); + expect(uncaughtSeen(reason)).toBe(true); + done(); + }); + }); + + it("propagates a JS-initiated chain back to the outer JS catch with identity", function () { + var reason = new Error("chain-identity"); + var caught = null; + try { + com.tns.tests.UncaughtErrorPolicyTest.invoke(new java.lang.Runnable({ + run: function () { + throw reason; + } + })); + } catch (e) { + caught = e; + } + // The exception crossed JS -> Java -> JS and surfaced as the very + // same JS object - not contained, not wrapped. + expect(caught).toBe(reason); + expect(uncaughtSeen(reason)).toBe(false); + }); + + // uncaughtErrorPolicy: "throw" is not exercised automatically because a + // real crash would kill the test runner. To smoke it manually, set + // { "uncaughtErrorPolicy": "throw" } in the app's package.json and throw + // from a native-initiated callback (or leave a promise rejection + // unhandled) - the app must terminate with a com.tns.NativeScriptException + // whose stack trace points at the JS frames. Default-off behavior is + // covered by every other suite here. +}); + +describe("nativeuncaughterror", function () { + // The native-layer death notification: fired synchronously from the + // uncaught-exception handler for exceptions the JS layer does not own. + // preventDefault() is best-effort - on Android it skips the error + // activity and the default (killing) handler, which is what keeps the + // test runner alive here. + var previousUncaughtHook; + var uncaught; + var addedGlobalListeners; + + beforeEach(function () { + previousUncaughtHook = global.__onUncaughtError; + uncaught = []; + global.__onUncaughtError = function (error) { + uncaught.push(error); + }; + addedGlobalListeners = []; + }); + + afterEach(function () { + global.__onUncaughtError = previousUncaughtHook; + for (var i = 0; i < addedGlobalListeners.length; i++) { + var l = addedGlobalListeners[i]; + global.removeEventListener(l.type, l.handler); + } + addedGlobalListeners = []; + }); + + function onGlobal(type, handler) { + global.addEventListener(type, handler); + addedGlobalListeners.push({ type: type, handler: handler }); + } + + function pollUntil(predicate, cb) { + var turns = 0; + (function poll() { + if (predicate() || turns >= 50) { + cb(); + return; + } + turns++; + setTimeout(poll, 10); + })(); + } + + it("fires for a native crash on a runtime-less thread (via the main runtime); preventDefault() spares the process", function (done) { + var marker = "native-crash-on-alien-thread"; + var received = null; + var errorEventFired = false; + + onGlobal("nativeuncaughterror", function (e) { + if (e.message && e.message.indexOf(marker) !== -1) { + received = e; + // Best-effort cancel: skip the error activity and the default + // (killing) handler. Without this the test runner would die. + e.preventDefault(); + } + }); + onGlobal("error", function (e) { + if (e.message && e.message.indexOf(marker) !== -1) { + errorEventFired = true; + } + }); + + com.tns.tests.UncaughtErrorPolicyTest.throwOnNewThread(marker); + + pollUntil(function () { return received !== null; }, function () { + expect(received).not.toBeNull(); + expect(received instanceof ErrorEvent).toBe(true); + expect(received.type).toBe("nativeuncaughterror"); + // The event carries the wrapped original Java exception. + expect(received.error.nativeException).toBeDefined(); + expect(received.error.nativeException.getMessage()).toBe(marker); + // `error` keeps its still-containable invariant: it never fires + // for a native-layer death. + expect(errorEventFired).toBe(false); + // Prevented, so the legacy hook did not fire either. + var hookSaw = false; + for (var i = 0; i < uncaught.length; i++) { + var err = uncaught[i]; + if (err && err.message === marker) { + hookSaw = true; + break; + } + } + expect(hookSaw).toBe(false); + // And the app survived the background-thread crash. + expect(1 + 1).toBe(2); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testUnhandledRejections.js b/test-app/app/src/main/assets/app/tests/testUnhandledRejections.js new file mode 100644 index 000000000..e847aa6d0 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testUnhandledRejections.js @@ -0,0 +1,185 @@ +describe("unhandled promise rejections", function () { + // Unhandled rejections are tracked per-isolate and reported once per + // looper turn through the same uncaught-error machinery exposed via + // global.__onUncaughtError. Each test installs a temporary hook and + // restores the previous one in afterEach no matter what. Assertions are + // marker-based (matching this suite's own reasons) so stray rejections + // from other suites can never interfere. + var previousHook; + var reported; + var addedGlobalListeners; + + beforeEach(function () { + previousHook = global.__onUncaughtError; + reported = []; + global.__onUncaughtError = function (error) { + reported.push(error); + }; + addedGlobalListeners = []; + }); + + afterEach(function () { + global.__onUncaughtError = previousHook; + for (var i = 0; i < addedGlobalListeners.length; i++) { + var l = addedGlobalListeners[i]; + global.removeEventListener(l.type, l.handler); + } + addedGlobalListeners = []; + }); + + function onGlobal(type, handler) { + global.addEventListener(type, handler); + addedGlobalListeners.push({ type: type, handler: handler }); + } + + function reportedSeen(reason) { + return reported.indexOf(reason) !== -1; + } + + // The drain happens on a looper turn, so poll across a few turns until the + // predicate holds (or give up after a bounded number of turns). + function pollUntil(predicate, cb) { + var turns = 0; + (function poll() { + if (predicate() || turns >= 25) { + cb(); + return; + } + turns++; + setTimeout(poll, 10); + })(); + } + + // Wait a couple of looper turns to confirm something did NOT happen. + function afterQuietTurns(cb) { + setTimeout(function () { + setTimeout(cb, 20); + }, 20); + } + + it("reports an unhandled Promise.reject through __onUncaughtError", function (done) { + var reason = new Error("unhandled-promise-reject"); + Promise.reject(reason); + pollUntil(function () { return reportedSeen(reason); }, function () { + expect(reportedSeen(reason)).toBe(true); + done(); + }); + }); + + it("does not report when .catch is attached synchronously in the same turn", function (done) { + var reason = new Error("handled-same-turn"); + var p = Promise.reject(reason); + p.catch(function () {}); + afterQuietTurns(function () { + expect(reportedSeen(reason)).toBe(false); + done(); + }); + }); + + it("reports an uncaught throw from an async function", function (done) { + var reason = new Error("async-function-throw"); + (async () => { + throw reason; + })(); + pollUntil(function () { return reportedSeen(reason); }, function () { + expect(reportedSeen(reason)).toBe(true); + done(); + }); + }); + + it("reports an unhandled rejection thrown from a .then callback", function (done) { + var reason = new Error("then-callback-throw"); + Promise.resolve().then(function () { + throw reason; + }); + pollUntil(function () { return reportedSeen(reason); }, function () { + expect(reportedSeen(reason)).toBe(true); + done(); + }); + }); + + it("ignores a late .catch attached after the rejection was already reported", function (done) { + var reason = new Error("late-catch"); + var p = Promise.reject(reason); + pollUntil(function () { return reportedSeen(reason); }, function () { + expect(reportedSeen(reason)).toBe(true); + var countBefore = reported.length; + // Attaching a handler after the report was already delivered must + // not crash or report again. + p.catch(function () {}); + afterQuietTurns(function () { + expect(reported.length).toBe(countBefore); + done(); + }); + }); + }); + + it("unhandledrejection listener receives reason and promise; preventDefault suppresses the hook", function (done) { + var reason = new Error("rejected-with-listener"); + var received = null; + onGlobal("unhandledrejection", function (e) { + if (e.reason === reason) { + received = e; + e.preventDefault(); + } + }); + + Promise.reject(reason); + + pollUntil(function () { return received !== null; }, function () { + expect(received).not.toBeNull(); + expect(received instanceof PromiseRejectionEvent).toBe(true); + expect(received.type).toBe("unhandledrejection"); + expect(received.reason).toBe(reason); + expect(typeof received.promise.then).toBe("function"); + // The drain sets a combined `stackTrace` on the (object) reason + // BEFORE dispatching the event, so a listener sees it. + expect(typeof received.reason.stackTrace).toBe("string"); + expect(received.reason.stackTrace.length).toBeGreaterThan(0); + afterQuietTurns(function () { + expect(reportedSeen(reason)).toBe(false); + done(); + }); + }); + }); + + it("fires rejectionhandled when a handler is attached after the rejection was reported", function (done) { + var reason = new Error("late-handler"); + var rejectionHandled = null; + onGlobal("rejectionhandled", function (e) { + if (e.reason === reason) { + rejectionHandled = e; + } + }); + // Prevent the report so it does not hit the hook; the promise still + // counts as reported and becomes outstanding for rejectionhandled + // purposes. + var reportedPromise = null; + onGlobal("unhandledrejection", function (e) { + if (e.reason === reason) { + reportedPromise = e.promise; + e.preventDefault(); + } + }); + + var p = Promise.reject(reason); + + pollUntil(function () { return reportedPromise !== null; }, function () { + // Attach a late handler a couple turns after the report. + setTimeout(function () { + p.catch(function () {}); + pollUntil(function () { return rejectionHandled !== null; }, function () { + expect(rejectionHandled).not.toBeNull(); + expect(rejectionHandled instanceof PromiseRejectionEvent).toBe(true); + expect(rejectionHandled.type).toBe("rejectionhandled"); + expect(typeof rejectionHandled.promise.then).toBe("function"); + expect(rejectionHandled.promise).toBe(reportedPromise); + // The original rejection reason is retained past reporting + // and carried on the rejectionhandled event, per spec. + expect(rejectionHandled.reason).toBe(reason); + done(); + }); + }, 20); + }); + }); +}); diff --git a/test-app/app/src/main/java/com/tns/NativeScriptUncaughtExceptionHandler.java b/test-app/app/src/main/java/com/tns/NativeScriptUncaughtExceptionHandler.java index 75a96c2b0..cefac2d48 100644 --- a/test-app/app/src/main/java/com/tns/NativeScriptUncaughtExceptionHandler.java +++ b/test-app/app/src/main/java/com/tns/NativeScriptUncaughtExceptionHandler.java @@ -18,35 +18,65 @@ public NativeScriptUncaughtExceptionHandler(Logger logger, Context context) { @Override public void uncaughtException(Thread thread, Throwable ex) { - String currentThreadMessage = String.format("An uncaught Exception occurred on \"%s\" thread.\n%s\n", thread.getName(), ex.getMessage()); - String stackTraceErrorMessage = Runtime.getStackTraceErrorMessage(ex); - String errorMessage = String.format("%s\nStackTrace:\n%s", currentThreadMessage, stackTraceErrorMessage); + // An uncaughtErrorPolicy: "throw" exception was already fully reported + // to JS (event + hook + log) at the throw decision point - reporting + // it again here would double-dispatch the same failure. Checked first + // so the already-reported path does no work at all: no JS roundtrip + // and no eager stack rendering (the message strings below are built + // lazily, only when something actually consumes them). + boolean alreadyReportedToJs = ex instanceof NativeScriptException && ((NativeScriptException) ex).isReportedToJs(); + + String errorMessage = null; + boolean handledByJs = false; + + if (!alreadyReportedToJs) { + // Resolve the reporting runtime FIRST: Runtime.isInitialized() is + // thread-local, so gating on it would silently skip reporting for + // crashes on threads with no runtime of their own. Those fall back + // to the main runtime's isolate (the JNI layer takes the + // v8::Locker, so entering it cross-thread is safe). + Runtime runtime = Runtime.getCurrentRuntime(); + if (runtime == null) { + runtime = Runtime.getMainRuntime(); + } - if (Runtime.isInitialized()) { - try { - if (Util.isDebuggableApp(context)) { - System.err.println(errorMessage); - } + if (runtime != null && runtime.isInitializedImpl()) { + try { + String stackTraceErrorMessage = Runtime.getStackTraceErrorMessage(ex); + errorMessage = buildErrorMessage(thread, ex, stackTraceErrorMessage); - Runtime runtime = Runtime.getCurrentRuntime(); + if (Util.isDebuggableApp(context)) { + System.err.println(errorMessage); + } - if (runtime != null) { - runtime.passUncaughtExceptionToJs(ex, ex.getMessage(), stackTraceErrorMessage, Runtime.getJSStackTrace(ex)); - } - } catch (Throwable t) { - if (Util.isDebuggableApp(context)) { - t.printStackTrace(); + handledByJs = runtime.passUncaughtExceptionToJs(ex, ex.getMessage(), stackTraceErrorMessage, Runtime.getJSStackTrace(ex)); + } catch (Throwable t) { + if (Util.isDebuggableApp(context)) { + t.printStackTrace(); + } } } } if (logger.isEnabled()) { + if (errorMessage == null) { + errorMessage = buildErrorMessage(thread, ex, Runtime.getStackTraceErrorMessage(ex)); + } logger.write("Uncaught Exception Message=" + errorMessage); } + if (handledByJs) { + // A JS `nativeuncaughterror` listener called preventDefault() - + // the exception is fully handled: no error activity, no crash. + return; + } + boolean res = false; if (Util.isDebuggableApp(context)) { + if (errorMessage == null) { + errorMessage = buildErrorMessage(thread, ex, Runtime.getStackTraceErrorMessage(ex)); + } try { Class ErrReport = null; java.lang.reflect.Method startActivity = null; @@ -69,4 +99,9 @@ public void uncaughtException(Thread thread, Throwable ex) { defaultHandler.uncaughtException(thread, ex); } } + + private static String buildErrorMessage(Thread thread, Throwable ex, String stackTraceErrorMessage) { + String currentThreadMessage = String.format("An uncaught Exception occurred on \"%s\" thread.\n%s\n", thread.getName(), ex.getMessage()); + return String.format("%s\nStackTrace:\n%s", currentThreadMessage, stackTraceErrorMessage); + } } diff --git a/test-app/app/src/main/java/com/tns/tests/EscapeExceptionTest.java b/test-app/app/src/main/java/com/tns/tests/EscapeExceptionTest.java new file mode 100644 index 000000000..6167ed9da --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/EscapeExceptionTest.java @@ -0,0 +1,22 @@ +package com.tns.tests; + +public class EscapeExceptionTest { + public static void throwIOException() throws java.io.IOException { + throw new java.io.IOException("original-io-exception"); + } + + /* + * Invokes a callback implemented in JS and returns whatever Throwable + * escapes it (or null). Catching Throwable (rather than a concrete type) + * lets the tests assert exactly which exception class crossed the + * JS->Java boundary. + */ + public static Throwable invokeCatchingThrowable(Runnable runnable) { + try { + runnable.run(); + return null; + } catch (Throwable t) { + return t; + } + } +} diff --git a/test-app/app/src/main/java/com/tns/tests/UncaughtErrorPolicyTest.java b/test-app/app/src/main/java/com/tns/tests/UncaughtErrorPolicyTest.java new file mode 100644 index 000000000..4567d17b5 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/UncaughtErrorPolicyTest.java @@ -0,0 +1,42 @@ +package com.tns.tests; + +public class UncaughtErrorPolicyTest { + public static volatile int lastCompareResult = -999; + + /* + * Invokes the comparator from a posted looper message - a native-initiated + * entry into JS with no JS frames below it - so an uncaught throw in the + * JS implementation is contained and compare() returns the int default. + */ + public static void compareOnLooper(final java.util.Comparator comparator, final Runnable done) { + new android.os.Handler(android.os.Looper.myLooper()).post(new Runnable() { + @Override + public void run() { + lastCompareResult = comparator.compare("a", "b"); + done.run(); + } + }); + } + + /* + * Direct pass-through with no catch: lets tests verify that a JS->Java->JS + * chain propagates the JS exception back to the outer JS catch. + */ + public static void invoke(Runnable runnable) { + runnable.run(); + } + + /* + * Crashes a brand-new thread that has no runtime of its own: the default + * uncaught-exception handler must fall back to the main runtime and + * dispatch `nativeuncaughterror` there. + */ + public static void throwOnNewThread(final String message) { + new Thread(new Runnable() { + @Override + public void run() { + throw new RuntimeException(message); + } + }).start(); + } +} diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index a8e428801..63a33e196 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -103,8 +103,11 @@ add_library( src/main/cpp/ConcurrentQueue.cpp src/main/cpp/Constants.cpp src/main/cpp/DirectBuffer.cpp + src/main/cpp/ErrorEvents.cpp + src/main/cpp/Events.cpp src/main/cpp/FieldAccessor.cpp src/main/cpp/File.cpp + src/main/cpp/Interop.cpp src/main/cpp/IsolateDisposer.cpp src/main/cpp/JEnv.cpp src/main/cpp/DesugaredInterfaceCompanionClassNameResolver.cpp diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 5ce450fef..a7fe67236 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -63,6 +63,24 @@ void CallbackHandlers::Init(Isolate *isolate) { MethodCache::Init(); } +/* + * Marks a JS -> Java call in flight on the runtime (see + * Runtime::JavaCallDepth): a JS callback that throws while the depth is + * non-zero is part of a JS-initiated chain and must propagate back to the + * outer JS catch instead of being contained at the boundary. + */ +namespace { +struct JavaCallScope { + explicit JavaCallScope(Runtime* runtime) : runtime_(runtime) { + runtime_->EnterJavaCall(); + } + ~JavaCallScope() { + runtime_->LeaveJavaCall(); + } + Runtime* runtime_; +}; +} // namespace + bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &jsObject, const std::string &fullClassName, const ArgsWrapper &argWrapper, @@ -76,6 +94,10 @@ bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &j auto runtime = Runtime::GetRuntime(isolate); auto objectManager = runtime->GetObjectManager(); + // The Java constructor may synchronously call back into JS (extended + // class init) - that whole window is a JS-initiated chain. + JavaCallScope javaCallScope(runtime); + JEnv env; jclass generatedJavaClass = ResolveClass(isolate, baseClassName, fullClassName, @@ -211,6 +233,10 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string auto isolate = args.GetIsolate(); + // The Java method may synchronously call back into JS (an overridden + // method on the receiver) - that whole window is a JS-initiated chain. + JavaCallScope javaCallScope(Runtime::GetRuntime(isolate)); + if ((entry != nullptr) && entry->getIsResolved()) { auto &entrySignature = entry->getSig(); isStatic = entry->isStatic; @@ -694,7 +720,8 @@ int CallbackHandlers::RunOnMainThreadFdCallback(int fd, int events, void *data) cb->Call(context, context->Global(), 0, nullptr); // ignore JS return value - if(tc.HasCaught()){ + if (tc.HasCaught() && + !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { throw NativeScriptException(tc); } @@ -931,9 +958,15 @@ Local CallbackHandlers::CallJSMethod(Isolate *isolate, JNIEnv *_env, //TODO: if javaResult is a pure js object create a java object that represents this object in java land if (tc.HasCaught()) { - stringstream ss; - ss << "Calling js method " << methodName << " failed"; - throw NativeScriptException(tc, ss.str()); + if (NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + // Reported per uncaughtErrorPolicy; the native caller resumes + // with a default value. + jsResult = v8::Undefined(isolate); + } else { + stringstream ss; + ss << "Calling js method " << methodName << " failed"; + throw NativeScriptException(tc, ss.str()); + } } result = handleScope.Escape(jsResult); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index eddcca93d..f62eeef7d 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -340,7 +340,8 @@ namespace tns { } - if(tc.HasCaught()){ + if (tc.HasCaught() && + !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { throw NativeScriptException(tc); } diff --git a/test-app/runtime/src/main/cpp/ErrorEvents.cpp b/test-app/runtime/src/main/cpp/ErrorEvents.cpp new file mode 100644 index 000000000..56ef451cf --- /dev/null +++ b/test-app/runtime/src/main/cpp/ErrorEvents.cpp @@ -0,0 +1,279 @@ +#include "ErrorEvents.h" + +#include "ArgConverter.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "Runtime.h" + +using namespace std; +using namespace tns; +using namespace v8; + +/* + * Non-throwing runtime lookup, safe from V8 callbacks that may fire while a + * runtime is being torn down (Runtime::GetRuntime throws in that window). + */ +static Runtime* GetRuntimeOrNull(Isolate* isolate) { + return static_cast( + isolate->GetData((uint32_t) Runtime::IsolateData::RUNTIME)); +} + +/* + * Native function handed to the bootstrap IIFE as `nativeReportFatal(error, + * stackString)`. It runs the terminal tail (shim + log) WITHOUT re-dispatching + * an event: reportError and listener-thrown errors have already gone through + * JS dispatch, so dispatching again here would recurse. + */ +static void NativeReportFatalCallback(const FunctionCallbackInfo& info) { + auto isolate = info.GetIsolate(); + Local error = info.Length() > 0 ? info[0] + : Undefined(isolate).As(); + string stack; + if (info.Length() > 1 && info[1]->IsString()) { + stack = ArgConverter::ConvertToString(info[1].As()); + } + NativeScriptException::ReportFatalTail(isolate, error, stack); +} + +void ErrorEvents::Init(Local context) { + /* + * WHATWG error-events layer, layered on top of the generic event + * primitives installed by Events::Init and ported from the iOS runtime. + * Plain (module-free) script, strict inside the IIFE, ES5-ish so it never + * depends on other runtime extensions. The IIFE is invoked with two + * arguments - the internal EventTarget backing the global (so native + * dispatch survives app code overwriting globalThis.dispatchEvent) and + * the native nativeReportFatal(error, stack) function that runs the + * terminal tail - and returns three closures bound to that backing store. + * ErrorEvent/PromiseRejectionEvent subclass the Event captured off + * globalThis at init time, which runs before any user code. + */ + auto source = R"js( + (function (globalTarget, nativeReportFatal) { + "use strict"; + var g = globalThis; + var Event = g.Event; + + function ErrorEvent(type, opts) { + opts = opts || {}; + Event.call(this, type, opts); + this.message = opts.message !== undefined ? String(opts.message) : ""; + this.filename = opts.filename !== undefined ? String(opts.filename) : ""; + this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0; + this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0; + this.error = opts.error !== undefined ? opts.error : null; + } + ErrorEvent.prototype = Object.create(Event.prototype); + ErrorEvent.prototype.constructor = ErrorEvent; + + function PromiseRejectionEvent(type, opts) { + opts = opts || {}; + Event.call(this, type, opts); + this.promise = opts.promise; + this.reason = opts.reason; + } + PromiseRejectionEvent.prototype = Object.create(Event.prototype); + PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; + + // A listener that throws must not stop other listeners: route the thrown + // value to the native fatal tail instead of ever recursively dispatching + // another `error` event from inside dispatch. + globalTarget._installListenerErrorReporter(function (e) { + try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} + }); + + g.reportError = function (e) { + if (arguments.length === 0) { + throw new TypeError("Failed to execute 'reportError': 1 argument required, but only 0 present."); + } + var ev = new ErrorEvent("error", { + message: (e && e.message !== undefined && e.message !== null) ? String(e.message) : String(e), + error: e, + cancelable: true + }); + if (globalTarget.dispatchEvent(ev)) { + nativeReportFatal(e, (e && e.stack) || ""); + } + }; + + g.ErrorEvent = ErrorEvent; + g.PromiseRejectionEvent = PromiseRejectionEvent; + + // Closures called by C++. They never look up globalThis.dispatchEvent, + // so they keep working even if app code overwrites it. + function dispatchErrorEvent(error, message, stack) { + var ev = new ErrorEvent("error", { + message: message !== undefined && message !== null ? String(message) : "", + error: error, + cancelable: true + }); + globalTarget.dispatchEvent(ev); + return ev.defaultPrevented; + } + function dispatchUnhandledRejection(promise, reason) { + var ev = new PromiseRejectionEvent("unhandledrejection", { + promise: promise, + reason: reason, + cancelable: true + }); + globalTarget.dispatchEvent(ev); + return ev.defaultPrevented; + } + function dispatchRejectionHandled(promise, reason) { + var ev = new PromiseRejectionEvent("rejectionhandled", { + promise: promise, + reason: reason, + cancelable: false + }); + globalTarget.dispatchEvent(ev); + } + function dispatchNativeUncaughtError(error, message, stack) { + var ev = new ErrorEvent("nativeuncaughterror", { + message: message !== undefined && message !== null ? String(message) : "", + error: error, + cancelable: true + }); + globalTarget.dispatchEvent(ev); + return ev.defaultPrevented; + } + + return [dispatchErrorEvent, dispatchUnhandledRejection, dispatchRejectionHandled, dispatchNativeUncaughtError]; + }) + )js"; + + auto isolate = context->GetIsolate(); + auto runtime = GetRuntimeOrNull(isolate); + if (runtime == nullptr) { + throw NativeScriptException("ErrorEvents::Init: no runtime for isolate"); + } + if (runtime->GlobalEventTarget().IsEmpty()) { + throw NativeScriptException("ErrorEvents::Init: Events::Init must run first"); + } + Local globalTarget = runtime->GlobalEventTarget().Get(isolate); + + Local