RFC: Unified, web-compliant error handling — WinterTC-aligned events, nativeuncaughterror, stackTrace legacy status, and runtime-free error UI #53
edusperoni
started this conversation in
1. Ideas & Discussions
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: Unified, web-compliant error handling — WinterTC-aligned events,
nativeuncaughterror,stackTracelegacy status, and runtime-free error UISummary
NativeScript 9.1 unifies error handling across the iOS and Android runtimes around the web's model, on top of a standards-aligned event foundation: spec-shaped
Event/EventTargetas general-purpose global primitives,ErrorEvent/PromiseRejectionEvent,reportError(), and cancelableerror/unhandledrejection(+rejectionhandled) events onglobalThis— the same surface WinterTC (TC55, Minimum Common Web Platform API) standardizes for non-browser runtimes — plus the native-interop extensions the web has no vocabulary for: a shareduncaughtErrorPolicyconfig,interop.escapeException, and native exceptions round-tripping with their original identity.This RFC (a) documents that foundation as the shared contract both runtime PRs implement, and (b) proposes the three remaining contract decisions before 9.1 freezes them:
nativeuncaughterrorevent (landed on Android in17cd448; proposed for iOS) — a last-chance, cancelable event fired when the platform's uncaught-exception handler sees a native-layer death the JS pipeline never reported.preventDefault()skips the platform's default behavior (dev error UI + the default killing handler) on a best-effort basis. This completes the invariant thaterror/unhandledrejectionfire only while the failure is still containable.error.stackTraceas hook-era legacy — canonical stack sources in the events contract areerror.stack(JS) anderror.nativeException(native); the injectedstackTraceproperty stays for compatibility but does not become part of the forever-spec.errorlistener it installs in debug builds.Motivation
The 9.1 work gives both runtimes an identical, JS-observable error contract. Three inconsistencies remain, and each is a one-way door once apps depend on it:
stackTraceis ambiguous. Both runtimes inject a combinedstackTracestring property onto the error object before reporting. It predates the event model (it was the only context the__onUncaughtErrorhooks got), it duplicateserror.stack, and it mutates a user-owned object — the pattern web specs deliberately avoid (that's whyErrorEventcarriesmessage/filename/linenoinstead of decorating the error). If we say nothing, it gets grandfathered into the unified contract, format and all.errorevent, that responsibility can move to core/userland — where it can be themed, replaced, or removed — and the runtimes shrink to reporting only.The foundation: WinterTC-aligned events (shipping in 9.1)
Both runtime PRs implement the same event substrate, byte-compatible in JS-observable behavior. This section is descriptive — it is what ios#409 and android#1985 already do — and is included so the proposals below can be evaluated against the full contract.
Standard surface (the part that overlaps WinterTC's Minimum Common Web Platform API — the interoperability target shared by Node, Deno, Bun, workerd and friends):
EventandEventTargetas general-purpose global constructors — registration order,once/captureoptions,stopPropagation/stopImmediatePropagation,handleEventobjects,dispatchEventreturning!defaultPrevented. Usable for app/library eventing today, and the substrate for future primitives (AbortController/AbortSignal, fetch-shaped APIs) without new plumbing.globalThisas an event target (addEventListener/removeEventListener/dispatchEvent), in the same role asWorkerGlobalScope— nowindowrequired.ErrorEventandPromiseRejectionEventconstructors;reportError()routing a caught-but-fatal error through the exact pipeline of an uncaught one.errorandunhandledrejectionare cancelable;preventDefault()= fully handled (no legacy hooks, no fatal log, no dev UI, no policy throw).rejectionhandledfires as a task, carrying the original reason. Unhandled rejections are tracked per isolate and reported once per event-loop turn; a handler attached before the turn ends cancels the report.globalThis.dispatchEvent. A throwing listener never stops the remaining listeners and never recursively dispatches.NativeScript extensions (the part the web has no vocabulary for):
uncaughtErrorPolicy: "report" | "throw"— one config key, same values, same default, on both platforms.interop.escapeException(x)— the portable per-call escape hatch converting a JS throw into a real native throw at the boundary, preserving original native-exception identity on rethrow.error.nativeExceptionon errors surfaced from native throws, and JS stack traces carried on every native exception the machinery produces (documenteduserInfokeys +tns_javascriptStackTraceon iOS; synthesized real stack frames on Android).__onUncaughtError/__onDiscardedErrorhooks kept working (invoked only when no listener prevented) so@nativescript/coreneeds zero changes at 9.1.The proposals below decide the three pieces of this contract that are still open.
Guide-level explanation
1.
nativeuncaughterrorStatus: landed on Android (
17cd448); proposed here for iOS and for ratification as the shared spec.A global event dispatched synchronously from the platform's last-chance handler (Android
Thread.UncaughtExceptionHandler; iOSNSUncaughtExceptionHandler) for everything that reaches it un-marked — pure-native crashes, uncaughtinterop.escapeExceptionforwards, bootstrap failures. The handler classifies nothing: un-reported-by-the-JS-pipeline is the entire rule.Key semantics (as landed on Android):
errorevent recipe.e.erroris anErrorwhosemessage/namecome from the native exception and whosenativeExceptionis the wrapped original (Throwableon Android,NSExceptionon iOS). One crash-SDK code path serves both events.preventDefault()skips the default behavior, best-effort. The default is the platform's fatal path: the debug error UI plus handing the exception to the default (process-killing) handler. Preventing is meaningful for background-thread crashes (the thread dies, the app lives); it cannot resurrect a crashed main thread, and the docs say so plainly. This is a different, weaker promise thanerror'spreventDefault()— which is exactly why native deaths get their own event instead of overloadingerror.error/unhandledrejectionnow fire only while the failure is fully containable (preventDefault()is honest); everything past that line isnativeuncaughterror. This also removes a footgun: a genericerrorlistener can no longer accidentally spare a process whose crashed thread is already gone.uncaughtErrorPolicy: "throw"rethrows carry a reported-to-JS mark) short-circuit the handler — no second event, no second hook. Uncaught escapes are un-marked by design (escapes bypass reporting), so crash SDKs get their last look at those here.__onUncaughtErrorhook still fires for unprevented native crashes (that is whatApplication.uncaughtErrorEventhas received for years).Open for iOS (see Unresolved questions): the reported-to-JS mark equivalent on
NSException, thread marshaling from the crashing thread, and how much of "skip the default handler" is achievable — afterNSUncaughtExceptionHandlerreturns, process death is generally unavoidable on iOS, sopreventDefault()there may only suppress the runtime-added behavior (dev UI, hook chain), not termination. The unified contract wording accounts for this: "skips the platform's default behavior; survival is best-effort and platform/thread-dependent."2.
error.stackTracebecomes documented legacyNo rename, no removal — reclassification:
e.error.stack/e.reason.stackfor the JS trace;e.error.nativeException(withcallStackSymbols/getStackTrace()) for the native trace.stackTracekeeps working exactly as today (populated before event dispatch on both platforms) but is documented as a compatibility detail of the__onUncaughtErrorhook era — deprecated together with the hooks, removed no earlier than whenever the hooks are..stack, it mutates a user-owned object (observable inJSON.stringify, collidable with library-owned properties), and keeping it out of the events contract frees both runtimes from freezing its exact composition format forever.ErrorEvent), where the platform owns the object it decorates. That is left as a follow-up, not part of 9.1.3. Error UI moves to core/userland
Today: the iOS runtime renders a UIKit modal and the Android runtime an error activity for unhandled errors in debug builds — hardcoded look, hardcoded behavior, duplicated per platform.
Proposed end state: the runtimes never draw UI. Core installs a default listener in debug builds:
Userland (or a devtools package) can replace it wholesale. Release-build behavior is governed purely by
uncaughtErrorPolicyand the app's own listeners.Migration: in 9.1 the runtime UI remains (behind the existing flags, e.g. iOS
showErrorDisplay) so nothing changes out of the box; core ships its listener-based overlay in a 9.x minor; NativeScript 10 deletes the runtime UI code paths and their flags.Reference-level details
Event/dispatch matrix after this RFC (identical on both platforms):
preventDefault()errorunhandledrejectionrejectionhandledinterop.escapeExceptionnativeuncaughterrorif it reaches the last-chance handler uncaughtnativeuncaughterrornativeuncaughterrorordering: fired synchronously from the last-chance handler before the platform's default handling proceeds, on a best-effort basis (the runtime must acquire the JS engine safely from the crashing thread — cross-thread via the main-runtime fallback when the thread has no runtime; if it cannot, the event is skipped — never deadlock a dying process).isReportedToJs(); iOS needs theNSExceptionequivalent);nativeuncaughterrorfires only for exceptions without the mark. Escapes are deliberately un-marked.stackTracedocs move from the event-contract tables to the legacy-hooks section in both runtimes' documentation.Drawbacks
nativeuncaughterrorruns JS on a crashing process — even read-only listeners can fail halfway. Mitigated by best-effort semantics and by documenting that listeners must be minimal and synchronous.errorevent changes behavior for Android apps that relied on canceling them there; the containment ability survives viapreventDefault()onnativeuncaughterror, but listeners must be updated (release-notes item, landed in android#1985).Alternatives considered
errorfor native crashes everywhere (Android's status quo): rejected — makescancelablea lie on iOS, and portable code can't distinguish "JS bug" from "native crash".stackTrace(e.g.composedStackTrace): rejected — breaks shipped hook-era consumers (NativeScriptError.stackTraceis public core API) to fix an aesthetic problem; reclassification achieves the same spec hygiene for free.Adoption plan
stackTracereclassified in docs.nativeuncaughterror: landed on Android; iOS implementation to match this spec. Native crashes stop firingerroron Android (containment moves tonativeuncaughterror).errorfor native crashes;__on*hooks andstackTraceinjection enter final deprecation.Unresolved questions
nativeuncaughterror—NSUncaughtExceptionHandlerruns on the crashing thread: acquiring the isolate can block against a wedged runtime thread (proposal: try-lock with a short timeout, skip on failure); anNSExceptionreported-to-JS mark is needed (associated object, like the JS-stack attachment); and post-handler process death is unavoidable, sopreventDefault()on iOS suppresses runtime-added behavior only. Needs prototyping.reportError(interop.escapeException(x))dispatch first or throw immediately? Today it dispatches (JS-side) before the native tail sees the brand — the one corner where "an explicit escape always wins" has an asterisk. Both platforms should pick one answer here.preventDefault()by default in debug? If yes,uncaughtErrorPolicy: "throw"is inert in debug builds — probably desirable, but worth stating explicitly.nativeuncaughterror— Android answers this with current-thread-runtime, falling back to the main runtime for runtime-less threads. Should a crash on a worker's own thread fire on the worker global, the main global, or both? (JS-originated worker errors already have a defined chain; this is only about native deaths.)All reactions