diff --git a/src/http/error.rs b/src/http/error.rs index b7edb71da82a..07c05d50c79f 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -327,6 +327,31 @@ impl bun_core::output::ErrName for Error { } } +impl Error { + /// Node.js / undici errno-style `err.code` string (falls back to `name()`). + #[allow(clippy::trivially_copy_pass_by_ref)] + pub fn errno_code(&self) -> &'static str { + match self { + Self::ConnectionRefused => "ECONNREFUSED", + Self::ConnectionClosed => "ECONNRESET", + Self::Timeout => "ETIMEDOUT", + Self::InvalidHTTPResponse => "HPE_INVALID_CHUNK_SIZE", + Self::ResponseHeadersTooLarge => "UND_ERR_HEADERS_OVERFLOW", + Self::InvalidContentLength => "UND_ERR_RES_CONTENT_LENGTH_MISMATCH", + Self::HTTP2ContentLengthMismatch | Self::HTTP3ContentLengthMismatch => { + "UND_ERR_RES_CONTENT_LENGTH_MISMATCH" + } + Self::Zlib(_) => "Z_DATA_ERROR", + Self::Picohttp(bun_picohttp::ParseResponseError::MalformedHttpResponse) => { + "HPE_INVALID_CONSTANT" + } + Self::Cert(e) => <&'static str>::from(e), + Self::Sys(e) => <&'static str>::from(e), + _ => self.name(), + } + } +} + impl From for Error { fn from(e: bun_zlib::ZlibError) -> Self { match e { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 97fbbf7b30fd..8a2f78cbb6fb 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2353,6 +2353,81 @@ JSC::EncodedJSValue JSGlobalObject__createOutOfMemoryError(JSC::JSGlobalObject* return JSValue::encode(exception); } +// Bare Error that snapshots the caller's stack for later transplant. +extern "C" JSC::EncodedJSValue Bun__captureCallerStackError(JSC::JSGlobalObject* globalObject) +{ + return JSC::JSValue::encode(JSC::createError(globalObject, "fetch"_s)); +} + +// TypeError('fetch failed'|'terminated') with `.cause`, `.code` mirrored from +// the cause, and `.stack` transplanted from `stackSourceValue` (the call-site +// Error captured when fetch() was invoked). +extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( + JSC::JSGlobalObject* globalObject, + JSC::EncodedJSValue causeValue, + JSC::EncodedJSValue stackSourceValue, + bool terminated) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto clientData = WebCore::clientData(vm); + + JSC::JSObject* result = JSC::createTypeError(globalObject, + terminated ? "terminated"_s : "fetch failed"_s); + + JSC::JSValue cause = JSC::JSValue::decode(causeValue); + if (cause && !cause.isEmpty() && !cause.isUndefined()) { + result->putDirect(vm, vm.propertyNames->cause, cause, JSC::PropertyAttribute::DontEnum | 0); + + if (auto* causeObj = cause.getObject()) { + JSC::JSValue code = causeObj->getDirect(vm, clientData->builtinNames().codePublicName()); + if (code && !code.isUndefined()) + result->putDirect(vm, clientData->builtinNames().codePublicName(), code, 0); + } + } + + // Transplant only for the pre-response rejection; body-stage errors get + // their stack from the body promise's async-attach path. + auto* destInstance = dynamicDowncast(result); + JSC::JSValue stackSrc = JSC::JSValue::decode(stackSourceValue); + if (auto* srcInstance = dynamicDowncast(stackSrc); srcInstance && destInstance && !terminated) { + if (auto* srcTrace = srcInstance->stackTrace(); srcTrace && !srcTrace->isEmpty()) { + // Copy: source may be shared across a cloned Response body. + WTF::Vector frames; + frames.appendVector(*srcTrace); + destInstance->setStackFrames(vm, WTF::move(frames)); + } else { + // GC may have materialized the source (finalizeUnconditionally + // clears the frame vector when a captured callee is collected): + // fall back to its `.stack` string with the header rewritten. + // Swallow a throwing prepareStackTrace; the header-only fallback + // below still runs. + srcInstance->materializeErrorInfoIfNeeded(vm); + if (scope.exception()) [[unlikely]] { + scope.clearExceptionExceptTermination(); + } else if (JSC::JSValue srcStack = srcInstance->getDirect(vm, vm.propertyNames->stack); srcStack && srcStack.isString()) { + auto str = asString(srcStack)->value(globalObject); + if (scope.exception()) [[unlikely]] { + scope.clearExceptionExceptTermination(); + } else { + size_t nl = str->find('\n'); + auto frames = nl == WTF::notFound ? WTF::emptyString() : str->substring(nl); + result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, makeString("TypeError: fetch failed"_s, frames)), JSC::PropertyAttribute::DontEnum | 0); + } + } + } + } + + if (destInstance && !terminated) { + auto* destTrace = destInstance->stackTrace(); + if ((!destTrace || destTrace->isEmpty()) && !result->getDirect(vm, vm.propertyNames->stack)) { + result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, String("TypeError: fetch failed"_s)), JSC::PropertyAttribute::DontEnum | 0); + } + } + + return JSC::JSValue::encode(result); +} + JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject) { SystemError err = *arg0; diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index d8905ec4115b..965e969295e2 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -644,6 +644,12 @@ impl From for bun_event_loop::ErasedJsError { } } +/// Bare `Error` that snapshots the current JS stack for later transplant. +#[inline] +pub fn capture_caller_stack_error(global: &JSGlobalObject) -> JSValue { + Bun__captureCallerStackError(global) +} + /// Converts `bun.JSError` → `std.Io.Writer.Error` for Console formatting paths. /// `Display` impls return `fmt::Error`; the JS exception, if any, remains on the VM. #[inline] @@ -1170,6 +1176,7 @@ unsafe extern "C" { global: &JSGlobalObject, code: u8, ) -> JSValue; + safe fn Bun__captureCallerStackError(global: &JSGlobalObject) -> JSValue; safe fn ZigString__toValueGC(this: &bun_core::ZigString, global: &JSGlobalObject) -> JSValue; // ZigString__toExternalValue: use the generated `cpp::` re-export (canonical signature). safe fn ZigString__toJSONObject(this: &bun_core::ZigString, global: &JSGlobalObject) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index d2402bc94dd5..e016986dc939 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -569,9 +569,27 @@ pub enum ValueError { /// error" to TypeError, so use this for fetch-layer rejections that /// callers feature-detect via `err instanceof TypeError`. TypeError(BunString), + /// `TypeError('fetch failed'|'terminated')` with `.cause`. `stack_source` + /// holds the `fetch()` call-site frames for transplant onto the TypeError. + FetchFailed { + cause: SystemError, + terminated: bool, + stack_source: jsc::strong::Optional, + }, JSValue(jsc::strong::Optional), } +// SAFETY: `JSGlobalObject` is opaque `UnsafeCell`, so `&JSGlobalObject` is +// ABI-identical to a non-null `JSGlobalObject*`; `JSValue` is `#[repr(C)]`. +unsafe extern "C" { + safe fn Bun__createFetchFailedTypeError( + global: &JSGlobalObject, + cause: JSValue, + stack_source: JSValue, + terminated: bool, + ) -> JSValue; +} + impl ValueError { // Not a clean Drop — resets self to safe-empty in place. Renamed from `deinit` // per PORTING.md (never expose `pub fn deinit(&mut self)`). @@ -581,6 +599,7 @@ impl ValueError { ValueError::SystemError(_system_error) => {} ValueError::Message(message) => message.deref(), ValueError::TypeError(message) => message.deref(), + ValueError::FetchFailed { stack_source, .. } => stack_source.deinit(), ValueError::JSValue(v) => v.deinit(), ValueError::AbortReason(_) => {} } @@ -614,6 +633,16 @@ impl ValueError { } ValueError::Message(message) => message.to_error_instance(global_object), ValueError::TypeError(message) => message.to_type_error_instance(global_object), + ValueError::FetchFailed { + cause, + terminated, + stack_source, + } => { + let cause_js = core::mem::take(cause).to_error_instance(global_object); + cause_js.ensure_still_alive(); + let stack_src = stack_source.swap(); + Bun__createFetchFailedTypeError(global_object, cause_js, stack_src, *terminated) + } // do an early return in this case we don't need to create a new Strong ValueError::JSValue(js_value) => { return js_value.get().unwrap_or(JSValue::UNDEFINED); @@ -630,6 +659,18 @@ impl ValueError { ValueError::SystemError(e) => ValueError::SystemError(e.clone()), ValueError::Message(m) => ValueError::Message(m.clone()), ValueError::TypeError(m) => ValueError::TypeError(m.clone()), + ValueError::FetchFailed { + cause, + terminated, + stack_source, + } => ValueError::FetchFailed { + cause: cause.clone(), + terminated: *terminated, + stack_source: match stack_source.get() { + Some(v) => jsc::strong::Optional::create(v, global_object), + None => jsc::strong::Optional::empty(), + }, + }, ValueError::JSValue(js_ref) => { if let Some(js_value) = js_ref.get() { return ValueError::JSValue(jsc::strong::Optional::create( diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 526e44910f96..e5cf2fb8dd62 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -2097,6 +2097,10 @@ fn fetch_impl( } else { jsc::strong::Optional::create(check_server_identity, global_this) }, + caller_stack_source: jsc::strong::Optional::create( + jsc::capture_caller_stack_error(global_this), + global_this, + ), unix_socket_path: core::mem::replace(&mut unix_socket_path, ZigStringSlice::empty()), }; diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 9bff5a530a83..457ddb2d5a8c 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -111,6 +111,10 @@ pub struct FetchTasklet { // must be stored because AbortSignal stores reason weakly pub abort_reason: StrongOptional, + /// Error captured at the `fetch()` call site; its frames become the + /// rejection TypeError's `.stack` (otherwise empty at event-loop top). + pub caller_stack_source: StrongOptional, + // custom checkServerIdentity pub check_server_identity: StrongOptional, pub reject_unauthorized: bool, @@ -486,6 +490,7 @@ impl FetchTasklet { self.request_body.detach(); self.abort_reason.deinit(); + self.caller_stack_source.deinit(); self.check_server_identity.deinit(); self.clear_abort_signal(); // Clear the sink only after the requested ended otherwise we would potentialy lose the last chunk @@ -1286,9 +1291,7 @@ impl FetchTasklet { let fail = self.result.fail.unwrap(); - // Fetch-spec "network error" cases that callers feature-detect via - // `instanceof TypeError`. Keep this list narrow; the catch-all - // SystemError below is still a plain Error for backwards compat. + // Stays a bare TypeError (not FetchFailed): no network `.cause` to attach. if fail == http::Error::RequestBodyNotReusable { return BodyValueError::TypeError(BunString::static_( "Request body is a ReadableStream and cannot be replayed for this redirect", @@ -1304,6 +1307,10 @@ impl FetchTasklet { BunString::EMPTY }; + // undici: 'terminated' once headers arrived, else 'fetch failed'. + let terminated = self.metadata.is_some(); + let stack_source = core::mem::take(&mut self.caller_stack_source); + // The hostname never resolved: report the resolver error (`ENOTFOUND`, // ...) with `syscall`/`hostname`, the same shape `node:dns` produces, // rather than a generic connect-failure message. `dns_error` is the @@ -1322,15 +1329,15 @@ impl FetchTasklet { hostname, ); err.path = path.into(); - return BodyValueError::SystemError(err); + return BodyValueError::FetchFailed { + cause: err, + terminated, + stack_source, + }; } } - let code = if fail == http::Error::ConnectionClosed { - BunString::static_("ECONNRESET") - } else { - BunString::static_(fail.name()) - }; + let code = BunString::static_(fail.errno_code()); let message = match fail { http::Error::ConnectionClosed => BunString::static_( @@ -1562,7 +1569,11 @@ impl FetchTasklet { ..Default::default() }; - BodyValueError::SystemError(fetch_error) + BodyValueError::FetchFailed { + cause: fetch_error, + terminated, + stack_source, + } } pub(crate) fn on_readable_stream_available( @@ -1897,6 +1908,7 @@ impl FetchTasklet { signal_store: http::signals::Store::default(), has_schedule_callback: AtomicBool::new(false), abort_reason: StrongOptional::empty(), + caller_stack_source: fetch_options.caller_stack_source, check_server_identity: fetch_options.check_server_identity, reject_unauthorized: fetch_options.reject_unauthorized, upgraded_connection: fetch_options.upgraded_connection, @@ -2564,6 +2576,7 @@ pub struct FetchOptions { // Custom Hostname pub hostname: Option>, pub check_server_identity: StrongOptional, + pub caller_stack_source: StrongOptional, pub unix_socket_path: ZigStringSlice, pub ssl_config: Option, pub upgraded_connection: bool, @@ -2600,6 +2613,7 @@ impl Default for FetchOptions { global_this: None, hostname: None, check_server_identity: StrongOptional::empty(), + caller_stack_source: StrongOptional::empty(), unix_socket_path: ZigStringSlice::EMPTY, ssl_config: None, upgraded_connection: false, diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index 5addb57ce4c4..67c8c3b540d0 100644 --- a/test/bake/fixtures/deinitialization/test.ts +++ b/test/bake/fixtures/deinitialization/test.ts @@ -58,7 +58,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web if (sendAnyRequests) { if (closeActiveConnections) { - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); + expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNRESET" }); } else { const response = await fetch(server.url.origin, { keepalive: false }); expect(response.status).toBe(200); @@ -68,7 +68,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web } // Server is closed - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); + expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); } try { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 32a8b92f381c..aefbca022b06 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -458,14 +458,17 @@ describe.concurrent("Server", () => { // Test that HTTPS keep-alive doesn't cause it to re-use the connection on // the next attempt, when the next attempt has reject unauthorized enabled { - expect( - async () => await fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()), - ).toThrow("self signed certificate"); + await expect( + fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()), + ).rejects.toMatchObject({ name: "TypeError", code: "DEPTH_ZERO_SELF_SIGNED_CERT" }); } { using _ = rejectUnauthorizedScope(true); - expect(async () => await fetch(server.url).then(res => res.text())).toThrow("self signed certificate"); + await expect(fetch(server.url).then(res => res.text())).rejects.toMatchObject({ + name: "TypeError", + code: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); } { diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index d6534b96eae8..89457978410e 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -91,7 +91,7 @@ it("should be able to abruptly stop the server many times", async () => { await fetch(url, { keepalive: true }).then(res => res.text()); expect.unreachable(); } catch (e) { - expect(["ECONNRESET", "ConnectionRefused"]).toContain(e.code); + expect(["ECONNRESET", "ECONNREFUSED"]).toContain(e.code); } } @@ -2481,7 +2481,12 @@ it.concurrent("should not instanciate error instances in each request", async () await Promise.all(batch); } } - expect(heapStats().objectTypeCounts.Error || 0).toBeLessThanOrEqual(startErrorCount); + // fetch() allocates one Error per call for the rejection stack. GC reclaims + // most; conservative stack scanning and the last batch's live Responses can + // pin a handful, so allow one batch of headroom (well below the 1000 a + // per-request server-side leak would produce, which is what this guards). + Bun.gc(true); + expect(heapStats().objectTypeCounts.Error || 0).toBeLessThanOrEqual(startErrorCount + batchSize); }); it("should be able to abort a sendfile response and streams", async () => { @@ -2676,7 +2681,7 @@ it.concurrent( if (success) { expect(res.text()).resolves.toBe("Hello, World!"); } else { - expect(res.text()).rejects.toThrow(/The socket connection was closed unexpectedly./); + expect(res.text()).rejects.toMatchObject({ name: "TypeError", code: "ECONNRESET" }); } } await Promise.all([testTimeout("/ok", true), testTimeout("/timeout", false)]); diff --git a/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts b/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts index ae347c443b4b..75995e67d8d5 100644 --- a/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts +++ b/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts @@ -29,6 +29,8 @@ try { await res.text(); expect(true).toBe("unreacheable"); } catch (err) { + expect(err.name).toBe("TypeError"); + expect(err.message).toBe("fetch failed"); expect(err.code).toBe("FailedToOpenSocket"); - expect(err.message).toBe("Was there a typo in the url or port?"); + expect(err.cause?.message).toBe("Was there a typo in the url or port?"); } diff --git a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts index ad4425d1fd95..ae36b0aa47e0 100644 --- a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts +++ b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts @@ -31,6 +31,8 @@ try { await res.text(); expect.unreachable(); } catch (err) { + expect(err.name).toBe("TypeError"); + expect(err.message).toBe("fetch failed"); expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - expect(err.message).toBe("unable to verify the first certificate"); + expect(err.cause?.message).toBe("unable to verify the first certificate"); } diff --git a/test/js/bun/util/error-name-preservation.test.ts b/test/js/bun/util/error-name-preservation.test.ts index bf42635db393..700f5633eba0 100644 --- a/test/js/bun/util/error-name-preservation.test.ts +++ b/test/js/bun/util/error-name-preservation.test.ts @@ -45,11 +45,21 @@ describe("native error name/code preservation", () => { err = e; } expect(err).toBeDefined(); - // The exact message string from the fetch error match arm (was a - // ~40-arm `e if e == err!(X)` guard chain, now a real match). - expect({ code: err.code, message: String(err.message) }).toEqual({ - code: "ConnectionRefused", - message: "Unable to connect. Is the computer able to access the url?", + // The WHATWG fetch shape: outer TypeError('fetch failed') with the + // underlying error as `.cause`. The on_reject match-arm message lives on + // the cause; the outer error also mirrors `.code` for compatibility. + expect({ + name: err.name, + code: err.code, + message: String(err.message), + causeCode: err.cause?.code, + causeMessage: String(err.cause?.message), + }).toEqual({ + name: "TypeError", + code: "ECONNREFUSED", + message: "fetch failed", + causeCode: "ECONNREFUSED", + causeMessage: "Unable to connect. Is the computer able to access the url?", }); }); }); diff --git a/test/js/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 374ac06a7429..5af73adc8166 100644 --- a/test/js/first_party/undici/undici.test.ts +++ b/test/js/first_party/undici/undici.test.ts @@ -186,9 +186,10 @@ describe("undici.request maxRedirections", () => { // redirect may be followed, so the client stops at /redirect/1 instead // of chasing the chain to the end. hits.length = 0; - await expect(request(`${origin}/redirect/0`, { maxRedirections: 1 })).rejects.toThrow( - "redirected too many times", - ); + await expect(request(`${origin}/redirect/0`, { maxRedirections: 1 })).rejects.toMatchObject({ + name: "TypeError", + code: "TooManyRedirects", + }); expect(hits).toEqual(["/redirect/0", "/redirect/1"]); // A cap large enough for the whole chain still reaches the final response. diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index 9e458160ad40..9eb12abcaa59 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -670,6 +670,6 @@ it("proper error if missing CRLF after chunk data", async () => { await fetch(`http://localhost:${address.port}`).then(res => res.text()); expect.unreachable(); } catch (e) { - expect(e?.code).toBe("InvalidHTTPResponse"); + expect(e?.code).toBe("HPE_INVALID_CHUNK_SIZE"); } }); diff --git a/test/js/web/fetch/client-fetch.test.ts b/test/js/web/fetch/client-fetch.test.ts index 37cf159bbc09..5162611fef45 100644 --- a/test/js/web/fetch/client-fetch.test.ts +++ b/test/js/web/fetch/client-fetch.test.ts @@ -422,7 +422,14 @@ test("unresolvable hostname rejects with the resolver error", async () => { `const out = []; const report = p => p.then( () => "resolved", - ({ name, code, syscall, hostname, message }) => ({ name, code, syscall, hostname, message }), + ({ name, code, cause }) => ({ + name, + code, + causeCode: cause?.code, + syscall: cause?.syscall, + hostname: cause?.hostname, + message: cause?.message, + }), ); for (let i = 0; i < 3; i++) { out.push(await report(fetch("http://" + ${JSON.stringify(host)} + "/"))); @@ -448,8 +455,9 @@ test("unresolvable hostname rejects with the resolver error", async () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const notFound = (hostname: string) => ({ - name: "Error", + name: "TypeError", code: "ENOTFOUND", + causeCode: "ENOTFOUND", syscall: "getaddrinfo", hostname, message: `getaddrinfo ENOTFOUND ${hostname}`, @@ -508,11 +516,11 @@ test("error on redirect", async () => { }).listen(0); await once(server, "listening"); - expect( + await expect( fetch(`http://localhost:${server.address().port}`, { redirect: "error", }), - ).rejects.toThrow(/UnexpectedRedirect/); + ).rejects.toMatchObject({ name: "TypeError", code: "UnexpectedRedirect" }); }); test("Receiving non-Latin1 headers", async () => { @@ -674,5 +682,5 @@ test("a response with an obs-fold header continuation is rejected, not silently r => ({ rejected: false as const, setCookie: r.headers.get("set-cookie") }), e => ({ rejected: true as const, code: e.code }), ); - expect(outcome).toEqual({ rejected: true, code: "Malformed_HTTP_Response" }); + expect(outcome).toEqual({ rejected: true, code: "HPE_INVALID_CONSTANT" }); }); diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts new file mode 100644 index 000000000000..d0224125207a --- /dev/null +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// fetch() network errors must be TypeError('fetch failed') with a `.cause` +// carrying the underlying error (matching Node.js / undici), a non-empty +// string `.stack` pointing at the caller, and a Node-compatible errno `.code` +// on the cause. Bun also keeps `.code` on the outer TypeError for backwards +// compatibility so `err.code === "ECONNREFUSED"` keeps working. + +// Bind an ephemeral port then close it so nothing is listening. +function refusedPort(): number { + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = server.port; + server.stop(true); + return port; +} + +function expectFetchFailed(err: unknown, code: string) { + expect(err).toBeInstanceOf(TypeError); + const e = err as TypeError & { code?: string; cause?: Error & { code?: string } }; + expect(e.name).toBe("TypeError"); + expect(e.message).toBe("fetch failed"); + expect(typeof e.stack).toBe("string"); + expect(e.stack).toContain("fetch-error-shape.test.ts"); + expect(e.cause).toBeInstanceOf(Error); + expect(e.cause?.code).toBe(code); + // compat bridge: outer error also carries .code + expect(e.code).toBe(code); +} + +describe("fetch network error shape", () => { + test("ECONNREFUSED: TypeError('fetch failed') with cause.code", async () => { + const port = refusedPort(); + let caught: unknown; + try { + await fetch(`http://127.0.0.1:${port}/`); + } catch (e) { + caught = e; + } + expectFetchFailed(caught, "ECONNREFUSED"); + const e = caught as Error & { path?: string; cause?: Error & { path?: string } }; + expect(e.cause?.path).toBe(`http://127.0.0.1:${port}/`); + }); + + test("ECONNRESET: server closes mid-handshake", async () => { + await using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + // RST before any HTTP bytes. + socket.terminate(); + }, + data() {}, + }, + }); + let caught: unknown; + try { + await fetch(`http://127.0.0.1:${server.port}/`); + } catch (e) { + caught = e; + } + expectFetchFailed(caught, "ECONNRESET"); + }); + + test("DNS failure carries hostname/syscall on cause", async () => { + // A 64-byte DNS label violates RFC 1035, so getaddrinfo rejects it + // locally on every platform without touching the network. + const host = Buffer.alloc(64, "a").toString() + ".invalid"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `fetch("http://" + process.env.BAD_HOST + "/").catch(e => { + process.stdout.write(JSON.stringify({ + name: e.name, + message: e.message, + causeSyscall: e.cause && e.cause.syscall, + causeHostname: e.cause && e.cause.hostname, + causeCode: e.cause && e.cause.code, + })); + });`, + ], + env: { + ...bunEnv, + BAD_HOST: host, + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // resolver-dependent code (ENOTFOUND, EAI_AGAIN, ...); assert shape only + expect({ out: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + out: { + name: "TypeError", + message: "fetch failed", + causeSyscall: "getaddrinfo", + causeHostname: host, + causeCode: expect.any(String), + }, + stderr: "", + exitCode: 0, + }); + }); + + test(".catch() consumer still gets a stack", async () => { + // In-process: the caller frame is live for the whole test, so the + // captured stack reliably points at this file. + const port = refusedPort(); + const caught = await fetch(`http://127.0.0.1:${port}/`).catch(e => e); + expectFetchFailed(caught, "ECONNREFUSED"); + + // Subprocess: at top level of a `-e` script the module body has finished + // by the time the rejection fires, so a GC between capture and reject can + // collect the callee and force-materialize the source Error's trace. The + // outer TypeError must still have a string `.stack`. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `fetch("http://127.0.0.1:" + process.env.REFUSED_PORT + "/").catch(e => { + process.stdout.write(JSON.stringify({ + name: e.name, + message: e.message, + stackIsString: typeof e.stack === "string", + code: e.code, + causeCode: e.cause && e.cause.code, + })); + });`, + ], + env: { ...bunEnv, REFUSED_PORT: String(port) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + out: { + name: "TypeError", + message: "fetch failed", + stackIsString: true, + code: "ECONNREFUSED", + causeCode: "ECONNREFUSED", + }, + stderr: "", + exitCode: 0, + }); + }); + + test("is-network-error heuristic matches", async () => { + // https://github.com/sindresorhus/is-network-error + // Checks: err instanceof TypeError && message in {'fetch failed', ...} + let caught: unknown; + try { + await fetch(`http://127.0.0.1:${refusedPort()}/`); + } catch (e) { + caught = e; + } + const e = caught as Error; + const looksLikeNetworkError = + Object.prototype.toString.call(e) === "[object Error]" && + e.name === "TypeError" && + ["fetch failed", "terminated"].includes(e.message); + expect(looksLikeNetworkError).toBe(true); + }); +}); diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 701fbf4208d8..924bbdbecdfb 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -440,8 +440,8 @@ describe("corrupt compressed responses", () => { return payload; }; const bodies: Record = { - gzip: [corrupt(gzipSync), "ZlibError"], - deflate: [corrupt(deflateSync), "ZlibError"], + gzip: [corrupt(gzipSync), "Z_DATA_ERROR"], + deflate: [corrupt(deflateSync), "Z_DATA_ERROR"], br: [corrupt(brotliCompressSync), "BrotliDecompressionError"], zstd: [corrupt(zstdCompressSync), "ZstdDecompressionError"], }; @@ -532,7 +532,7 @@ describe("corrupt compressed responses", () => { e => e, ); expect(bodyErr).toBeInstanceOf(Error); - expect((bodyErr as { code?: string }).code).toBe("InvalidHTTPResponse"); + expect((bodyErr as { code?: string }).code).toBe("HPE_INVALID_CHUNK_SIZE"); } finally { srv.close(); } @@ -543,8 +543,8 @@ describe("corrupt compressed responses", () => { const plain = Buffer.alloc(FULL, "A"); const truncate = (b: Buffer) => b.subarray(0, b.length >> 1); const codecs: Record = { - gzip: [truncate(gzipSync(plain)), "ZlibError"], - deflate: [truncate(deflateSync(plain)), "ZlibError"], + gzip: [truncate(gzipSync(plain)), "Z_DATA_ERROR"], + deflate: [truncate(deflateSync(plain)), "Z_DATA_ERROR"], br: [truncate(brotliCompressSync(plain)), "BrotliDecompressionError"], zstd: [truncate(zstdCompressSync(plain)), "ZstdDecompressionError"], }; diff --git a/test/js/web/fetch/fetch-http2-adversarial.test.ts b/test/js/web/fetch/fetch-http2-adversarial.test.ts index d66fc0782ac9..6473b03c3ffd 100644 --- a/test/js/web/fetch/fetch-http2-adversarial.test.ts +++ b/test/js/web/fetch/fetch-http2-adversarial.test.ts @@ -320,7 +320,7 @@ describe.concurrent("fetch() HTTP/2 adversarial", () => { const result = await fetch(url, h2) .then(r => r.text(), errcode) .catch(errcode); - expect(String(result)).toMatch(/HTTP2|ProtocolError|ConnectionClosed/); + expect(String(result)).toMatch(/HTTP2|ProtocolError|ECONNRESET/); }, ); }); diff --git a/test/js/web/fetch/fetch-http2-client.test.ts b/test/js/web/fetch/fetch-http2-client.test.ts index 96a5f9de2641..1b56ef09a0ee 100644 --- a/test/js/web/fetch/fetch-http2-client.test.ts +++ b/test/js/web/fetch/fetch-http2-client.test.ts @@ -852,10 +852,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL async url => { await using proc = await spawnFetch(` try { await fetch("${url}", { tls: { rejectUnauthorized: false } }); console.log("ok"); } - catch (e) { console.log("rejected", String(e).includes("Refused")); } + catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected HTTP2RefusedStream"); expect(exitCode).toBe(0); // initial + 5 retries expect(attempts).toBe(6); @@ -1154,10 +1154,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL const r = await fetch("${url}", { tls: { rejectUnauthorized: false } }); await r.text(); console.log("ok"); - } catch (e) { console.log("rejected", String(e).includes("ContentLength")); } + } catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected UND_ERR_RES_CONTENT_LENGTH_MISMATCH"); expect(exitCode).toBe(0); }, ); @@ -1176,10 +1176,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL try { const r = await fetch("${url}", { tls: { rejectUnauthorized: false } }); console.log("ok", r.status, (await r.text()).length); - } catch (e) { console.log("rejected", String(e).includes("ContentLength")); } + } catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected UND_ERR_RES_CONTENT_LENGTH_MISMATCH"); expect(exitCode).toBe(0); }, ); @@ -1945,7 +1945,7 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL // client's first SSL_read that returns app data is also the call that // completes the handshake. ssl_on_data must fire on_handshake there or the // socket never gets re-tagged for h2 and the frame bytes hit the HTTP/1.1 - // parser as Malformed_HTTP_Response. Neither node:tls nor Bun.listen exposes + // parser as HPE_INVALID_CONSTANT. Neither node:tls nor Bun.listen exposes // the 0.5-RTT write window, so this hits a real Cloudflare-fronted origin — // tolerate network blips by only failing on the specific regression code. test("GET https://registry.npmjs.org over protocol: http2", async () => { @@ -1959,9 +1959,9 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL expect(stderr).toBe(""); expect(exitCode).toBe(0); const out = stdout.trim(); - // The bug under test surfaces as Malformed_HTTP_Response — DNS/connect + // The bug under test surfaces as HPE_INVALID_CONSTANT — DNS/connect // failures or 5xx are environmental, not regressions. - expect(out).not.toContain("Malformed_HTTP_Response"); + expect(out).not.toContain("HPE_INVALID_CONSTANT"); if (!out.startsWith("status")) { console.warn(`skipping live h2 assertion: ${out}`); return; diff --git a/test/js/web/fetch/fetch-redirect.test.ts b/test/js/web/fetch/fetch-redirect.test.ts index 73ea51d509ea..1bc5275b3f67 100644 --- a/test/js/web/fetch/fetch-redirect.test.ts +++ b/test/js/web/fetch/fetch-redirect.test.ts @@ -202,7 +202,7 @@ it.each([ () => ({ rejected: false as const, code: undefined }), e => ({ rejected: true as const, code: e.code }), ); - expect(outcome).toEqual({ rejected: true, code: "Malformed_HTTP_Response" }); + expect(outcome).toEqual({ rejected: true, code: "HPE_INVALID_CONSTANT" }); expect(requests).toHaveLength(1); } finally { server.close(); diff --git a/test/js/web/fetch/fetch-syscall-fault.test.ts b/test/js/web/fetch/fetch-syscall-fault.test.ts index c6f835836ccb..a336fc2fb561 100644 --- a/test/js/web/fetch/fetch-syscall-fault.test.ts +++ b/test/js/web/fetch/fetch-syscall-fault.test.ts @@ -72,7 +72,7 @@ describe.skipIf(skip)("fetch() under injected syscall faults (http)", () => { }); expect(r.signal).toBeNull(); expect(r.ok).toBe(false); - expect(["ECONNRESET", "ConnectionClosed"]).toContain(r.code); + expect(r.code).toBe("ECONNRESET"); }); test("recv → short reads (1 byte) deliver complete body", async () => { @@ -127,7 +127,7 @@ describe.skipIf(skip)("fetch() under injected syscall faults (http)", () => { expect(r.signal).toBeNull(); expect(r.ok).toBe(false); // fetch wraps connect failure as a generic open-socket error. - expect(["ECONNREFUSED", "FailedToOpenSocket", "ConnectionRefused"]).toContain(r.code); + expect(["ECONNREFUSED", "FailedToOpenSocket"]).toContain(r.code); }); test("recv → 0 (peer closed) before any byte rejects cleanly (no hang)", async () => { diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index ca7ee6af46dd..9580effcf3ce 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1264,17 +1264,17 @@ describe.concurrent("fetch() with streaming", () => { expect(buffer.toString("utf8")).toBe("unreachable"); } catch (err) { if (compression === "br") { - expect((err as Error).name).toBe("Error"); + expect((err as Error).name).toBe("TypeError"); expect((err as Error).code).toBe("BrotliDecompressionError"); } else if (compression === "deflate-libdeflate") { - expect((err as Error).name).toBe("Error"); - expect((err as Error).code).toBe("ZlibError"); + expect((err as Error).name).toBe("TypeError"); + expect((err as Error).code).toBe("Z_DATA_ERROR"); } else if (compression === "zstd") { - expect((err as Error).name).toBe("Error"); + expect((err as Error).name).toBe("TypeError"); expect((err as Error).code).toBe("ZstdDecompressionError"); } else { - expect((err as Error).name).toBe("Error"); - expect((err as Error).code).toBe("ZlibError"); + expect((err as Error).name).toBe("TypeError"); + expect((err as Error).code).toBe("Z_DATA_ERROR"); } } } @@ -1365,7 +1365,7 @@ describe.concurrent("fetch() with streaming", () => { gcTick(false); expect(buffer.toString("utf8")).toBe("unreachable"); } catch (err) { - expect((err as Error).name).toBe("Error"); + expect((err as Error).name).toBe("TypeError"); expect((err as Error).code).toBe("ECONNRESET"); } }); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 88180c6d016b..9430ce178354 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -2079,7 +2079,11 @@ describe("maxRedirects", () => { }); it("rejects once the chain exceeds maxRedirects", async () => { - expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toThrow("redirected too many times"); + await expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toMatchObject({ + name: "TypeError", + message: "fetch failed", + code: "TooManyRedirects", + }); }); it("follows the chain when maxRedirects is large enough", async () => { @@ -2673,7 +2677,7 @@ it("rejects a response with an unparseable Content-Length instead of treating it .then(res => res.text()) .catch(e => e); expect(result).toBeInstanceOf(Error); - expect((result as any).code).toBe("InvalidContentLength"); + expect((result as any).code).toBe("UND_ERR_RES_CONTENT_LENGTH_MISMATCH"); } // A well-formed Content-Length is still delivered normally. diff --git a/test/js/web/fetch/fetch.tls.cert-mismatch-churn.fixture.ts b/test/js/web/fetch/fetch.tls.cert-mismatch-churn.fixture.ts index 533b58ada86c..0dbd9b47b1e8 100644 --- a/test/js/web/fetch/fetch.tls.cert-mismatch-churn.fixture.ts +++ b/test/js/web/fetch/fetch.tls.cert-mismatch-churn.fixture.ts @@ -94,7 +94,7 @@ for (let i = 0; i < 3; i++) { }, err => { const code = typeof err?.code === "string" ? err.code : err?.name; - if (code === "Timeout" || code === "TimeoutError" || code === "ETIMEDOUT" || code === "ECONNRESET") { + if (code === "TimeoutError" || code === "ETIMEDOUT" || code === "ECONNRESET") { bump("stalled"); } else { failures.push(`stalled handshake fetch rejected with ${code ?? err}`); @@ -164,8 +164,8 @@ for (let batch = 0; batch < 8; batch++) { // truncation surface as ECONNRESET-flavored errors. if ( code === "TimeoutError" || + code === "ETIMEDOUT" || code === "ECONNRESET" || - code === "ConnectionClosed" || err?.name === "TimeoutError" ) { bump("churn"); diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 6ffc837062ed..624bb45681b6 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -114,8 +114,7 @@ describe("HTMLRewriter", () => { const fullBody = "

hello world

"; // Ties the rejection to the connection failure so an unrelated rejection // ("Body already used", an internal rewriter error) can't keep this green. - // The exact RST message varies by platform, so match loosely. - const connectionError = /socket|connection|ECONNRESET/i; + const connectionError = { name: "TypeError", code: "ECONNRESET" }; async function withPartialBodyServer(fn) { let release; @@ -154,20 +153,17 @@ describe("HTMLRewriter", () => { function settle(promise) { return promise.then( value => ({ rejected: false, value }), - error => ({ rejected: true, message: String(error?.message) }), + error => ({ rejected: true, name: error?.name, code: error?.code }), ); } - const rejectedWithConnectionError = { - rejected: true, - message: expect.stringMatching(connectionError), - }; + const rejectedWithConnectionError = { rejected: true, ...connectionError }; it("control: .text() on the untransformed response rejects", async () => { await withPartialBodyServer(async (url, release) => { const res = await fetch(url); const text = res.text(); release(); - await expect(text).rejects.toThrow(connectionError); + await expect(text).rejects.toMatchObject(connectionError); }); }); @@ -191,7 +187,7 @@ describe("HTMLRewriter", () => { const res = await fetch(url); const buf = rewriter().transform(res).arrayBuffer(); release(); - await expect(buf).rejects.toThrow(connectionError); + await expect(buf).rejects.toMatchObject(connectionError); }); }); @@ -260,7 +256,7 @@ describe("HTMLRewriter", () => { const res = await fetch(url); const text = rw.transform(res).text(); release(); - await expect(text).rejects.toThrow(connectionError); + await expect(text).rejects.toMatchObject(connectionError); expect(endCalls).toBe(0); }); }); @@ -274,8 +270,14 @@ describe("HTMLRewriter", () => { const text = res.text(); release(); // Awaiting the rejection is the barrier: the body is now Value::Error. - await expect(text).rejects.toThrow(connectionError); - expect(() => rewriter().transform(res)).toThrow(connectionError); + await expect(text).rejects.toMatchObject(connectionError); + let thrown; + try { + rewriter().transform(res); + } catch (e) { + thrown = e; + } + expect(thrown).toMatchObject(connectionError); }); }); diff --git a/test/regression/issue/18413-deflate-semantics.test.ts b/test/regression/issue/18413-deflate-semantics.test.ts index 5bbc14bc7f04..e4ae903af857 100644 --- a/test/regression/issue/18413-deflate-semantics.test.ts +++ b/test/regression/issue/18413-deflate-semantics.test.ts @@ -196,7 +196,7 @@ test("truncated zlib-wrapped deflate should fail", async () => { await response.text(); expect.unreachable("Should have thrown decompression error"); } catch (err: any) { - expect(err.code).toMatch(/ZlibError|ShortRead/); + expect(err.code).toMatch(/Z_DATA_ERROR|ShortRead/); } }); @@ -222,7 +222,7 @@ test("invalid deflate data should fail", async () => { await response.text(); expect.unreachable("Should have thrown decompression error"); } catch (err: any) { - expect(err.code).toMatch(/ZlibError/); + expect(err.code).toMatch(/Z_DATA_ERROR/); } }); diff --git a/test/regression/issue/18413-truncation.test.ts b/test/regression/issue/18413-truncation.test.ts index 9f53c8776c72..9e7675da5299 100644 --- a/test/regression/issue/18413-truncation.test.ts +++ b/test/regression/issue/18413-truncation.test.ts @@ -105,7 +105,7 @@ test("truncated gzip stream should throw error", async () => { await response.text(); expect.unreachable("Should have thrown decompression error"); } catch (err: any) { - expect(err.code || err.name || err.message).toMatch(/ZlibError|ShortRead/); + expect(err.code || err.name || err.message).toMatch(/Z_DATA_ERROR|ShortRead/); } }); @@ -144,7 +144,7 @@ test("truncated deflate stream should throw error", async () => { await response.text(); expect.unreachable("Should have thrown decompression error"); } catch (err: any) { - expect(err.code || err.name || err.message).toMatch(/ZlibError|ShortRead/); + expect(err.code || err.name || err.message).toMatch(/Z_DATA_ERROR|ShortRead/); } }); @@ -253,7 +253,7 @@ test("invalid gzip data should fail", async () => { await response.text(); expect.unreachable("Should have thrown decompression error"); } catch (err: any) { - expect(err.code || err.name || err.message).toMatch(/ZlibError/); + expect(err.code || err.name || err.message).toMatch(/Z_DATA_ERROR/); } }); diff --git a/test/vendor.json b/test/vendor.json index be7f81d00a5c..0387ea0218cf 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -6,7 +6,8 @@ "skipTests": { "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", - "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." + "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value.", + "core*stop.test.ts": "TEMPORARY: Bun's fetch() network errors now reject as TypeError('fetch failed') with the underlying error on .cause, matching Node/undici and the WHATWG Fetch spec (oven-sh/bun#20486). elysia 1.4.28's 'shuts down the server' test asserts err.message contains 'Unable to connect' (now on err.cause.message); remove this skip once the upstream assertion checks err.code === 'ECONNREFUSED' or err.cause?.message instead." } } ]