Diagnosability: route unhandled promise rejections to the host handler (#196) - #204
Diagnosability: route unhandled promise rejections to the host handler (#196)#204bkaradzic-microsoft wants to merge 4 commits into
Conversation
A fire-and-forget rejected promise (an un-awaited fetch() that fails, or a throw inside a .then with no .catch) previously vanished silently: AppRuntime::Dispatch only catches synchronous Napi::Error throws, and no engine had a promise-rejection tracker, so the embedder's UnhandledExceptionHandler never fired and the process exited 0. - AppRuntime::Options gains opt-in EnableUnhandledPromiseRejectionTracking (default false = no behavior change). When set, unhandled rejections route into the existing UnhandledExceptionHandler. - AppRuntime::OnUnhandledPromiseRejection(const Napi::Error&) forwards to the handler; the napi_value -> Napi::Error wrapping is done per-engine (the JSI shim's napi.h has no Napi::Value/Error(napi_env, napi_value) constructor, so shared code must not do it). - V8: Isolate::SetPromiseRejectCallback, with reporting deferred via Dispatch so a rejection handled synchronously in the same turn is not reported (Node-like). - Chakra: the OS EdgeMode runtime exposes no host promise-rejection tracker (JsSetHostPromiseRejectionTracker is ChakraCore-only), so the option is a no-op here. - Native tests (skipped on non-V8 backends, including the Android JNI target which now defines JSRUNTIMEHOST_NAPI_ENGINE): a fire-and-forget rejection reaches the handler; a synchronously-handled rejection does not. JavaScriptCore and JSI trackers are follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tness
Responds to the review on the unhandled-promise-rejection handler:
- Implement the JavaScriptCore backend in this PR (no longer a follow-up).
AppRuntime_JavaScriptCore.cpp registers a JS callback via
JSGlobalContextSetUnhandledRejectionCallback (forward-declared SPI, guarded
with __builtin_available). JSC fires it at the microtask checkpoint only for
still-unhandled rejections, so the adapter is a thin direct report with no
candidate bookkeeping.
- Factor out the engine-agnostic bookkeeping into a shared header,
AppRuntime_PromiseRejection.h: ToError() plus a PromiseRejectionTracker<>
template that collects no-handler rejections, drops them on handler-added, and
reports survivors at end-of-turn. Included only by the V8/JSC TUs (the JSI
napi.h shim lacks the napi_value -> Napi::Value bridge ToError needs).
- Always track unhandled rejections (routed to UnhandledExceptionHandler):
removed the opt-in Options::EnableUnhandledPromiseRejectionTracking flag.
- V8 robustness fixes:
* Drain candidates into a local (std::move) before reporting, so a host
handler that synchronously rejects another promise cannot invalidate the
iterator mid-flush.
* Match handler-added events by promise object identity instead of
v8::Object::GetIdentityHash (which is not unique and could drop rejections).
- Tests: replace the runtime JSRUNTIMEHOST_NAPI_ENGINE string compare with a
compile-time gate. CMake now emits JSRUNTIMEHOST_NAPI_ENGINE_<engine> (e.g.
_V8, _JavaScriptCore) for both the desktop and Android UnitTests targets, and
the rejection tests compile their body only on supported engines.
- Consolidate the coverage documentation onto AppRuntime.h
(OnUnhandledPromiseRejection): V8 and JavaScriptCore supported; Chakra
(in-box/EdgeMode) and JSI no-op. Trim the scattered Chakra note accordingly.
Validated on Win32: V8 Release -- both AppRuntime rejection tests pass and
JavaScript.All stays green (203 passing) with always-on tracking; Chakra Debug
compiles and the rejection tests skip via the compile-time gate.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The macOS CI matrix built fine, but the Linux JSC job (WebKitGTK via gcc) failed: JSGlobalContextSetUnhandledRejectionCallback is an Apple SPI absent from WebKitGTK, and __builtin_available / its iOS/macOS platform names are Clang/Apple-only, so the unguarded registration didn't compile under gcc. - AppRuntime_JavaScriptCore.cpp: wrap the rejection-callback machinery (forward declaration, thread_local context, callback, and registration) in #if __APPLE__. On non-Apple JSC (WebKitGTK/Linux, Android JSC) tracking is now a documented no-op, consistent with the existing #if __APPLE__ guard around JSGlobalContextSetInspectable. - Shared.cpp: tighten the compile-time gate to V8 || (JavaScriptCore && __APPLE__), so the rejection tests skip on non-Apple JSC where the hook is unavailable (otherwise they would hang waiting for a report). - AppRuntime.h: note that JSC support is Apple-only (WebKitGTK JSC is a no-op). Re-validated on Win32 V8 Release: AppRuntime rejection tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#200) Implements the fetch/XHR diagnosability work tracked in #196 — making transport failures observable to the embedding app instead of opaque — plus modern `AbortSignal` support. Requires the UrlLib transport-error accessors merged in BabylonJS/UrlLib#33 (this PR bumps the UrlLib pin to `e86ffb3`). > **Note:** the unhandled-promise-rejection hop that was originally part of this PR has been **split out into #204** for its own design discussion (it's engine-conditional — V8/Apple JSC only — and needs an `#ifdef`-free rewrite). Everything remaining here is uniform across all JS engines and carries no engine/platform preprocessor guards. Each hop is a self-contained commit. --- ### Hop 2 — fetch/XHR transport-error fidelity Previously every transport failure (DNS failure, connection refused, TLS rejection, missing `app:///` asset) was flattened: `fetch()` rejected with a plain `Error{"fetch: network request failed"}` (no `cause`/`code`/`url`, stack snapshotted in a worker tick = zero user frames); XHR exposed nothing beyond `status === 0`. - `fetch` rejects with a **`TypeError`** (stable message `"fetch failed"`), detail nested under **`error.cause = {code, detail, url, status}`** (Node/undici shape, not top-level own-properties). - `cause.code`/`cause.detail` come from UrlLib; present on Apple/Linux, omitted on Windows/Android while the standard shape (`TypeError` + stable message + `cause.url/status`) is preserved everywhere — a strict, additive superset of the spec. (This variance is a UrlLib HTTP-backend difference, not a JS-engine one.) - The JS call-site **stack is captured synchronously** inside `fetch()` (via the global `Error` constructor, which materializes frames even on Chakra) and reattached to the rejection. - `XMLHttpRequest` gains additive read-only **`errorCode`/`errorDetail`** accessors; its standard `error` event + `status === 0` behavior is unchanged. ### Hop 4 — `init.signal` + modern AbortSignal `fetch()` ignored `init.signal` entirely (`arcana::cancellation::none()`), and the `AbortSignal` polyfill predated the modern spec. - `AbortSignal`: `reason` (read-only), `throwIfAborted()`, static `AbortSignal.abort(reason?)`, read-only `aborted`, and a default **`AbortError`** reason (an `Error` whose `name` is `"AbortError"`; no `DOMException` polyfill exists). `AbortController.abort(reason?)` forwards the reason. - `fetch` honors `init.signal` via its JS interface: an already-aborted signal rejects synchronously; an in-flight abort cancels the transport (`UrlRequest::Abort()`) and rejects with the signal's `reason`. --- ### Validation Built and ran the UnitTests suite on **Win32 / Chakra** and **Win32 / V8**; CI is green across the full matrix (Chakra/V8/JSC/JSI × Win32/UWP/Android/iOS/macOS/Linux, incl. sanitizers). New tests (all engine-agnostic): fetch `TypeError`+`cause` shape (refused / missing-asset), XHR `errorCode`/`errorDetail`, the modern `AbortSignal` API, and fetch `AbortError` (pre-aborted + in-flight). ### Cross-repo follow-up `UrlRequest::Abort()` only cancels the Windows backend today — making it interrupt the curl/NSURLSession transports is a separate UrlLib change (noted in #196). `AbortSignal.timeout()` is also a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Branimir Karadžić (via Copilot) <223556219+Copilot@users.noreply.github.com>
|
Strong preference for always-on where supported + a loud one-time no-op warning elsewhere, with the capability query promoted to public API rather than test-only. Opt-in safety nets don't get enabled until after the incident, and holding for Chakra means holding forever — +1 to the One design ask: route rejections through an internal observed/handled seam instead of straight into |
There was a problem hiding this comment.
Pull request overview
Routes unhandled JavaScript Promise rejections into the existing AppRuntime::Options::UnhandledExceptionHandler (where supported by the underlying JS engine) to improve diagnosability of fire-and-forget async failures, and adds unit tests to validate the behavior.
Changes:
- Add engine-side unhandled promise rejection hooks for V8 and Apple JavaScriptCore, forwarding to
AppRuntime::OnUnhandledPromiseRejection. - Introduce
AppRuntime_PromiseRejection.hwith shared rejection bookkeeping/wrapping helpers. - Add engine-gated unit tests and CMake compile definitions to enable per-engine test selection.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/UnitTests/Shared/Shared.cpp | Adds unit tests for “unhandled rejection reaches handler” and “synchronously handled rejection does not”. |
| Tests/UnitTests/CMakeLists.txt | Defines engine macros (string + per-engine define) to enable compile-time test gating. |
| Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt | Mirrors engine macro compile definitions for the Android JNI unit test target. |
| Core/AppRuntime/Source/AppRuntime.cpp | Adds AppRuntime::OnUnhandledPromiseRejection forwarding to the host handler. |
| Core/AppRuntime/Source/AppRuntime_V8.cpp | Installs a V8 SetPromiseRejectCallback hook and defers reporting to end-of-turn via a tracker. |
| Core/AppRuntime/Source/AppRuntime_PromiseRejection.h | Adds shared helper to wrap rejection reasons and track/report candidates (V8). |
| Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp | Registers Apple JSC SPI unhandled-rejection callback (when available) and forwards to host handler. |
| Core/AppRuntime/Source/AppRuntime_Chakra.cpp | Documents that unhandled-rejection tracking is a no-op on in-box/EdgeMode Chakra. |
| Core/AppRuntime/Include/Babylon/AppRuntime.h | Documents and exposes OnUnhandledPromiseRejection for engine backends to call. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ToError treated any object as error-like, so a plain object such as {code: 42} produced an error with an empty message. Require a native Error or an object with a string message, stringify everything else, and never let the conversion (which runs script) throw out of an engine callback.
Guard the embedder handler call for the same reason: on JavaScriptCore it runs inside a JSC callback, and on V8 an escaping exception reaches Dispatch, whose catch-all aborts.
In the tests, fulfil the promise only once (UnhandledExceptionHandler covers every unhandled error, not just rejections), take the future before the eval, and drop the unused <string_view>.
|
Apologies for the slow reply on this one -- it sat while the split-out PRs ahead of it went through review. Taking the points in order. Always-on + loud no-op, and a public capability query. I agree, and the reasoning about opt-in safety nets matches what I have seen. The one thing I would push back on slightly is where the loudness goes: a one-time warning logged from the runtime is easy to miss in an embedder that has already routed logging somewhere, and it fires at a point where the embedder cannot do anything but read it. A public SupportsUnhandledRejectionTracking() is the part that actually turns the footgun from silent into checkable, because it lets the assert live in the embedder's telemetry init where a failure is actionable. So: public query as the primary mechanism, warning as the backstop for embedders that never call it. That is a superset of what you asked for, not a substitute, so I do not think we disagree. You are also right that holding for Chakra means holding forever. The The observed/handled seam. This is the most interesting ask and I had not thought about it in those terms. You are right that the end-of-turn deferral bookkeeping is the hard half of @bghgary -- before I build this out, one decision is yours rather than mine. This PR exists because you raised whether an engine-partial diagnosability feature should ship at all. The proposal above is always-on where supported, public capability query, loud one-time warning where not, which is a deliberate choice to ship partial coverage and make the gap visible rather than gate it behind an opt-in or hold it. If you would rather it be opt-in, that changes the shape of the API and I would rather find out now than after the rework. Could you settle that one? Separately, the four review comments on this PR are addressed in 207b97a -- the |
…escape UAF, BabylonJS#225 QuickJS throw) Takes upstream's escapable-handle-scope implementation (BabylonJS#223) over shotgun's earlier 111efc5 attempt. Upstream's is strictly more correct: it keys scopes by a monotonic counter rather than a position in handle_scope_stack (two scopes opened with no handle allocated between them share a position, so a position-derived token cannot tell them apart), holds the escaped handle beside the scope instead of inserting it into the middle of the stack (which shifted every entry above it and invalidated the recorded start of any still-open nested scope), and rejects out-of-LIFO closes rather than corrupting the stack. Shared.cpp is resolved hunk-by-hunk rather than with `git checkout --theirs`: the two quickjs files carry only the superseded 111efc5, but Shared.cpp also carries shotgun's unhandled-promise-rejection tests (still unlanded upstream as BabylonJS#204), which taking the whole file would have silently dropped. The merged file is exactly the union of both sides' test cases, 9 + 10 -> 12 with 7 shared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Split out of #200 so the universal fetch/XHR + AbortSignal work can land without being blocked on this feature's design discussion. Part of the diagnosability work tracked in #196.
What this does
A fire-and-forget rejected promise (an un-awaited
fetch()that rejects, or a throw in a.thenwith no.catch) currently vanishes silently —AppRuntime::Dispatchonly catches synchronous throws. This routes unhandled promise rejections into the existingUnhandledExceptionHandler(the Sentry/Bugsnag hook), matching the browserunhandledrejectionbehavior. Reporting is deferred to the end of the turn, so a rejection handled synchronously in the same turn (e.g.const p = Promise.reject(e); p.catch(...)) is not reported.Engine coverage (the reason this is split out)
Coverage depends on whether the underlying engine exposes a host promise-rejection hook:
Isolate::SetPromiseRejectCallbackJSGlobalContextSetUnhandledRejectionCallback(Apple SPI)JsSetHostPromiseRejectionTrackeris ChakraCore-onlyOpen design questions
@bghgary raised two concerns on #200 that this PR exists to resolve properly rather than rush:
Is an engine-partial diagnosability feature worth shipping? Today this is always-on where supported, silent no-op elsewhere. That's a footgun: an embedder wiring up telemetry gets async-rejection coverage on V8/Apple but silently nothing on Chakra — the default engine on the Win32/UWP path — with no signal that coverage is missing. Options to discuss:
Avoid
#ifdefs. The current implementation uses#if __APPLE__inside the shared JSC file and#if defined(<engine>)guards in the tests, which cuts against the repo convention of expressing engine/platform divergence via CMake-selected source files (AppRuntime_${ENGINE}+AppRuntime_${PLATFORM}). Planned rework before this merges:AppRuntime_${PLATFORM}mechanism) with a no-op on Unix/Linux JSC — instead of#if __APPLE__insideAppRuntime_JavaScriptCore.cpp.SupportsUnhandledRejectionTracking()) so the tests do a runtimeGTEST_SKIP()instead of a compile-time#if.Contents
Core/AppRuntime— V8 and Apple-JSC rejection callbacks routed throughOnUnhandledPromiseRejection→UnhandledExceptionHandler; Chakra/JSI documented no-ops. NewAppRuntime_PromiseRejection.hhelper.Tests/UnitTests/Shared/Shared.cpp: fire-and-forget rejection reaches the handler; a synchronously-handled rejection does not. Currently guarded to the supporting engines (to be converted to a runtime capability skip per the rework above).Status
Draft — opening for the design discussion above. The
#ifdef-free rework and the loud-no-op decision should land before this is marked ready.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com
Fixes #196