Debuggability: fetch/XHR error fidelity, unhandled-rejection handler, and AbortSignal (#196)#200
Draft
bkaradzic-microsoft wants to merge 4 commits into
Draft
Conversation
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>
Contributor
There was a problem hiding this comment.
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 asTypeError("fetch failed")with a structurederror.causeobject and an optionally reattached call-site stack.XMLHttpRequestgains additiveerrorCode/errorDetailaccessors 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.
…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>
This was referenced Jun 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the diagnosability work tracked in #196 — making
fetch()/XMLHttpRequesttransport 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 touchFetch.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 plainError{"fetch: network request failed"}(nocause/code/url, stack snapshotted in a worker tick = zero user frames); XHR exposed nothing beyondstatus === 0.fetchrejects with aTypeError(stable message"fetch failed"), detail nested undererror.cause = {code, detail, url, status}(Node/undici shape, not top-level own-properties).cause.code/cause.detailcome 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.fetch()(via the globalErrorconstructor, which materializes frames even on Chakra) and reattached to the rejection.XMLHttpRequestgains additive read-onlyerrorCode/errorDetailaccessors; its standarderrorevent +status === 0behavior is unchanged.Hop 3 — unhandled promise rejection → host handler
A fire-and-forget rejected promise (un-awaited
fetch()failure, or a throw in a.thenwith no.catch) vanished silently:AppRuntime::Dispatchonly catches synchronous throws.AppRuntime::Options::EnableUnhandledPromiseRejectionTracking(defaultfalse). When set, unhandled rejections route into the existingUnhandledExceptionHandler(the Sentry/Bugsnag hook).Isolate::SetPromiseRejectCallback, with reporting deferred viaDispatchso a synchronously-handled rejection is not reported (Node-like).JsSetHostPromiseRejectionTrackeris ChakraCore-only), so the option is a documented no-op. JSC/JSI are follow-ups.Hop 4 —
init.signal+ modern AbortSignalfetch()ignoredinit.signalentirely (arcana::cancellation::none()), and theAbortSignalpolyfill predated the modern spec.AbortSignal:reason(read-only),throwIfAborted(), staticAbortSignal.abort(reason?), read-onlyaborted, and a defaultAbortErrorreason (anErrorwhosenameis"AbortError"; noDOMExceptionpolyfill exists).AbortController.abort(reason?)forwards the reason.fetchhonorsinit.signalvia its JS interface: an already-aborted signal rejects synchronously; an in-flight abort cancels the transport (UrlRequest::Abort()) and rejects with the signal'sreason.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+causeshape (refused / missing-asset), XHRerrorCode/errorDetail, the modernAbortSignalAPI, fetchAbortError(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