Skip to content

Debuggability: fetch/XHR error fidelity, unhandled-rejection handler, and AbortSignal (#196)#200

Draft
bkaradzic-microsoft wants to merge 4 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:hop2-fetch-error-fidelity
Draft

Debuggability: fetch/XHR error fidelity, unhandled-rejection handler, and AbortSignal (#196)#200
bkaradzic-microsoft wants to merge 4 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:hop2-fetch-error-fidelity

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Jun 16, 2026

Copy link
Copy Markdown
Member

Implements the diagnosability work tracked in #196 — making fetch()/XMLHttpRequest transport failures, unhandled promise rejections, and aborts observable to the embedding app instead of opaque. Originally split across #200/#201/#202; consolidated here into one PR since it's a single feature in one repo (and hops 2 and 4 both touch Fetch.cpp).

Each hop is a self-contained commit. Requires the UrlLib transport-error accessors merged in BabylonJS/UrlLib#33 (this PR bumps the UrlLib pin to e86ffb3).


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.
  • 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 3 — unhandled promise rejection → host handler

A fire-and-forget rejected promise (un-awaited fetch() failure, or a throw in a .then with no .catch) vanished silently: AppRuntime::Dispatch only catches synchronous throws.

  • Opt-in AppRuntime::Options::EnableUnhandledPromiseRejectionTracking (default false). When set, unhandled rejections route into the existing UnhandledExceptionHandler (the Sentry/Bugsnag hook).
  • V8: Isolate::SetPromiseRejectCallback, with reporting deferred via Dispatch so a synchronously-handled rejection 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 documented no-op. JSC/JSI are follow-ups.

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 (203 mocha tests + native tests) and Win32 / V8; CI is green across the full matrix (Chakra/V8/JSC/JSI × Win32/UWP/Android/iOS/macOS/Linux, incl. sanitizers).

New tests: fetch TypeError+cause shape (refused / missing-asset), XHR errorCode/errorDetail, the modern AbortSignal API, fetch AbortError (pre-aborted + in-flight), and native unhandled-rejection tests (V8-gated; skipped on engines without a tracker).

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() and JSC/JSI rejection trackers are also follow-ups.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Previously every fetch()/XMLHttpRequest transport failure (DNS failure,
connection refused, TLS failure, missing local asset, ...) was flattened to a
single opaque signal -- fetch rejected with `std::runtime_error{"fetch: network
request failed"}` (a plain Error with no cause/code/url and a stack snapshotted
in a worker-thread scheduler tick, i.e. zero user frames), and XHR exposed
nothing beyond status 0. Field crash reports could not tell the failure classes
apart. This is hop 2 of BabylonJS#196, now unblocked by the
UrlLib transport-error accessors (BabylonJS/UrlLib#33).

fetch:
- Reject with a TypeError whose message is the stable string "fetch failed"
  (kept constant so crash-report grouping stays intact), carrying the variable
  detail on `error.cause = { code, detail, url, status }` -- the Node/undici
  shape -- rather than as non-standard top-level own-properties.
- `cause.code`/`cause.detail` come from UrlLib's ErrorSymbol()/ErrorString();
  they are present on backends that populate them (Apple, Linux) and omitted on
  those that do not yet (Windows, Android), while the standard observable shape
  (TypeError + stable message + cause.url/status) is preserved everywhere. This
  is a strict, additive superset of the spec: spec-conformant code sees only a
  TypeError, as in a browser; BN-aware code can distinguish failure classes.
- Capture the JS call-site stack synchronously inside fetch(), before SendAsync()
  hands off to a worker thread, and reattach it to the rejection so crash reports
  attribute the failing call. Uses the global JS Error constructor so engines
  that materialize .stack from that path (incl. Chakra) capture live frames.

XMLHttpRequest:
- Add non-standard, additive read-only `errorCode`/`errorDetail` accessors that
  expose the same UrlLib detail; the standard error event + status===0 behavior
  is unchanged.

Also bump the UrlLib pin to e86ffb3 (BabylonJS#33) so the accessors are available, add
JS tests for the rejection shape and XHR diagnostics, and document both in the
polyfill Readmes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 16, 2026 19:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves diagnosability of transport-level failures in the fetch() and XMLHttpRequest polyfills by preserving structured error detail (from UrlLib) and capturing a synchronous call-site stack before work hops to a worker thread.

Changes:

  • fetch() now rejects transport failures as TypeError("fetch failed") with a structured error.cause object and an optionally reattached call-site stack.
  • XMLHttpRequest gains additive errorCode / errorDetail accessors for transport-failure diagnostics.
  • UrlLib pin is bumped to include transport-error accessor support; unit tests and READMEs are updated accordingly.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Tests/UnitTests/Scripts/tests.ts Adds unit tests asserting the new fetch TypeError + cause shape and XHR errorCode/errorDetail behavior.
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h Declares new errorCode / errorDetail accessors.
Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp Implements the new diagnostics accessors and wires them as instance properties.
Polyfills/XMLHttpRequest/Readme.md Documents the new XHR transport-error diagnostics.
Polyfills/Fetch/Source/Fetch.cpp Implements fetch failed TypeError rejections with cause and synchronous stack capture/reattachment.
Polyfills/Fetch/Readme.md Documents the new fetch transport-failure rejection shape and stack behavior.
CMakeLists.txt Updates UrlLib dependency pin to the commit providing transport-error accessors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Polyfills/Fetch/Source/Fetch.cpp
Comment thread Polyfills/XMLHttpRequest/Readme.md
@bkaradzic-microsoft bkaradzic-microsoft marked this pull request as draft June 16, 2026 19:58
…or event

- CaptureCallSiteStack: detect the global Error constructor via IsUndefined()/IsNull() instead of IsFunction(), matching the repo pattern (some JavaScriptCore/JSI builds report constructors as typeof 'object', which would silently disable synchronous stack capture).

- XMLHttpRequest Readme: add the 'error' event to the supported-events list, since the implementation and tests rely on it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI added 2 commits June 16, 2026 16:18
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>
fetch() previously ignored init.signal entirely (it passed
arcana::cancellation::none() to both continuations), so AbortController could
never cancel a request or reject with AbortError, and the AbortSignal polyfill
predated the modern spec. This is hop 4 of BabylonJS#196.

AbortSignal:
- Add `reason` (read-only) and `throwIfAborted()`.
- Add static `AbortSignal.abort(reason?)`.
- Make `aborted` read-only (remove the setter).
- Abort(reason) now records the reason, defaulting to an AbortError (an Error
  whose name is "AbortError", as there is no DOMException polyfill).
- AbortController.abort(reason) forwards the reason to the signal.

fetch:
- Honor init.signal via its JS interface (so fetch stays decoupled from the
  AbortController C++ types): an already-aborted signal rejects synchronously
  with the signal's reason; otherwise an "abort" listener cancels the transport
  (UrlRequest::Abort()) and the completion continuation rejects with the
  signal's reason (an AbortError) instead of a network-error TypeError. The
  listener is torn down when the request settles, breaking the ownership cycle.

Transport cancellation is effective on backends where UrlRequest::Abort() is
implemented (Windows today; the curl/NSURLSession backends are a UrlLib
follow-up noted in BabylonJS#196). AbortSignal.timeout() is left as a follow-up.

Adds JS tests for the modern AbortSignal API and for fetch rejecting with an
AbortError both pre-aborted and in-flight (validated on Win32, where the UrlLib
backend honors Abort()).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bkaradzic-microsoft bkaradzic-microsoft changed the title fetch/XHR: surface transport-failure detail (TypeError + cause + sync stack) — hop 2 of #196 Debuggability: fetch/XHR error fidelity, unhandled-rejection handler, and AbortSignal (#196) Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants