Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
47 changes: 47 additions & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2353,6 +2353,53 @@ 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 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<JSC::ErrorInstance>(stackSrc)) {
if (auto* destInstance = dynamicDowncast<JSC::ErrorInstance>(result)) {
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));
}
Comment thread
robobun marked this conversation as resolved.
}
}

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
30 changes: 23 additions & 7 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 @@ -1304,6 +1309,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 +1331,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 +1571,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 +1910,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 +2578,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 +2615,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.toThrow("fetch failed");
} 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.toThrow("fetch failed");
Comment thread
robobun marked this conversation as resolved.
Outdated
}

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.
}
20 changes: 15 additions & 5 deletions test/js/bun/util/error-name-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
});
});
});
2 changes: 1 addition & 1 deletion test/js/web/fetch/chunked-trailing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});
Loading
Loading