Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions crates/perry-ext-fastify/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<u8>, 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::<StringHeader>());
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
66 changes: 66 additions & 0 deletions crates/perry-ext-fastify/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading