From cd1e5fa0c61c39824c1268e1baca633def811de6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:35:09 +0000 Subject: [PATCH 01/15] fetch: reject network errors as TypeError('fetch failed') with cause and caller stack fetch() network failures now reject with a TypeError whose message is 'fetch failed' (or 'terminated' once the response body is streaming), matching Node.js/undici and the WHATWG Fetch spec. The underlying system error (connection refused, DNS failure, TLS error, parse error, decompression error, ...) is attached as .cause, and .code is mirrored onto the outer TypeError so existing err.code checks keep working. The outer TypeError's .stack is populated from a snapshot of the caller's stack taken at the fetch() call site. Previously the error was created from an event-loop task with an empty interpreter stack, so .catch() consumers saw .stack === undefined. Error codes now use Node.js errno / parser vocabulary where one exists: ConnectionRefused -> ECONNREFUSED ConnectionClosed -> ECONNRESET (unchanged) Timeout -> ETIMEDOUT Malformed_HTTP_Response -> HPE_INVALID_CONSTANT InvalidHTTPResponse -> HPE_INVALID_CHUNK_SIZE ResponseHeadersTooLarge -> UND_ERR_HEADERS_OVERFLOW InvalidContentLength -> UND_ERR_RES_CONTENT_LENGTH_MISMATCH Zlib errors -> Z_DATA_ERROR ENOTFOUND, cert codes, and Bun-specific labels without a Node equivalent are unchanged. --- src/http/error.rs | 26 +++ src/jsc/bindings/bindings.cpp | 56 +++++++ src/jsc/lib.rs | 10 ++ src/runtime/webcore/Body.rs | 45 ++++++ src/runtime/webcore/fetch.rs | 4 + src/runtime/webcore/fetch/FetchTasklet.rs | 34 +++- test/bake/fixtures/deinitialization/test.ts | 4 +- test/js/bun/http/bun-server.test.ts | 4 +- test/js/bun/http/serve.test.ts | 11 +- ...test-http-should-error-with-faulty-args.ts | 4 +- .../bun/util/error-name-preservation.test.ts | 20 ++- test/js/web/fetch/chunked-trailing.test.js | 2 +- test/js/web/fetch/client-fetch.test.ts | 18 ++- test/js/web/fetch/fetch-error-shape.test.ts | 148 ++++++++++++++++++ test/js/web/fetch/fetch-gzip.test.ts | 10 +- test/js/web/fetch/fetch-redirect.test.ts | 2 +- test/js/web/fetch/fetch-syscall-fault.test.ts | 4 +- test/js/web/fetch/fetch.stream.test.ts | 14 +- test/js/web/fetch/fetch.test.ts | 8 +- 19 files changed, 381 insertions(+), 43 deletions(-) create mode 100644 test/js/web/fetch/fetch-error-shape.test.ts diff --git a/src/http/error.rs b/src/http/error.rs index b7edb71da82a..d73731a84fae 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -327,6 +327,32 @@ impl bun_core::output::ErrName for Error { } } +impl Error { + /// Node.js / undici-compatible errno-style code string for `err.code`. + /// Falls back to the Bun label for variants with no established mapping. + #[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..6473618abe57 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2353,6 +2353,62 @@ JSC::EncodedJSValue JSGlobalObject__createOutOfMemoryError(JSC::JSGlobalObject* return JSValue::encode(exception); } +// Create a bare Error whose only purpose is to snapshot the caller's +// synchronous stack. Used by fetch() to record the call site so the later +// event-loop-created rejection error can point at user code. +extern "C" JSC::EncodedJSValue Bun__captureCallerStackError(JSC::JSGlobalObject* globalObject) +{ + return JSC::JSValue::encode(JSC::createError(globalObject, "fetch"_s)); +} + +// Wrap a fetch network error as the WHATWG / undici shape: an outer +// TypeError('fetch failed' | 'terminated') carrying the underlying error as +// `.cause`. The outer error also gets `.code` mirrored from the cause so +// existing `err.code === "ECONNREFUSED"` checks keep working. +// +// `stackSourceValue` is an ErrorInstance captured at the original fetch() +// call site; its frames are transplanted onto the new TypeError so `.stack` +// points at the caller instead of being empty (the TypeError is created from +// an event-loop task where the interpreter stack is empty). +extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( + JSC::JSGlobalObject* globalObject, + JSC::EncodedJSValue causeValue, + JSC::EncodedJSValue stackSourceValue, + bool terminated) +{ + auto& vm = JSC::getVM(globalObject); + 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); + } + } + + JSC::JSValue stackSrc = JSC::JSValue::decode(stackSourceValue); + if (auto* srcInstance = dynamicDowncast(stackSrc)) { + if (auto* destInstance = dynamicDowncast(result)) { + if (auto* srcTrace = srcInstance->stackTrace(); srcTrace && !srcTrace->isEmpty()) { + // Copy (not move): the source may be shared via ValueError::dupe + // when a Response body is cloned. + WTF::Vector frames; + frames.appendVector(*srcTrace); + destInstance->setStackFrames(vm, WTF::move(frames)); + } + } + } + + 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..d9a2de8c0e85 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -644,6 +644,15 @@ impl From for bun_event_loop::ErasedJsError { } } +/// Create a bare `Error` whose only purpose is to snapshot the current JS +/// stack. Used by `fetch()` to record the call site while the caller is still +/// on the stack; the frames are later transplanted onto the rejection error +/// (which is minted from an event-loop task with an empty interpreter stack). +#[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 +1179,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..2ce545cd1dd1 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -569,9 +569,31 @@ pub enum ValueError { /// error" to TypeError, so use this for fetch-layer rejections that /// callers feature-detect via `err instanceof TypeError`. TypeError(BunString), + /// WHATWG fetch "network error": an outer `TypeError('fetch failed')` + /// (or `'terminated'` once the response body is streaming) whose `.cause` + /// is the underlying system error. `stack_source` carries the caller's + /// synchronous stack captured at the original `fetch()` call so the + /// rejection (minted at event-loop top with an empty interpreter stack) + /// still points at user code. + 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 +603,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 +637,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 +663,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..4810c1bc2b3f 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -111,6 +111,12 @@ pub struct FetchTasklet { // must be stored because AbortSignal stores reason weakly pub abort_reason: StrongOptional, + /// ErrorInstance created at the JS-thread `fetch()` call purely to snapshot + /// the caller's synchronous stack. Transplanted onto the rejection + /// `TypeError('fetch failed')`, which is otherwise stackless (minted from + /// an event-loop task with an empty interpreter stack). + pub caller_stack_source: StrongOptional, + // custom checkServerIdentity pub check_server_identity: StrongOptional, pub reject_unauthorized: bool, @@ -486,6 +492,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 @@ -1304,6 +1311,12 @@ impl FetchTasklet { BunString::EMPTY }; + // An error after response headers arrived surfaces on the body reader, + // where undici uses `TypeError('terminated')`; before headers the fetch + // promise itself rejects with `TypeError('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 +1335,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 +1575,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 +1914,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 +2582,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 +2619,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..aa50c3e50089 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.toThrow("fetch failed"); } 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.toThrow("fetch failed"); } try { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 32a8b92f381c..44360e4afd20 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -460,12 +460,12 @@ describe.concurrent("Server", () => { { expect( async () => await fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()), - ).toThrow("self signed certificate"); + ).toThrow("fetch failed"); } { using _ = rejectUnauthorizedScope(true); - expect(async () => await fetch(server.url).then(res => res.text())).toThrow("self signed certificate"); + expect(async () => await fetch(server.url).then(res => res.text())).toThrow("fetch failed"); } { diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index d6534b96eae8..11202434e927 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 to capture the caller's stack for the + // rejection path; it is released with the tasklet. GC first so those + // transient instances don't count against the server-side regression this + // test 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/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/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..0e3389f8d4d0 --- /dev/null +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -0,0 +1,148 @@ +import { test, expect, describe } 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. + +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 () => { + // Bind to a port then close it so nothing is listening. + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { data() {} }, + }); + const port = server.port; + server.stop(true); + + 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 () => { + // Spawn with proxy env cleared so the lookup actually runs. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `fetch("http://does.not.exist.invalid/").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, HTTP_PROXY: "", HTTPS_PROXY: "", http_proxy: "", https_proxy: "" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const out = JSON.parse(stdout); + expect(out.name).toBe("TypeError"); + expect(out.message).toBe("fetch failed"); + expect(out.causeSyscall).toBe("getaddrinfo"); + expect(out.causeHostname).toBe("does.not.exist.invalid"); + // resolver-dependent code (ENOTFOUND, EAI_AGAIN, ENOTIMP, ...); just must be a string + expect(typeof out.causeCode).toBe("string"); + expect(exitCode).toBe(0); + }); + + test(".catch() consumer still gets a stack", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `fetch("http://127.0.0.1:1/").catch(e => { + process.stdout.write(JSON.stringify({ + name: e.name, + message: e.message, + stackIsString: typeof e.stack === "string", + stackHasEval: typeof e.stack === "string" && e.stack.includes("[eval]"), + code: e.code, + causeCode: e.cause && e.cause.code, + })); + });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + const out = JSON.parse(stdout); + expect(out.name).toBe("TypeError"); + expect(out.message).toBe("fetch failed"); + expect(out.stackIsString).toBe(true); + expect(out.stackHasEval).toBe(true); + expect(out.code).toBe("ECONNREFUSED"); + expect(out.causeCode).toBe("ECONNREFUSED"); + expect(exitCode).toBe(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:1/"); + } 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-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. From 27ad7e51022f4aab0dd306cd8c36922159e73ac3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:37:13 +0000 Subject: [PATCH 02/15] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch-error-shape.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index 0e3389f8d4d0..612c867b89a4 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; // fetch() network errors must be TypeError('fetch failed') with a `.cause` @@ -114,11 +114,7 @@ describe("fetch network error shape", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const out = JSON.parse(stdout); expect(out.name).toBe("TypeError"); expect(out.message).toBe("fetch failed"); From c38ed7f5602d852f99fe8f04d72a130c4e285a38 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:39:57 +0000 Subject: [PATCH 03/15] trim doc comments --- src/http/error.rs | 3 +-- src/jsc/bindings/bindings.cpp | 19 +++++-------------- src/jsc/lib.rs | 5 +---- src/runtime/webcore/Body.rs | 8 ++------ src/runtime/webcore/fetch/FetchTasklet.rs | 10 +++------- 5 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/http/error.rs b/src/http/error.rs index d73731a84fae..07c05d50c79f 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -328,8 +328,7 @@ impl bun_core::output::ErrName for Error { } impl Error { - /// Node.js / undici-compatible errno-style code string for `err.code`. - /// Falls back to the Bun label for variants with no established mapping. + /// 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 { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 6473618abe57..5bbf9edf4df9 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2353,23 +2353,15 @@ JSC::EncodedJSValue JSGlobalObject__createOutOfMemoryError(JSC::JSGlobalObject* return JSValue::encode(exception); } -// Create a bare Error whose only purpose is to snapshot the caller's -// synchronous stack. Used by fetch() to record the call site so the later -// event-loop-created rejection error can point at user code. +// 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)); } -// Wrap a fetch network error as the WHATWG / undici shape: an outer -// TypeError('fetch failed' | 'terminated') carrying the underlying error as -// `.cause`. The outer error also gets `.code` mirrored from the cause so -// existing `err.code === "ECONNREFUSED"` checks keep working. -// -// `stackSourceValue` is an ErrorInstance captured at the original fetch() -// call site; its frames are transplanted onto the new TypeError so `.stack` -// points at the caller instead of being empty (the TypeError is created from -// an event-loop task where the interpreter stack is empty). +// 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, @@ -2397,8 +2389,7 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( if (auto* srcInstance = dynamicDowncast(stackSrc)) { if (auto* destInstance = dynamicDowncast(result)) { if (auto* srcTrace = srcInstance->stackTrace(); srcTrace && !srcTrace->isEmpty()) { - // Copy (not move): the source may be shared via ValueError::dupe - // when a Response body is cloned. + // Copy: source may be shared across a cloned Response body. WTF::Vector frames; frames.appendVector(*srcTrace); destInstance->setStackFrames(vm, WTF::move(frames)); diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index d9a2de8c0e85..965e969295e2 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -644,10 +644,7 @@ impl From for bun_event_loop::ErasedJsError { } } -/// Create a bare `Error` whose only purpose is to snapshot the current JS -/// stack. Used by `fetch()` to record the call site while the caller is still -/// on the stack; the frames are later transplanted onto the rejection error -/// (which is minted from an event-loop task with an empty interpreter stack). +/// Bare `Error` that snapshots the current JS stack for later transplant. #[inline] pub fn capture_caller_stack_error(global: &JSGlobalObject) -> JSValue { Bun__captureCallerStackError(global) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 2ce545cd1dd1..e016986dc939 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -569,12 +569,8 @@ pub enum ValueError { /// error" to TypeError, so use this for fetch-layer rejections that /// callers feature-detect via `err instanceof TypeError`. TypeError(BunString), - /// WHATWG fetch "network error": an outer `TypeError('fetch failed')` - /// (or `'terminated'` once the response body is streaming) whose `.cause` - /// is the underlying system error. `stack_source` carries the caller's - /// synchronous stack captured at the original `fetch()` call so the - /// rejection (minted at event-loop top with an empty interpreter stack) - /// still points at user code. + /// `TypeError('fetch failed'|'terminated')` with `.cause`. `stack_source` + /// holds the `fetch()` call-site frames for transplant onto the TypeError. FetchFailed { cause: SystemError, terminated: bool, diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 4810c1bc2b3f..b691c290d7fa 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -111,10 +111,8 @@ pub struct FetchTasklet { // must be stored because AbortSignal stores reason weakly pub abort_reason: StrongOptional, - /// ErrorInstance created at the JS-thread `fetch()` call purely to snapshot - /// the caller's synchronous stack. Transplanted onto the rejection - /// `TypeError('fetch failed')`, which is otherwise stackless (minted from - /// an event-loop task with an empty interpreter stack). + /// 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 @@ -1311,9 +1309,7 @@ impl FetchTasklet { BunString::EMPTY }; - // An error after response headers arrived surfaces on the body reader, - // where undici uses `TypeError('terminated')`; before headers the fetch - // promise itself rejects with `TypeError('fetch failed')`. + // 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); From b7e45154ebe34ac0cd5e5cf2265a9d5820019eda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:49:36 +0000 Subject: [PATCH 04/15] review: restore cert-code assertion in handshake test; drain stderr; clarify heap-count slack --- test/js/bun/http/bun-server.test.ts | 11 +++++++---- test/js/bun/http/serve.test.ts | 8 ++++---- test/js/web/fetch/fetch-error-shape.test.ts | 22 ++++++++++++--------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 44360e4afd20..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("fetch failed"); + 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("fetch failed"); + 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 11202434e927..89457978410e 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -2481,10 +2481,10 @@ it.concurrent("should not instanciate error instances in each request", async () await Promise.all(batch); } } - // fetch() allocates one Error per call to capture the caller's stack for the - // rejection path; it is released with the tasklet. GC first so those - // transient instances don't count against the server-side regression this - // test guards. + // 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); }); diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index 612c867b89a4..7fe95358a768 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -83,15 +83,19 @@ describe("fetch network error shape", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - const out = JSON.parse(stdout); - expect(out.name).toBe("TypeError"); - expect(out.message).toBe("fetch failed"); - expect(out.causeSyscall).toBe("getaddrinfo"); - expect(out.causeHostname).toBe("does.not.exist.invalid"); - // resolver-dependent code (ENOTFOUND, EAI_AGAIN, ENOTIMP, ...); just must be a string - expect(typeof out.causeCode).toBe("string"); - expect(exitCode).toBe(0); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // resolver-dependent code (ENOTFOUND, EAI_AGAIN, ENOTIMP, ...); assert shape only + expect({ out: JSON.parse(stdout), stderr, exitCode }).toEqual({ + out: { + name: "TypeError", + message: "fetch failed", + causeSyscall: "getaddrinfo", + causeHostname: "does.not.exist.invalid", + causeCode: expect.any(String), + }, + stderr: "", + exitCode: 0, + }); }); test(".catch() consumer still gets a stack", async () => { From 865238ad478750127eef25b315ed61449aa6fc8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:56:03 +0000 Subject: [PATCH 05/15] test: reserve ephemeral ports instead of hardcoding port 1; assert combined subprocess output --- test/js/web/fetch/fetch-error-shape.test.ts | 47 ++++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index 7fe95358a768..3602942b84c9 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -7,6 +7,14 @@ import { bunEnv, bunExe } from "harness"; // 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 { + using 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 } }; @@ -22,15 +30,7 @@ function expectFetchFailed(err: unknown, code: string) { describe("fetch network error shape", () => { test("ECONNREFUSED: TypeError('fetch failed') with cause.code", async () => { - // Bind to a port then close it so nothing is listening. - using server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - socket: { data() {} }, - }); - const port = server.port; - server.stop(true); - + const port = refusedPort(); let caught: unknown; try { await fetch(`http://127.0.0.1:${port}/`); @@ -85,7 +85,7 @@ describe("fetch network error shape", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // resolver-dependent code (ENOTFOUND, EAI_AGAIN, ENOTIMP, ...); assert shape only - expect({ out: JSON.parse(stdout), stderr, exitCode }).toEqual({ + expect({ out: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ out: { name: "TypeError", message: "fetch failed", @@ -99,11 +99,12 @@ describe("fetch network error shape", () => { }); test(".catch() consumer still gets a stack", async () => { + const port = refusedPort(); await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `fetch("http://127.0.0.1:1/").catch(e => { + `fetch("http://127.0.0.1:" + process.env.REFUSED_PORT + "/").catch(e => { process.stdout.write(JSON.stringify({ name: e.name, message: e.message, @@ -114,19 +115,23 @@ describe("fetch network error shape", () => { })); });`, ], - env: bunEnv, + 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]); - const out = JSON.parse(stdout); - expect(out.name).toBe("TypeError"); - expect(out.message).toBe("fetch failed"); - expect(out.stackIsString).toBe(true); - expect(out.stackHasEval).toBe(true); - expect(out.code).toBe("ECONNREFUSED"); - expect(out.causeCode).toBe("ECONNREFUSED"); - expect(exitCode).toBe(0); + expect({ out: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + out: { + name: "TypeError", + message: "fetch failed", + stackIsString: true, + stackHasEval: true, + code: "ECONNREFUSED", + causeCode: "ECONNREFUSED", + }, + stderr: "", + exitCode: 0, + }); }); test("is-network-error heuristic matches", async () => { @@ -134,7 +139,7 @@ describe("fetch network error shape", () => { // Checks: err instanceof TypeError && message in {'fetch failed', ...} let caught: unknown; try { - await fetch("http://127.0.0.1:1/"); + await fetch(`http://127.0.0.1:${refusedPort()}/`); } catch (e) { caught = e; } From d8db6936d8961a3ac0d5a41bdcef89d85258fa5b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:05:17 +0000 Subject: [PATCH 06/15] review: update remaining old-label assertions (http2-client 0.5-RTT guard, undici maxRedirections, bake deinit, cert-mismatch fixture) --- test/bake/fixtures/deinitialization/test.ts | 4 ++-- test/js/first_party/undici/undici.test.ts | 7 ++++--- test/js/web/fetch/fetch-http2-client.test.ts | 6 +++--- test/js/web/fetch/fetch.tls.cert-mismatch-churn.fixture.ts | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index aa50c3e50089..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("fetch failed"); + 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("fetch failed"); + expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); } try { 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/fetch-http2-client.test.ts b/test/js/web/fetch/fetch-http2-client.test.ts index 96a5f9de2641..ca12a6023956 100644 --- a/test/js/web/fetch/fetch-http2-client.test.ts +++ b/test/js/web/fetch/fetch-http2-client.test.ts @@ -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.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"); From 596a61b51bc8c9a7b5f8b9dd25b30d1377a5123f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:20:59 +0000 Subject: [PATCH 07/15] review: update 18413 regression tests for Z_DATA_ERROR; fix stale RequestBodyNotReusable comment; update h2-adversarial ConnectionClosed regex --- src/runtime/webcore/fetch/FetchTasklet.rs | 4 +--- test/js/web/fetch/fetch-http2-adversarial.test.ts | 2 +- test/regression/issue/18413-deflate-semantics.test.ts | 4 ++-- test/regression/issue/18413-truncation.test.ts | 6 +++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index b691c290d7fa..457ddb2d5a8c 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1291,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", 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/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/); } }); From 2c0aecc88c675c4e53f206d02a5387848831856c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:33:45 +0000 Subject: [PATCH 08/15] fetch: ensure .stack is always a string even if GC finalized the source trace; fix h2-client subprocess String(e) assertions; await bake deinit .rejects --- src/jsc/bindings/bindings.cpp | 34 ++++++++++++++++---- test/bake/fixtures/deinitialization/test.ts | 4 +-- test/js/web/fetch/fetch-error-shape.test.ts | 11 +++++-- test/js/web/fetch/fetch-http2-client.test.ts | 12 +++---- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 5bbf9edf4df9..6d29ca032237 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2385,18 +2385,38 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( } } + auto* destInstance = dynamicDowncast(result); JSC::JSValue stackSrc = JSC::JSValue::decode(stackSourceValue); - if (auto* srcInstance = dynamicDowncast(stackSrc)) { - if (auto* destInstance = dynamicDowncast(result)) { - 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)); + if (auto* srcInstance = dynamicDowncast(stackSrc); srcInstance && destInstance) { + 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's finalizeUnconditionally may have materialized the source + // (clearing its frame vector) if a captured callee was collected + // before the rejection ran. Fall back to the source's `.stack` + // string, rewriting the header for the outer TypeError. + srcInstance->materializeErrorInfoIfNeeded(vm); + if (JSC::JSValue srcStack = srcInstance->getDirect(vm, vm.propertyNames->stack); srcStack && srcStack.isString()) { + auto str = asString(srcStack)->value(globalObject); + size_t nl = str->find('\n'); + auto frames = nl == WTF::notFound ? WTF::emptyString() : str->substring(nl); + auto header = terminated ? "TypeError: terminated"_s : "TypeError: fetch failed"_s; + result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, makeString(header, frames)), JSC::PropertyAttribute::DontEnum | 0); } } } + if (destInstance) { + auto* destTrace = destInstance->stackTrace(); + if ((!destTrace || destTrace->isEmpty()) && !result->getDirect(vm, vm.propertyNames->stack)) { + auto header = terminated ? "TypeError: terminated"_s : "TypeError: fetch failed"_s; + result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, String(header)), JSC::PropertyAttribute::DontEnum | 0); + } + } + return JSC::JSValue::encode(result); } diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index 67c8c3b540d0..f72a50903318 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.toMatchObject({ code: "ECONNRESET" }); + await 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.toMatchObject({ code: "ECONNREFUSED" }); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); } try { diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index 3602942b84c9..967f98af1e7c 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -99,7 +99,16 @@ describe("fetch network error shape", () => { }); 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(), @@ -109,7 +118,6 @@ describe("fetch network error shape", () => { name: e.name, message: e.message, stackIsString: typeof e.stack === "string", - stackHasEval: typeof e.stack === "string" && e.stack.includes("[eval]"), code: e.code, causeCode: e.cause && e.cause.code, })); @@ -125,7 +133,6 @@ describe("fetch network error shape", () => { name: "TypeError", message: "fetch failed", stackIsString: true, - stackHasEval: true, code: "ECONNREFUSED", causeCode: "ECONNREFUSED", }, diff --git a/test/js/web/fetch/fetch-http2-client.test.ts b/test/js/web/fetch/fetch-http2-client.test.ts index ca12a6023956..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); }, ); From ef09a2b5869620a632ff72d1a6b0d21db50daee9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:38:46 +0000 Subject: [PATCH 09/15] test: update test-http-should-not-accept-untrusted-certificates for TypeError('fetch failed') shape --- .../test-http-should-not-accept-untrusted-certificates.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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"); } From c750f99a80d71333a98f1cebdac5a8d25cda8de7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:45:51 +0000 Subject: [PATCH 10/15] test: make DNS failure test hermetic (64-byte label rejected locally) --- test/js/web/fetch/fetch-error-shape.test.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index 967f98af1e7c..cdd7a6bafee8 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -64,12 +64,14 @@ describe("fetch network error shape", () => { }); test("DNS failure carries hostname/syscall on cause", async () => { - // Spawn with proxy env cleared so the lookup actually runs. + // 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://does.not.exist.invalid/").catch(e => { + `fetch("http://" + process.env.BAD_HOST + "/").catch(e => { process.stdout.write(JSON.stringify({ name: e.name, message: e.message, @@ -79,18 +81,27 @@ describe("fetch network error shape", () => { })); });`, ], - env: { ...bunEnv, HTTP_PROXY: "", HTTPS_PROXY: "", http_proxy: "", https_proxy: "" }, + 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, ENOTIMP, ...); assert shape only + // 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: "does.not.exist.invalid", + causeHostname: host, causeCode: expect.any(String), }, stderr: "", From ae5afe12393b424fab564d27f28a661fd8f61e1f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:03:27 +0000 Subject: [PATCH 11/15] fetch: skip call-site stack transplant for body-stage errors so the consumer's async frames win; update html-rewriter body-fail matchers for the TypeError shape [skip size check] binary-size baseline is main #79916 (12 commits behind this PR's base, including node:inspector, node:repl, node:quic landings) --- src/jsc/bindings/bindings.cpp | 12 ++++++++---- test/js/workerd/html-rewriter.test.js | 26 ++++++++++++++------------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 6d29ca032237..ea95e563a7d1 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2385,9 +2385,14 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( } } + // Only transplant the fetch() call-site stack for the pre-response + // rejection. A body-stage ('terminated') error reaches the consumer via + // the body stream/promise, whose reject path attaches the awaiter's async + // frames; overwriting those with the fetch() call site would point at the + // wrong line. auto* destInstance = dynamicDowncast(result); JSC::JSValue stackSrc = JSC::JSValue::decode(stackSourceValue); - if (auto* srcInstance = dynamicDowncast(stackSrc); srcInstance && destInstance) { + 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; @@ -2409,11 +2414,10 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( } } - if (destInstance) { + if (destInstance && !terminated) { auto* destTrace = destInstance->stackTrace(); if ((!destTrace || destTrace->isEmpty()) && !result->getDirect(vm, vm.propertyNames->stack)) { - auto header = terminated ? "TypeError: terminated"_s : "TypeError: fetch failed"_s; - result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, String(header)), JSC::PropertyAttribute::DontEnum | 0); + result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, String("TypeError: fetch failed"_s)), JSC::PropertyAttribute::DontEnum | 0); } } 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); }); }); From 002f46563d70811e6a45e8f1c587937015530407 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:05:56 +0000 Subject: [PATCH 12/15] bindings: add exception scope to Bun__createFetchFailedTypeError (materializeErrorInfoIfNeeded can invoke prepareStackTrace); drop redundant using in refusedPort helper [skip size check] --- src/jsc/bindings/bindings.cpp | 32 ++++++++++++--------- test/js/web/fetch/fetch-error-shape.test.ts | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index ea95e563a7d1..819fb1c50620 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2369,6 +2369,7 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( 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, @@ -2385,11 +2386,8 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( } } - // Only transplant the fetch() call-site stack for the pre-response - // rejection. A body-stage ('terminated') error reaches the consumer via - // the body stream/promise, whose reject path attaches the awaiter's async - // frames; overwriting those with the fetch() call site would point at the - // wrong line. + // 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) { @@ -2399,17 +2397,23 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( frames.appendVector(*srcTrace); destInstance->setStackFrames(vm, WTF::move(frames)); } else { - // GC's finalizeUnconditionally may have materialized the source - // (clearing its frame vector) if a captured callee was collected - // before the rejection ran. Fall back to the source's `.stack` - // string, rewriting the header for the outer TypeError. + // 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 (JSC::JSValue srcStack = srcInstance->getDirect(vm, vm.propertyNames->stack); srcStack && srcStack.isString()) { + if (scope.exception()) [[unlikely]] { + scope.clearException(); + } else if (JSC::JSValue srcStack = srcInstance->getDirect(vm, vm.propertyNames->stack); srcStack && srcStack.isString()) { auto str = asString(srcStack)->value(globalObject); - size_t nl = str->find('\n'); - auto frames = nl == WTF::notFound ? WTF::emptyString() : str->substring(nl); - auto header = terminated ? "TypeError: terminated"_s : "TypeError: fetch failed"_s; - result->putDirect(vm, vm.propertyNames->stack, JSC::jsString(vm, makeString(header, frames)), JSC::PropertyAttribute::DontEnum | 0); + if (scope.exception()) [[unlikely]] { + scope.clearException(); + } 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); + } } } } diff --git a/test/js/web/fetch/fetch-error-shape.test.ts b/test/js/web/fetch/fetch-error-shape.test.ts index cdd7a6bafee8..d0224125207a 100644 --- a/test/js/web/fetch/fetch-error-shape.test.ts +++ b/test/js/web/fetch/fetch-error-shape.test.ts @@ -9,7 +9,7 @@ import { bunEnv, bunExe } from "harness"; // Bind an ephemeral port then close it so nothing is listening. function refusedPort(): number { - using server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); const port = server.port; server.stop(true); return port; From afe9fd9cdeff2c25c0d2550e039e96cc733a63af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:21:32 +0000 Subject: [PATCH 13/15] vendor: skip elysia core/stop.test.ts until upstream checks err.code instead of the old Bun-specific message [skip size check] binary-size baseline #79916 is 12 main commits behind this PR's base (node:quic, node:repl, node:inspector landings account for the delta) --- test/vendor.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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." } } ] From 439a4002e74231c89c4e0739b896a1909a458934 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:36:54 +0000 Subject: [PATCH 14/15] bindings: use clearExceptionExceptTermination so a worker-terminate during prepareStackTrace is not swallowed [skip size check] --- src/jsc/bindings/bindings.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 819fb1c50620..8a2f78cbb6fb 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2404,11 +2404,11 @@ extern "C" JSC::EncodedJSValue Bun__createFetchFailedTypeError( // below still runs. srcInstance->materializeErrorInfoIfNeeded(vm); if (scope.exception()) [[unlikely]] { - scope.clearException(); + 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.clearException(); + scope.clearExceptionExceptTermination(); } else { size_t nl = str->find('\n'); auto frames = nl == WTF::notFound ? WTF::emptyString() : str->substring(nl); From c686d674284950db9efe4e8bacefc8fe8ec0fc0a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:56:54 +0000 Subject: [PATCH 15/15] bake deinit fixture: keep the original fire-and-forget .rejects pattern (the await addition was a review nit that changes timing in a deinit stress test) [skip size check] --- test/bake/fixtures/deinitialization/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index f72a50903318..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) { - await expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNRESET" }); + 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 - await expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); + expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); } try {