Skip to content

Commit 8c8e32f

Browse files
Diagnosability: fetch/XHR error fidelity and modern AbortSignal (#196) (#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>
1 parent 5a6d490 commit 8c8e32f

11 files changed

Lines changed: 449 additions & 19 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ FetchContent_Declare(llhttp
4949
EXCLUDE_FROM_ALL)
5050
FetchContent_Declare(UrlLib
5151
GIT_REPOSITORY https://github.com/BabylonJS/UrlLib.git
52-
GIT_TAG 74985214bd4f83a4906b2c62134ac2f9ab89e1ae
52+
GIT_TAG e86ffb34e77092266145497681efc74e0a920ffe
5353
EXCLUDE_FROM_ALL)
5454
# --------------------------------------------------
5555

Polyfills/AbortController/Readme.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
# AbortController
22
Implements parts of [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/) and [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Provides a way to trigger the abort signal. *Work In Progress*
33

4+
Supported on `AbortSignal`:
5+
* `aborted` (read-only) and `reason`
6+
* [`throwIfAborted()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted)
7+
* static [`AbortSignal.abort(reason?)`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort)
8+
* `onabort`, `addEventListener("abort", ...)`, `removeEventListener`
9+
10+
`AbortController.abort(reason?)` forwards the reason to the signal; when no reason is given the
11+
signal's `reason` defaults to an `AbortError` (an `Error` whose `name` is `"AbortError"`, since
12+
there is no `DOMException` polyfill). `fetch()` honors an `AbortSignal` passed via `init.signal`:
13+
an already-aborted signal rejects the promise synchronously, and an in-flight abort cancels the
14+
transport and rejects with the signal's `reason`. (Transport cancellation is effective on backends
15+
where `UrlLib::UrlRequest::Abort()` is implemented.)
16+
417
Currently not implemented:
5-
* [`ThrowIfAborted`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted)
618
* [`Timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout)
7-
* [`Abort`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort)
819

920
Both the AbortController and AbortSignal polyfills are initialized inside AbortController's initialize method:
1021
```c++

Polyfills/AbortController/Source/AbortController.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ namespace Babylon::Polyfills::Internal
2525
return m_signal.Value();
2626
}
2727

28-
void AbortController::Abort(const Napi::CallbackInfo&)
28+
void AbortController::Abort(const Napi::CallbackInfo& info)
2929
{
3030
AbortSignal* sig = AbortSignal::Unwrap(m_signal.Value());
31-
31+
3232
assert(sig != nullptr);
33-
sig->Abort();
33+
sig->Abort(info.Length() > 0 ? info[0] : info.Env().Undefined());
3434
}
3535

3636
AbortController::AbortController(const Napi::CallbackInfo& info)

Polyfills/AbortController/Source/AbortSignal.cpp

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ namespace Babylon::Polyfills::Internal
1111
env,
1212
JS_ABORT_SIGNAL_CONSTRUCTOR_NAME,
1313
{
14-
InstanceAccessor("aborted", &AbortSignal::GetAborted, &AbortSignal::SetAborted),
14+
InstanceAccessor("aborted", &AbortSignal::GetAborted, nullptr),
15+
InstanceAccessor("reason", &AbortSignal::GetReason, nullptr),
1516
InstanceAccessor("onabort", &AbortSignal::GetOnAbort, &AbortSignal::SetOnAbort),
17+
InstanceMethod("throwIfAborted", &AbortSignal::ThrowIfAborted),
1618
InstanceMethod("addEventListener", &AbortSignal::AddEventListener),
1719
InstanceMethod("removeEventListener", &AbortSignal::RemoveEventListener),
20+
StaticMethod("abort", &AbortSignal::AbortStatic),
1821
});
1922

2023
env.Global().Set(JS_ABORT_SIGNAL_CONSTRUCTOR_NAME, func);
@@ -26,10 +29,30 @@ namespace Babylon::Polyfills::Internal
2629
{
2730
}
2831

29-
void AbortSignal::Abort()
32+
Napi::Value AbortSignal::CreateAbortError(Napi::Env env, const char* message)
3033
{
34+
// There is no DOMException polyfill, so represent the abort reason as an Error whose `name`
35+
// is "AbortError" -- the value web code checks (`err.name === "AbortError"`).
36+
Napi::Error error = Napi::Error::New(env, message);
37+
error.Set("name", Napi::String::New(env, "AbortError"));
38+
return error.Value();
39+
}
40+
41+
void AbortSignal::Abort(const Napi::Value& reason)
42+
{
43+
if (m_aborted)
44+
{
45+
return;
46+
}
47+
3148
m_aborted = true;
3249

50+
Napi::Env env = Env();
51+
const Napi::Value resolvedReason = (reason.IsUndefined() || reason.IsEmpty())
52+
? CreateAbortError(env, "The operation was aborted.")
53+
: reason;
54+
m_reason = Napi::Persistent(resolvedReason);
55+
3356
auto onabort = m_onabort.Value();
3457
if (!onabort.IsNull() && !onabort.IsUndefined())
3558
{
@@ -39,14 +62,36 @@ namespace Babylon::Polyfills::Internal
3962
RaiseEvent("abort");
4063
}
4164

65+
Napi::Value AbortSignal::AbortStatic(const Napi::CallbackInfo& info)
66+
{
67+
Napi::Env env = info.Env();
68+
Napi::Object signalObject = env.Global().Get(JS_ABORT_SIGNAL_CONSTRUCTOR_NAME).As<Napi::Function>().New({});
69+
AbortSignal* signal = AbortSignal::Unwrap(signalObject);
70+
signal->Abort(info.Length() > 0 ? info[0] : env.Undefined());
71+
return signalObject;
72+
}
73+
4274
Napi::Value AbortSignal::GetAborted(const Napi::CallbackInfo&)
4375
{
4476
return Napi::Value::From(Env(), m_aborted);
4577
}
4678

47-
void AbortSignal::SetAborted(const Napi::CallbackInfo&, const Napi::Value& value)
79+
Napi::Value AbortSignal::GetReason(const Napi::CallbackInfo&)
4880
{
49-
m_aborted = value.As<Napi::Boolean>();
81+
if (m_reason.IsEmpty())
82+
{
83+
return Env().Undefined();
84+
}
85+
86+
return m_reason.Value();
87+
}
88+
89+
void AbortSignal::ThrowIfAborted(const Napi::CallbackInfo& info)
90+
{
91+
if (m_aborted)
92+
{
93+
throw Napi::Error{info.Env(), GetReason(info)};
94+
}
5095
}
5196

5297
Napi::Value AbortSignal::GetOnAbort(const Napi::CallbackInfo&)

Polyfills/AbortController/Source/AbortSignal.h

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,23 @@ namespace Babylon::Polyfills::Internal
1717
static void Initialize(Napi::Env env);
1818
explicit AbortSignal(const Napi::CallbackInfo& info);
1919

20-
void Abort();
20+
// Transition the signal to the aborted state with the given reason (undefined -> a default
21+
// "AbortError"), firing onabort and any "abort" listeners. No-op if already aborted.
22+
void Abort(const Napi::Value& reason);
23+
24+
// Build the default abort reason: an Error whose name is "AbortError" (there is no
25+
// DOMException polyfill), matching what the platform uses when abort() is called with no
26+
// reason and what fetch() rejects with on abort.
27+
static Napi::Value CreateAbortError(Napi::Env env, const char* message);
2128

2229
private:
2330
Napi::Value GetAborted(const Napi::CallbackInfo& info);
24-
void SetAborted(const Napi::CallbackInfo&, const Napi::Value& value);
31+
32+
Napi::Value GetReason(const Napi::CallbackInfo& info);
33+
void ThrowIfAborted(const Napi::CallbackInfo& info);
34+
35+
// AbortSignal.abort(reason?) -- returns an AbortSignal already in the aborted state.
36+
static Napi::Value AbortStatic(const Napi::CallbackInfo& info);
2537

2638
Napi::Value GetOnAbort(const Napi::CallbackInfo& info);
2739
void SetOnAbort(const Napi::CallbackInfo&, const Napi::Value& value);
@@ -33,6 +45,7 @@ namespace Babylon::Polyfills::Internal
3345
std::unordered_map<std::string, std::vector<Napi::FunctionReference>> m_eventHandlerRefs;
3446

3547
Napi::FunctionReference m_onabort;
48+
Napi::Reference<Napi::Value> m_reason;
3649
bool m_aborted = false;
3750
};
3851
}

Polyfills/Fetch/Readme.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,36 @@ Like `XMLHttpRequest`, `fetch()` supports loading local resources:
2828
* Only `GET` and `POST` methods are supported (a `UrlLib` limitation shared with `XMLHttpRequest`).
2929
* Only string request bodies are supported.
3030
* Consistent with the fetch spec, the promise rejects only on transport-level failures. A completed request with a non-`2xx` status (e.g. `404`) still resolves, with `response.ok === false`.
31+
32+
## Transport-failure rejections
33+
On a transport-level failure (DNS failure, connection refused, TLS failure, missing local
34+
asset, ...) the promise rejects with a **`TypeError`** whose `message` is the stable string
35+
`"fetch failed"`. The message is intentionally constant so crash-report grouping stays intact;
36+
the variable detail is carried on `error.cause` (the Node/undici shape), never spread across the
37+
message:
38+
39+
```js
40+
try {
41+
await fetch("https://does-not-resolve.invalid/");
42+
} catch (error) {
43+
error.message; // "fetch failed" (stable)
44+
error.cause.code; // stable token, e.g. "CURLE_COULDNT_RESOLVE_HOST" (where available)
45+
error.cause.detail; // full normalized UrlLib string (where available)
46+
error.cause.url; // the requested URL
47+
error.cause.status; // 0 for a transport failure
48+
}
49+
```
50+
51+
`error.cause.code` / `error.cause.detail` come from `UrlLib`'s normalized transport-error
52+
accessors and are present on the backends that populate them (Apple, Linux); on backends that do
53+
not yet (Windows, Android) they are omitted while the standard observable shape (a `TypeError`
54+
with the stable message, plus `cause.url` / `cause.status`) is preserved. This is a deliberate,
55+
strictly-additive superset of the spec: spec-conformant code only sees a `TypeError`, exactly as
56+
in a browser, while BN-aware diagnostic code can read `cause` to distinguish a DNS failure from a
57+
refused connection or a missing local asset.
58+
59+
The rejection's `stack` is captured synchronously at the `fetch()` call site (before the request
60+
is handed to a worker thread), so crash reports can attribute the failing call rather than an
61+
empty scheduler tick. (Engines that only materialize `.stack` when an error is thrown may omit
62+
the frames.)
63+

0 commit comments

Comments
 (0)