diff --git a/crates/perry-ext-fastify/src/context.rs b/crates/perry-ext-fastify/src/context.rs index febc7067e8b..12be662a474 100644 --- a/crates/perry-ext-fastify/src/context.rs +++ b/crates/perry-ext-fastify/src/context.rs @@ -13,6 +13,14 @@ use perry_ffi::{ const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; +/// Mirror of `perry_runtime::string::STRING_FLAG_HAS_LONE_SURROGATES` — the +/// `StringHeader::flags` bit marking a WTF-8 string that holds lone surrogates +/// (so it isn't well-formed UTF-8). Defined locally because this crate's +/// library code links only `perry-ffi`, not `perry-runtime` (same local-mirror +/// pattern as the `extern "C"` runtime symbols below); the value is part of the +/// stable runtime ABI. +const STRING_FLAG_HAS_LONE_SURROGATES: u32 = 1; + // Runtime symbols that perry-ffi hasn't yet wrapped — these are // stable `extern "C"` exports from perry-runtime. Declaring them // locally is the same pattern perry-ext-{net,http,ws} use for the @@ -637,6 +645,35 @@ pub unsafe extern "C" fn js_fastify_ctx_redirect(ctx_handle: Handle, url: i64, s pub(crate) unsafe fn jsvalue_to_response_body(value: f64) -> (Vec, BodyKind) { let jsv = JsValue::from_bits(value.to_bits()); if jsv.is_string() { + // Fast path: copy the StringHeader's bytes straight into the response + // Vec, skipping the `extract_jsvalue_string` round-trip + // (`String::from_utf8_lossy(..).to_string().into_bytes()` — one extra + // heap allocation plus a redundant UTF-8 validity scan). The body + // writer streams the bytes out unchanged, so the intermediate `String` + // buys nothing on the common text-response path. The pointer math + // mirrors `string_from_header`. + // + // Only well-formed UTF-8 is safe to copy verbatim. Perry strings are + // WTF-8 and may carry lone surrogates (flagged + // `STRING_FLAG_HAS_LONE_SURROGATES`); copying those raw would emit + // invalid UTF-8 in the response. For flagged strings fall through to + // the `extract_jsvalue_string` path, whose `from_utf8_lossy` substitutes + // U+FFFD — preserving the pre-fast-path output. + let ptr = js_get_string_pointer_unified(value); + if ptr != 0 { + let header = ptr as *const StringHeader; + let well_formed = (*header).flags & STRING_FLAG_HAS_LONE_SURROGATES == 0; + if well_formed { + let len = (*header).byte_len as usize; + let data_ptr = (header as *const u8).add(std::mem::size_of::()); + let mut bytes = Vec::with_capacity(len); + bytes.extend_from_slice(std::slice::from_raw_parts(data_ptr, len)); + return (bytes, BodyKind::TextOrJson); + } + } + // Fallback to the lossy `String` round-trip — taken for WTF-8 strings + // with lone surrogates, and defensively if the unified pointer accessor + // returns 0 (should not happen for an `is_string()` value). if let Some(s) = extract_jsvalue_string(value) { return (s.into_bytes(), BodyKind::TextOrJson); } diff --git a/crates/perry-ext-fastify/src/lib.rs b/crates/perry-ext-fastify/src/lib.rs index 5a0de2ac6d7..e14952113e0 100644 --- a/crates/perry-ext-fastify/src/lib.rs +++ b/crates/perry-ext-fastify/src/lib.rs @@ -801,4 +801,70 @@ mod tests { assert_eq!(pending.method, "POST"); assert_eq!(pending.path, "/users/42"); } + + /// The string fast path in `jsvalue_to_response_body` copies the + /// `StringHeader` bytes directly, bypassing the `extract_jsvalue_string` + /// round-trip. It must reproduce the source bytes exactly — including + /// multi-byte UTF-8 — and tag the body `TextOrJson`. + #[test] + fn jsvalue_to_response_body_string_fast_path() { + let _guard = GcTestGuard::new(); + // SAFETY: `alloc_string` returns a live `StringHeader`; we NaN-box it and + // read it back synchronously with no intervening GC. + unsafe { + for sample in ["Status: UP [200]", "héllo → 世界 🚀", ""] { + let s = perry_ffi::alloc_string(sample); + assert!(!s.is_null(), "alloc_string returned null for {sample:?}"); + let value = f64::from_bits(perry_ffi::nanbox_string_bits(s.as_raw())); + + let (bytes, kind) = crate::context::jsvalue_to_response_body(value); + assert_eq!(bytes, sample.as_bytes(), "byte-exact round-trip"); + assert_eq!(kind, crate::context::BodyKind::TextOrJson); + } + } + } + + /// A WTF-8 string carrying a lone surrogate must NOT take the verbatim-copy + /// fast path (that would emit invalid UTF-8). It has to fall back to the + /// lossy `String` round-trip, substituting U+FFFD — exactly the + /// pre-fast-path behaviour. + #[test] + fn jsvalue_to_response_body_lone_surrogate_falls_back_to_lossy() { + let _guard = GcTestGuard::new(); + unsafe { + // "hi" + lone surrogate U+D800 (WTF-8: ED A0 80) + "!". + let wtf8: &[u8] = b"hi\xED\xA0\x80!"; + let ptr = + perry_runtime::string::js_string_from_wtf8_bytes(wtf8.as_ptr(), wtf8.len() as u32); + assert!(!ptr.is_null()); + // The constructor flags the string as carrying lone surrogates, so the + // fast path must skip it (otherwise this test wouldn't exercise the guard). + assert_ne!( + (*ptr).flags & perry_runtime::string::STRING_FLAG_HAS_LONE_SURROGATES, + 0, + "expected js_string_from_wtf8_bytes to flag the lone surrogate" + ); + // The two `StringHeader` types are layout-identical across crates. + let value = f64::from_bits(perry_ffi::nanbox_string_bits( + ptr as *mut perry_ffi::StringHeader, + )); + + let (bytes, kind) = crate::context::jsvalue_to_response_body(value); + // Output is the lossy form (valid UTF-8), not the raw WTF-8 bytes. + assert_eq!( + bytes, + String::from_utf8_lossy(wtf8).into_owned().into_bytes() + ); + assert!( + std::str::from_utf8(&bytes).is_ok(), + "response body must be valid UTF-8" + ); + assert_ne!( + bytes.as_slice(), + wtf8, + "raw WTF-8 bytes must not be emitted verbatim" + ); + assert_eq!(kind, crate::context::BodyKind::TextOrJson); + } + } }