diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 032eacd0c94e..080d85803149 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -599,7 +599,10 @@ impl JSGlobalObject { let actual_type = if value.js_type().is_array() { bun_core::ZigString::static_(b"array") } else { - value.js_type_string(self).get_zig_string(self) + match value.js_type_string(self).get_zig_string(self) { + Ok(s) => s, + Err(e) => return e, + } }; self.err( JscError::INVALID_ARG_TYPE, @@ -644,7 +647,10 @@ impl JSGlobalObject { ) -> JsError { // `ZigStringSlice` is RAII: `Owned` frees // its `Vec`, `WTF` derefs the backing `WTFStringImpl` in `Drop`. - let ty_str = value.js_type_string(self).to_slice(self); + let ty_str = match value.js_type_string(self).to_slice(self) { + Ok(s) => s, + Err(e) => return e, + }; self.err( JscError::INVALID_ARG_TYPE, format_args!( diff --git a/src/jsc/JSString.rs b/src/jsc/JSString.rs index bcf409f93cad..aa805e4c6dfb 100644 --- a/src/jsc/JSString.rs +++ b/src/jsc/JSString.rs @@ -33,8 +33,18 @@ impl JSString { JSValue::from_cell(self) } - pub(crate) fn to_zig_string(&self, global: &JSGlobalObject, zig_str: &mut ZigString) { - JSC__JSString__toZigString(self, global, zig_str) + /// Flattening a rope can throw (JSC throws its "Out of memory" `RangeError` + /// when the flat buffer cannot be allocated). The C++ shim returns an empty + /// string in that case and leaves the exception on the VM, so it is observed + /// here, the same way `JSValue::to_zig_string` does. + pub(crate) fn to_zig_string( + &self, + global: &JSGlobalObject, + zig_str: &mut ZigString, + ) -> JsResult<()> { + crate::from_js_host_call_generic(global, || { + JSC__JSString__toZigString(self, global, zig_str) + }) } pub fn ensure_still_alive(&self) { @@ -42,22 +52,20 @@ impl JSString { core::hint::black_box(std::ptr::from_ref::(self)); } - pub fn get_zig_string(&self, global: &JSGlobalObject) -> ZigString { + pub fn get_zig_string(&self, global: &JSGlobalObject) -> JsResult { let mut out = ZigString::init(b""); - self.to_zig_string(global, &mut out); - out + self.to_zig_string(global, &mut out)?; + Ok(out) } #[inline] - pub fn view(&self, global: &JSGlobalObject) -> ZigString { + pub fn view(&self, global: &JSGlobalObject) -> JsResult { self.get_zig_string(global) } /// doesn't always allocate - pub fn to_slice(&self, global: &JSGlobalObject) -> ZigStringSlice { - let mut str = ZigString::init(b""); - self.to_zig_string(global, &mut str); - str.to_slice() + pub fn to_slice(&self, global: &JSGlobalObject) -> JsResult { + Ok(self.get_zig_string(global)?.to_slice()) } // `to_slice_clone` always allocates @@ -66,9 +74,7 @@ impl JSString { // callers a use-after-free once the cell is collected. pub fn to_slice_clone(&self, global: &JSGlobalObject) -> JsResult { - let mut str = ZigString::init(b""); - self.to_zig_string(global, &mut str); - Ok(str.to_slice_clone()) + Ok(self.get_zig_string(global)?.to_slice_clone()) } // `to_slice_z` guarantees a trailing NUL diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ca5a75f82164..63e529d10fc9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6748,13 +6748,19 @@ fn wrap_unhandled_rejection_error_for_uncaught_exception( if reason_str.is_string() { // SAFETY: `as_string()` returns a non-null `*mut JSString` when // `is_string()` is true; `view()` borrows it for the `write!` below. - let view = unsafe { (*reason_str.as_string()).view(global_object) }; - return global_object - .err( - crate::ErrorCode::ERR_UNHANDLED_REJECTION, - format_args!("{MSG_1}{view}\"."), - ) - .to_js(); + match unsafe { (*reason_str.as_string()).view(global_object) } { + Ok(view) => { + return global_object + .err( + crate::ErrorCode::ERR_UNHANDLED_REJECTION, + format_args!("{MSG_1}{view}\"."), + ) + .to_js(); + } + // The reason is a string that cannot be flattened; report it the + // same way as a reason that could not be stringified at all. + Err(_) => global_object.clear_exception(), + } } global_object .err( diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index 248176152965..71bf5518bd9c 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -1455,24 +1455,35 @@ pub mod js_bundler { { resolve.value = ResolveValue::NoMatch; } else { - let global = bv2_plugin(resolve.bv2).global_object(); + let plugin = bv2_plugin(resolve.bv2); + let global = plugin.global_object(); // `to_slice_clone` already heap-allocates; `into_vec` moves that // buffer out instead of allocating a second copy. - let path = path_value - .to_slice_clone(global) - .expect("Unexpected: path is not a string") - .into_vec() - .into_boxed_slice(); - let namespace = namespace_value - .to_slice_clone(global) - .expect("Unexpected: namespace is not a string") - .into_vec() - .into_boxed_slice(); - resolve.value = ResolveValue::Success(ResolveSuccess { - path, - namespace, - external: external_value.to_boolean(), + let strings = path_value.to_slice_clone(global).and_then(|path| { + let namespace = namespace_value.to_slice_clone(global)?; + Ok((path, namespace)) }); + resolve.value = match strings { + Ok((path, namespace)) => ResolveValue::Success(ResolveSuccess { + path: path.into_vec().into_boxed_slice(), + namespace: namespace.into_vec().into_boxed_slice(), + external: external_value.to_boolean(), + }), + // Both values are strings (BundlerPlugin.ts checked), but + // flattening one can still throw. The exception has to be taken + // here: left pending, it rejects `runOnResolvePlugins`, whose + // rejection handler calls `JSBundlerPlugin__addError` and + // answers this same request a second time. Report it the way + // `addError` would have instead. + Err(err) => { + let exception = global.take_exception(err); + ResolveValue::Err(plugin_msg_from_js( + plugin, + &resolve.import_record.source_file, + exception, + )) + } + }; } bv2_mut(resolve.bv2).on_resolve_async(resolve); diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 1be650bff0d6..9db01ce8c2ab 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -8046,7 +8046,7 @@ impl H2FrameParser { } }; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); if let Some(ret) = handle_encode(this, value, never_index)? { @@ -8080,7 +8080,7 @@ impl H2FrameParser { } }; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); bun_output::scoped_log!( H2FrameParser, @@ -8447,7 +8447,7 @@ impl H2FrameParser { }; let mut encode_value = |item: JSValue| -> JsResult> { let value_str = item.to_js_string(global_object)?; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); if !is_valid_header_value(value) { return Err(global_object @@ -8852,7 +8852,7 @@ impl H2FrameParser { } let name_str = name_js.to_js_string(global_object)?; - let name_slice = name_str.to_slice(global_object); + let name_slice = name_str.to_slice(global_object)?; let name = name_slice.slice(); if name.is_empty() { continue; @@ -8920,7 +8920,7 @@ impl H2FrameParser { } }; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); if !is_valid_header_value(value) { return Err(global_object @@ -9087,7 +9087,7 @@ impl H2FrameParser { } }; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); if !is_valid_header_value(value) { return Err(global_object @@ -9157,7 +9157,7 @@ impl H2FrameParser { } }; - let value_slice = value_str.to_slice(global_object); + let value_slice = value_str.to_slice(global_object)?; let value = value_slice.slice(); if !is_valid_header_value(value) { return Err(global_object diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index d6a9b5e20c41..f15a00a26c7d 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -4993,7 +4993,7 @@ impl Resolver { if record_type_str.length() == 0 { break 'brk RecordType::DEFAULT; } - match RECORD_TYPE_MAP.get(record_type_str.to_slice(global_this).slice()) { + match RECORD_TYPE_MAP.get(record_type_str.to_slice(global_this)?.slice()) { Some(r) => *r, None => { return Err(global_this.throw_invalid_argument_property_value( @@ -5186,7 +5186,7 @@ impl Resolver { }; } - let name = name_str.to_slice(global_this); + let name = name_str.to_slice(global_this)?; let resolver = global_resolver(global_this); resolver.do_lookup(name.slice(), port, options, global_this) @@ -5966,7 +5966,7 @@ impl Resolver { "non-empty string", )); } - let addr_slice = addr_str.to_slice(global_this); + let addr_slice = addr_str.to_slice(global_this)?; let addr_s = addr_slice.slice(); let port_value = arguments[1]; diff --git a/src/runtime/ipc.rs b/src/runtime/ipc.rs index 0d97b065e3d1..c66f0a6eba2d 100644 --- a/src/runtime/ipc.rs +++ b/src/runtime/ipc.rs @@ -2022,7 +2022,13 @@ fn import_windows_socket_payload(global: &JSGlobalObject, msg_data: JSValue) -> return None; } }; - let hex = jsc::JSString::opaque_ref(info_value.as_string()).to_slice(global); + let hex = match jsc::JSString::opaque_ref(info_value.as_string()).to_slice(global) { + Ok(hex) => hex, + Err(_) => { + global.clear_exception(); + return None; + } + }; let expected = bun_uws::socket_transfer::bsd_socket_export_size() as usize; let mut info = vec![0u8; expected]; let decoded = strings::decode_hex_to_bytes_truncate(&mut info, hex.slice()); diff --git a/src/runtime/node/node_cluster_binding.rs b/src/runtime/node/node_cluster_binding.rs index c9c22e06194f..77d632eccf63 100644 --- a/src/runtime/node/node_cluster_binding.rs +++ b/src/runtime/node/node_cluster_binding.rs @@ -334,7 +334,7 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js let atype = address_type.to_int32(); let host_owned: Vec = if address.is_string() { - let s = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let s = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global)?; let mut v = s.slice().to_vec(); v.push(0); v @@ -422,7 +422,7 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js let mut is_udp = false; let atype: i32; if address_type.is_string() { - let s = bun_jsc::JSString::opaque_ref(address_type.as_string()).to_slice(global); + let s = bun_jsc::JSString::opaque_ref(address_type.as_string()).to_slice(global)?; is_udp = true; atype = if s.slice() == b"udp6" { 6 } else { 4 }; } else { @@ -447,7 +447,7 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js if !address.is_string() { return Err(global.throw_invalid_argument_type_value("address", "string", address)); } - let path_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let path_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global)?; let path_bytes = path_slice.slice(); // SAFETY: sockaddr_un is plain C data; all-zero is a valid value. let mut sun: libc::sockaddr_un = unsafe { bun_core::ffi::zeroed_unchecked() }; @@ -599,7 +599,7 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js let fd: c_int; let bound_family: c_int; if address.is_string() { - let addr_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global); + let addr_slice = bun_jsc::JSString::opaque_ref(address.as_string()).to_slice(global)?; let addr_bytes = addr_slice.slice(); let mut addr_z: [u8; 256] = [0; 256]; if addr_bytes.len() >= addr_z.len() { diff --git a/src/runtime/node/node_util_binding.rs b/src/runtime/node/node_util_binding.rs index 335d1741b88c..d05c928a771e 100644 --- a/src/runtime/node/node_util_binding.rs +++ b/src/runtime/node/node_util_binding.rs @@ -237,7 +237,7 @@ pub(crate) fn parse_env(global: &JSGlobalObject, frame: &CallFrame) -> JsResult< // `validate_string` accepts StringObject, so coerce to a primitive JSString // before slicing. - let str = content.to_js_string(global)?.to_slice(global); + let str = content.to_js_string(global)?.to_slice(global)?; let mut p = envloader::Loader::init(); p.load_from_string::(str.slice())?; diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index 787f89b44f55..f99077a4d39f 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -115,10 +115,10 @@ pub(crate) fn crc32(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsRe // `is_string_literal()` guarantees `as_string()` is non-null and points to a // live JSString cell on the JSC heap. `JSString` is an `opaque_ffi!` // ZST handle; `opaque_ref` is the centralised deref proof. - break 'blk bun_jsc::JSString::opaque_ref(data.as_string()).to_slice(global_this); + break 'blk bun_jsc::JSString::opaque_ref(data.as_string()).to_slice(global_this)?; } let Some(buffer) = data.as_array_buffer(global_this) else { - let ty_str = data.js_type_string(global_this).to_slice(global_this); + let ty_str = data.js_type_string(global_this).to_slice(global_this)?; // ty_str drops at end of scope return Err(global_this .err( diff --git a/src/runtime/node/util/validators.rs b/src/runtime/node/util/validators.rs index a256a750aa1f..4df9f45356f7 100644 --- a/src/runtime/node/util/validators.rs +++ b/src/runtime/node/util/validators.rs @@ -3,10 +3,10 @@ use core::fmt; use bun_core::ZigString; use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsError, JsResult}; -fn get_type_name(global_object: &JSGlobalObject, value: JSValue) -> ZigString { +fn get_type_name(global_object: &JSGlobalObject, value: JSValue) -> JsResult { let js_type = value.js_type(); if js_type.is_array() { - return ZigString::static_("array"); + return Ok(ZigString::static_("array")); } value .js_type_string(global_object) @@ -43,7 +43,10 @@ pub(crate) fn throw_err_invalid_arg_type( expected_type: &str, value: JSValue, ) -> JsError { - let actual_type = get_type_name(global_this, value); + let actual_type = match get_type_name(global_this, value) { + Ok(actual_type) => actual_type, + Err(e) => return e, + }; throw_err_invalid_arg_type_with_message( global_this, format_args!( @@ -395,7 +398,7 @@ pub(crate) fn validate_array( min_length: Option, ) -> JsResult<()> { if !value.js_type().is_array() { - let actual_type = get_type_name(global_this, value); + let actual_type = get_type_name(global_this, value)?; return Err(throw_err_invalid_arg_type_with_message( global_this, format_args!( diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 8c01305a3f4c..af329c3049ec 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -882,7 +882,7 @@ impl ServerWebSocket { { let js_string = message_value.to_js_string(global_this)?; - let view = js_string.view(global_this); + let view = js_string.view(global_this)?; let slice = view.to_slice(); let ret = self.do_publish( @@ -936,7 +936,7 @@ impl ServerWebSocket { } let js_string = message_value.to_js_string(global_this)?; - let view = js_string.view(global_this); + let view = js_string.view(global_this)?; let slice = view.to_slice(); let ret = self.do_publish( @@ -1121,7 +1121,7 @@ impl ServerWebSocket { { let js_string = message_value.to_js_string(global_this)?; - let view = js_string.view(global_this); + let view = js_string.view(global_this)?; let slice = view.to_slice(); let buffer = slice.slice(); @@ -1166,7 +1166,7 @@ impl ServerWebSocket { } let js_string = message_value.to_js_string(global_this)?; - let view = js_string.view(global_this); + let view = js_string.view(global_this)?; let slice = view.to_slice(); let buffer = slice.slice(); @@ -1287,7 +1287,7 @@ impl ServerWebSocket { return Ok(ret); } else if value.is_string() { // SAFETY: to_js_string returns a non-null *mut JSString on the Ok path. - let string_value = value.to_js_string(global_this)?.to_slice(global_this); + let string_value = value.to_js_string(global_this)?.to_slice(global_this)?; let buffer = string_value.slice(); if buffer.len() > MAX_CONTROL_FRAME_PAYLOAD { return Err(throw_control_frame_too_large(global_this, buffer.len())); diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index eebed3c859dc..c7815d0eb9c4 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1720,7 +1720,7 @@ where { let js_string = message_value.to_js_string(global)?; - let view = js_string.view(global); + let view = js_string.view(global)?; let slice = view.to_slice(); // Keep `js_string` alive, not `message_value`: // when the input was not already a JSString, `to_js_string` allocates diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index c5d6551c63a1..f22a0510f580 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -1431,7 +1431,7 @@ impl UDPSocket { // plain cast (no `toPrimitive`, no user JS). `JSString` is an // `opaque_ffi!` ZST — `opaque_ref` is the safe deref. string_slices - .push(bun_jsc::JSString::opaque_ref(val.as_string()).to_slice(global_this)); + .push(bun_jsc::JSString::opaque_ref(val.as_string()).to_slice(global_this)?); break 'brk string_slices.last().unwrap().slice(); }; payloads[slice_idx] = slice.as_ptr(); @@ -1528,7 +1528,9 @@ impl UDPSocket { // and `this.socket orelse throw` below handles a // close-during-`toPrimitive`. // SAFETY: to_js_string returned non-null on success path. - payload_str = payload_arg.to_js_string(global_this)?.to_slice(global_this); + payload_str = payload_arg + .to_js_string(global_this)? + .to_slice(global_this)?; break 'brk payload_str.slice(); } else { return Err(global_this.throw_invalid_arguments(format_args!( diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index cf95b89cca37..6068977f8004 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -1388,7 +1388,11 @@ impl Expect { let type_name = if matcher_fn.is_null() { bun_core::String::static_("null") } else { - bun_core::String::init(matcher_fn.js_type_string(global_this).get_zig_string(global_this)) + bun_core::String::init( + matcher_fn + .js_type_string(global_this) + .get_zig_string(global_this)?, + ) }; return Err(global_this.throw_invalid_arguments(format_args!( "expect.extend: `{}` is not a valid matcher. Must be a function, is \"{}\"", diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index b2a479cb66e2..25612a010146 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -276,7 +276,7 @@ impl SubscriptionCtx { // `JSString` is an `opaque_ffi!` ZST — `opaque_ref` is the safe // deref (`as_string()` returns a live cell for string values). bun_jsc::JSString::opaque_ref(channel_name.as_string()) - .get_zig_string(global_object) + .get_zig_string(global_object)? ); return Ok(()); }; diff --git a/src/runtime/webcore/Sink.rs b/src/runtime/webcore/Sink.rs index 7d75f6bf210e..167e926cf894 100644 --- a/src/runtime/webcore/Sink.rs +++ b/src/runtime/webcore/Sink.rs @@ -496,7 +496,7 @@ impl JSSink { } let str_ = arg.to_js_string(global)?; - let view = str_.view(global); + let view = str_.view(global)?; if view.is_empty() { return Ok(JSValue::js_number(0.0)); } diff --git a/src/semver_jsc/SemverObject.rs b/src/semver_jsc/SemverObject.rs index 216eff5ebb29..ddd8266e3fb5 100644 --- a/src/semver_jsc/SemverObject.rs +++ b/src/semver_jsc/SemverObject.rs @@ -28,8 +28,8 @@ fn order(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { let left_string = arguments[0].to_js_string(global)?; let right_string = arguments[1].to_js_string(global)?; - let left = left_string.to_slice(global); - let right = right_string.to_slice(global); + let left = left_string.to_slice(global)?; + let right = right_string.to_slice(global)?; if !strings::is_all_ascii(left.slice()) { return Ok(JSValue::js_number_from_int32(0)); @@ -77,8 +77,8 @@ fn satisfies(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { let left_string = arguments[0].to_js_string(global)?; let right_string = arguments[1].to_js_string(global)?; - let left = left_string.to_slice(global); - let right = right_string.to_slice(global); + let left = left_string.to_slice(global)?; + let right = right_string.to_slice(global)?; if !strings::is_all_ascii(left.slice()) { return Ok(JSValue::FALSE); diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 0b9ff951d5dd..b9b62947283b 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -234,6 +234,49 @@ describe("bundler", () => { }, }); + // A 16-bit rope string of the maximum length (2^31 - 1 chars). Building it + // only allocates rope nodes, but a flat 16-bit buffer of that length fails + // WTF::StringImpl::isValidLength, so the first thing to read the string gets + // JSC's "Out of memory" RangeError without any allocation being attempted. + // Returned from onResolve with `external: true`, nothing on the JS side reads + // it, so the first read is the bundler converting the result to bytes. That + // used to leave the exception pending and answer the resolve with an empty + // string. + function unflattenableString() { + let str = "\u0100"; + for (let i = 0; i < 30; i++) str = str + str + "\u0100"; + expect(str.length).toBe(2 ** 31 - 1); + return str; + } + itBundled("plugin/ResolvePathStringConversionThrows", { + files: resolveFixture, + plugins(builder) { + builder.onResolve({ filter: /\.magic$/ }, () => { + return { path: unflattenableString(), external: true }; + }); + }, + bundleErrors: { + "/index.ts": [`Out of memory`], + }, + onAfterApiBundle(build) { + expect(build.success).toBe(false); + }, + }); + itBundled("plugin/ResolveNamespaceStringConversionThrows", { + files: resolveFixture, + plugins(builder) { + builder.onResolve({ filter: /\.magic$/ }, () => { + return { path: "foo", namespace: unflattenableString(), external: true }; + }); + }, + bundleErrors: { + "/index.ts": [`Out of memory`], + }, + onAfterApiBundle(build) { + expect(build.success).toBe(false); + }, + }); + // itBundled("plugin/ResolvePrefix", ({ root }) => { let onResolveCount = 0; diff --git a/test/js/bun/websocket/websocket-server.test.ts b/test/js/bun/websocket/websocket-server.test.ts index 2043fd4a1ed3..97f1b2ae8515 100644 --- a/test/js/bun/websocket/websocket-server.test.ts +++ b/test/js/bun/websocket/websocket-server.test.ts @@ -1580,6 +1580,68 @@ it("server.upgrade() with Sec-WebSocket-Protocol in options.headers does not use expect(exitCode).toBe(0); }); +it("send()/publish() with a string that cannot be flattened throw and send nothing", async () => { + // A 16-bit rope string of the maximum length (2^31 - 1 chars). Building it + // only allocates rope nodes, but a flat 16-bit buffer of that length fails + // WTF::StringImpl::isValidLength, so reading the string throws JSC's + // "Out of memory" RangeError without any allocation being attempted. The + // native side used to ignore that exception, write an empty text frame to + // the peer, and only then let the exception surface. + let huge = "\u0100"; + for (let i = 0; i < 30; i++) huge = huge + huge + "\u0100"; + expect(huge.length).toBe(2 ** 31 - 1); + + // Each entry is either what the call returned or { name, message } of what it threw. + const outcomes: unknown[] = []; + await using server = serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req, srv) { + if (srv.upgrade(req)) return; + return new Response("no", { status: 400 }); + }, + websocket: { + publishToSelf: true, + open(ws) { + ws.subscribe("topic"); + for (const attempt of [ + () => ws.send(huge), + () => ws.sendText(huge), + () => ws.publish("topic", huge), + () => ws.publishText("topic", huge), + () => server.publish("topic", huge), + ]) { + try { + outcomes.push(attempt()); + } catch (e) { + outcomes.push({ name: (e as Error).name, message: (e as Error).message }); + } + } + ws.send("done"); + }, + message() {}, + }, + }); + + const received: string[] = []; + const done = Promise.withResolvers(); + const client = new WebSocket(`ws://127.0.0.1:${server.port}/`); + client.onmessage = e => { + received.push(e.data); + if (e.data === "done") done.resolve(); + }; + client.onerror = () => done.reject(new Error("client websocket errored")); + client.onclose = () => done.reject(new Error("client websocket closed before receiving 'done'")); + await done.promise; + client.onclose = null; + client.close(); + + expect({ received, outcomes }).toEqual({ + received: ["done"], + outcomes: Array(5).fill({ name: "RangeError", message: "Out of memory" }), + }); +}); + // publish() fans out to N subscribers and must report backpressure/drops the // same way ws.send() does for a single socket. describe.concurrent("publish() return value reflects subscriber backpressure", () => {