diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 843ab1e825..fedebd258a 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -424,16 +424,34 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `.length` hot path). Tag check is platform-independent. let recv_tag = blk.lshr(I64, &recv_bits, "48"); let recv_tag_masked = blk.and(I64, &recv_tag, "65533"); // 0xFFFD - let handle_ok = blk.icmp_eq(I64, &recv_tag_masked, "32765"); // 0x7FFD - // SSO receivers fail this guard → route to slow path - // `js_value_length_f64` which has an SSO branch (reads - // length from the tag byte, no heap access). Accepting - // SSO here is safe because the fast path's - // `safe_load_i32_from_ptr(&recv_handle)` would read - // arbitrary bytes at the SSO "pointer" address, but - // the subsequent phi feeds the slow-path result when - // handle_ok is false — so SSO flow is correct via the - // slow path already, no widening needed. + let tag_ok = blk.icmp_eq(I64, &recv_tag_masked, "32765"); // 0x7FFD + // The tag check alone admits POINTER_TAG-boxed *handle-band* + // values — Web Fetch handles (Headers/Request/Response/Blob, id + // in [0x40000, 0xE0000)), net/http small handles, revocable-proxy + // ids — which are NaN-boxed registry ids, NOT heap pointers. A + // value statically typed Array/String/Named that actually holds + // such a handle at runtime (e.g. a `Response`/`Headers` reaching a + // `.length` site) would then `inttoptr` the bare id and load the + // GC-type byte at `id-8` and the length u32 at `id` — both + // unmapped low addresses → SIGSEGV (observed: doctor / mcp list + // crashing at the exact fetch-handle address). The IC-miss path + // (`js_object_get_field_ic_miss`) and the inline class-field guard + // already gate on `> HANDLE_BAND_TOP`; mirror that here so any + // handle-band receiver routes to the `js_value_length_f64` slow + // path, which classifies it by registry without dereferencing the + // raw id. `HANDLE_BAND_TOP` = 0xFFFFF (addr_class::HANDLE_BAND_MAX + // - 1). + let above_band = blk.icmp_ugt(I64, &recv_handle, "1048575"); // 0xFFFFF + let handle_ok = blk.and(I1, &tag_ok, &above_band); + // SSO receivers fail this guard → route to slow path + // `js_value_length_f64` which has an SSO branch (reads + // length from the tag byte, no heap access). Accepting + // SSO here is safe because the fast path's + // `safe_load_i32_from_ptr(&recv_handle)` would read + // arbitrary bytes at the SSO "pointer" address, but + // the subsequent phi feeds the slow-path result when + // handle_ok is false — so SSO flow is correct via the + // slow path already, no widening needed. let check_gc_idx = ctx.new_block("plen.check_gc"); let fast_idx = ctx.new_block("plen.fast"); diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index 091a07fbb2..4eae8d67ec 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -57,6 +57,18 @@ pub extern "C" fn js_value_length_f64(value: f64) -> f64 { // nonsense. if top16 == 0x7FFD { let handle = (bits & POINTER_MASK) as usize; + // A POINTER_TAG value in a handle band (Web Fetch + // Headers/Request/Response/Blob, net/http small handles, zlib stream + // ids, revocable-proxy ids — all `< 0x100000`) is a registry id, not a + // heap pointer. None of them carry a `.length`, and dereferencing the + // raw id (`handle - 8` GcHeader read, or `*handle` u32 below) hits + // unmapped low memory → SIGSEGV. The macOS 2 TB `heap_min` floor below + // masks this, but the Linux/Android/iOS `0x1000` floor does not, so + // reject the band explicitly here. Matches the band gating the inline + // `.length` codegen fast path now applies before falling in here. + if crate::value::addr_class::is_handle_band(handle) { + return 0.0; + } // Heap window: macOS mimalloc lands in 3-5 TB, but Android scudo, // Linux glibc, Windows mimalloc, and iOS-family device // libsystem_malloc all allocate much lower (often hundreds of GB @@ -616,3 +628,41 @@ pub unsafe extern "C" fn js_dynamic_object_keys(ptr: i64) -> *mut crate::array:: pub unsafe extern "C" fn js_get_property(object: f64, name_ptr: i64, name_len: i64) -> f64 { js_dynamic_object_get_property(object, name_ptr as *const i8, name_len as usize) } + +#[cfg(test)] +mod length_handle_band_tests { + use super::*; + use crate::value::addr_class; + + /// A POINTER_TAG-boxed *handle-band* value (Web Fetch + /// Headers/Request/Response/Blob ids, net/http handles, zlib/proxy ids — + /// all `< 0x100000`) is a registry id, not a heap pointer. Reaching the + /// `.length` slow path with such a value must return `0.0` WITHOUT + /// dereferencing the raw id (which would SIGSEGV at the unmapped low + /// address). Regression for the doctor / mcp-list startup crash where a + /// Fetch handle (e.g. `0x40005`) flowed into a `.length` site; the inline + /// codegen fast path now band-gates before falling in here, and this slow + /// path rejects the band explicitly so it is safe on every platform. + #[test] + fn fetch_band_handle_length_is_zero_not_a_deref() { + // Sample one id from each handle sub-band, including the exact band + // boundaries the bug touched. + for &id in &[ + 1usize, // common native handle + addr_class::FETCH_HANDLE_BAND_START, // 0x40000 + addr_class::FETCH_HANDLE_BAND_START + 5, // 0x40005 — the crash addr + addr_class::FETCH_HANDLE_BAND_END - 1, // top of fetch band + addr_class::PROXY_ID_BAND_START, // revocable-proxy id + addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF — last handle-band id + ] { + assert!(addr_class::is_handle_band(id)); + let boxed = crate::value::js_nanbox_pointer(id as i64); + // Must not crash and must report no length. + assert_eq!( + js_value_length_f64(boxed), + 0.0, + "handle-band id {id:#x} must yield length 0 without dereferencing" + ); + } + } +}