Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/http/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
robobun marked this conversation as resolved.
Self::Picohttp(bun_picohttp::ParseResponseError::MalformedHttpResponse) => {
"HPE_INVALID_CONSTANT"
}
Comment thread
robobun marked this conversation as resolved.
Self::Cert(e) => <&'static str>::from(e),
Self::Sys(e) => <&'static str>::from(e),
_ => self.name(),
}
}
}

impl From<bun_zlib::ZlibError> for Error {
fn from(e: bun_zlib::ZlibError) -> Self {
match e {
Expand Down
75 changes: 75 additions & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
auto* destInstance = dynamicDowncast<JSC::ErrorInstance>(result);
JSC::JSValue stackSrc = JSC::JSValue::decode(stackSourceValue);
if (auto* srcInstance = dynamicDowncast<JSC::ErrorInstance>(stackSrc); srcInstance && destInstance && !terminated) {
if (auto* srcTrace = srcInstance->stackTrace(); srcTrace && !srcTrace->isEmpty()) {
// Copy: source may be shared across a cloned Response body.
WTF::Vector<JSC::StackFrame> 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.
Comment thread
robobun marked this conversation as resolved.
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Comment thread
robobun marked this conversation as resolved.
}
}

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;
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,12 @@ impl From<JsError> 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]
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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)`).
Expand All @@ -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(_) => {}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/webcore/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2097,6 +2097,10 @@ fn fetch_impl<const ALLOW_GET_BODY: bool>(
} 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()),
};

Expand Down
34 changes: 24 additions & 10 deletions src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
robobun marked this conversation as resolved.
pub caller_stack_source: StrongOptional,

// custom checkServerIdentity
pub check_server_identity: StrongOptional,
pub reject_unauthorized: bool,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Comment thread
claude[bot] marked this conversation as resolved.

// 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
Expand All @@ -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_(
Expand Down Expand Up @@ -1562,7 +1569,11 @@ impl FetchTasklet {
..Default::default()
};

BodyValueError::SystemError(fetch_error)
BodyValueError::FetchFailed {
cause: fetch_error,
terminated,
stack_source,
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

pub(crate) fn on_readable_stream_available(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2564,6 +2576,7 @@ pub struct FetchOptions {
// Custom Hostname
pub hostname: Option<Box<[u8]>>,
pub check_server_identity: StrongOptional,
pub caller_stack_source: StrongOptional,
pub unix_socket_path: ZigStringSlice,
pub ssl_config: Option<http::ssl_config::SharedPtr>,
pub upgraded_connection: bool,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions test/bake/fixtures/deinitialization/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions test/js/bun/http/bun-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}

{
Expand Down
11 changes: 8 additions & 3 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?");
Comment thread
robobun marked this conversation as resolved.
}
Loading
Loading