diff --git a/rust_backend/crates/extensions/src/file_host.rs b/rust_backend/crates/extensions/src/file_host.rs index 5d345132..3dd6a376 100644 --- a/rust_backend/crates/extensions/src/file_host.rs +++ b/rust_backend/crates/extensions/src/file_host.rs @@ -107,7 +107,7 @@ fn call<'js>( return Err(READ_LIMIT_ERROR.into()); } result - .set("data", crate::host::decode_go_utf8(&bytes)) + .set("data", crate::host::decode_go_utf8_owned(bytes)) .map_err(js_error)?; } else { input diff --git a/rust_backend/crates/extensions/src/host.rs b/rust_backend/crates/extensions/src/host.rs index 40b703c7..2d873f2b 100644 --- a/rust_backend/crates/extensions/src/host.rs +++ b/rust_backend/crates/extensions/src/host.rs @@ -160,17 +160,14 @@ pub(crate) fn register<'js>( } }) .unwrap_or_default(); - decode_go_utf8(&decoded) + decode_go_utf8_owned(decoded) })?, )?; host.set( "encode", Function::new(ctx.clone(), |value: String| value.into_bytes())?, )?; - host.set( - "decode", - Function::new(ctx.clone(), |value: Vec| decode_go_utf8(&value))?, - )?; + host.set("decode", Function::new(ctx.clone(), decode_go_utf8_owned)?)?; host.set( "md5", Function::new(ctx.clone(), |value: String| { @@ -250,6 +247,15 @@ fn storage_result(result: Result<(), StorageError>) -> String { // Go's UTF-8 decoder consumes one byte for every invalid rune. Rust's lossy // conversion groups some invalid prefixes, which would change extension text. +pub(crate) fn decode_go_utf8_owned(bytes: Vec) -> String { + // Text responses/files already own their buffer. Reuse it for valid UTF-8 + // instead of holding a second full copy while QuickJS creates its string. + match String::from_utf8(bytes) { + Ok(text) => text, + Err(error) => decode_go_utf8(error.as_bytes()), + } +} + pub(crate) fn decode_go_utf8(mut bytes: &[u8]) -> String { let mut decoded = String::with_capacity(bytes.len()); while !bytes.is_empty() { @@ -272,6 +278,54 @@ pub(crate) fn decode_go_utf8(mut bytes: &[u8]) -> String { #[cfg(test)] mod tests { + #[test] + #[ignore = "manual release-process peak-memory measurement"] + fn benchmark_owned_text_memory() { + let bytes = vec![b'x'; spotiflac_network::MAX_RESPONSE_BYTES]; + match std::env::var("SPOTIFLAC_TEXT_MEMORY_MODE").as_deref() { + Ok("copy") => { + let text = super::decode_go_utf8(&bytes); + assert_eq!(text.len(), bytes.len()); + std::hint::black_box((&bytes, &text)); + } + Ok("reuse") => { + let pointer = bytes.as_ptr(); + let text = super::decode_go_utf8_owned(bytes); + assert_eq!(text.as_ptr(), pointer); + assert_eq!(text.len(), spotiflac_network::MAX_RESPONSE_BYTES); + std::hint::black_box(&text); + } + _ => panic!("set SPOTIFLAC_TEXT_MEMORY_MODE to copy or reuse"), + } + } + + #[test] + fn owned_text_reuses_large_buffers_and_preserves_invalid_byte_decoding() { + let bytes = "Music 音楽 🎵\0".repeat(128 * 1024).into_bytes(); + let pointer = bytes.as_ptr(); + let capacity = bytes.capacity(); + let text = super::decode_go_utf8_owned(bytes); + assert_eq!(text.as_ptr(), pointer); + assert_eq!(text.capacity(), capacity); + assert_eq!(text, "Music 音楽 🎵\0".repeat(128 * 1024)); + for bytes in [ + &b""[..], + b"a\xffb", + b"\xe2\x82", + b"\xed\xa0\x80", + b"\xf0\x9f\x8e\xb5\0\xff", + ] { + assert_eq!( + super::decode_go_utf8_owned(bytes.to_vec()), + super::decode_go_utf8(bytes), + ); + } + assert_eq!( + super::decode_go_utf8_owned(vec![0xe2, 0x82]), + "\u{fffd}\u{fffd}" + ); + } + #[test] fn digest_upgrade_preserves_extension_hash_and_hmac_encodings() { let runtime = crate::ExtensionRuntime::load( diff --git a/rust_backend/crates/extensions/src/network_host.rs b/rust_backend/crates/extensions/src/network_host.rs index a72d36cb..6a204f0f 100644 --- a/rust_backend/crates/extensions/src/network_host.rs +++ b/rust_backend/crates/extensions/src/network_host.rs @@ -172,7 +172,7 @@ fn response_object<'js>( object.set("bytes", TypedArray::new_copy(ctx.clone(), response.body)?)?; } else { object.set("statusCode", response.status)?; - object.set("body", decode_go_utf8(&response.body))?; + object.set("body", crate::host::decode_go_utf8_owned(response.body))?; } Ok(object) } @@ -192,3 +192,47 @@ fn encode_buffer(bytes: &TypedArray<'_, u8>) -> String { let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default(); base64::engine::general_purpose::STANDARD.encode(bytes) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_response_preserves_status_headers_unicode_and_invalid_bytes() { + let runtime = rquickjs::Runtime::new().unwrap(); + let context = rquickjs::Context::full(&runtime).unwrap(); + context.with(|ctx| { + for body in ["Music 音楽 🎵\0".as_bytes(), &b"a\xe2\x82b"[..]] { + let response = response_object( + &ctx, + HttpResponse { + status: 201, + status_text: "Created".into(), + url: "https://example.invalid/metadata".into(), + headers: BTreeMap::from([( + "Content-Type".into(), + vec!["text/plain".into()], + )]), + body: body.to_vec(), + }, + false, + ) + .unwrap(); + assert_eq!( + response.get::<_, String>("body").unwrap(), + decode_go_utf8(body) + ); + assert_eq!(response.get::<_, u16>("statusCode").unwrap(), 201); + assert!(response.get::<_, bool>("ok").unwrap()); + assert_eq!( + response + .get::<_, Object>("headers") + .unwrap() + .get::<_, String>("Content-Type") + .unwrap(), + "text/plain", + ); + } + }); + } +}