From aa5d439276c9d038bd4bdef27ce1750375a1c01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 22 Jun 2026 20:05:05 +0200 Subject: [PATCH] fix(codegen): inline .length fast path must reject handle-band receivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline `.length` codegen path (property_get.rs) validated only the NaN-box TAG of the receiver (top16 == POINTER_TAG/STRING_TAG) before `inttoptr`-ing the low-48 payload and dereferencing the GC-type byte at `handle-8` and the length u32 at `handle`. A POINTER_TAG-boxed *handle-band* value — a Web Fetch handle (Headers/Request/Response/Blob, registry id in [0x40000, 0xE0000)), net/http small handle, zlib/proxy id — passes the tag check but is a registry id, NOT a heap pointer. When a value statically typed Array/String/Named actually held such a handle at runtime and reached a `.length` site, the path dereferenced the bare id at an unmapped low address and SIGSEGVd (EXC_BAD_ACCESS). lldb on a minimal repro (`(new Headers() as string[]).length`): faults on `ldurb w9, [x8, #-0x8]` with x8 = 0x40000 (FETCH_HANDLE_BAND_START) and the receiver NaN-box tag x9 = 0x7ffd... — i.e. the GC-type probe at handle-8. The exact shape of a stripped-bundle crash whose fault address was 0x40005 (the 6th fetch handle); non-deterministic because whether handle-8 is mapped (so flow reaches the length load at `handle` itself) and what GC-type byte it reads depends on page-layout/timing — sometimes routing safely to the slow path (clean rc=1) instead. Fix: gate the inline fast path on `recv_handle > HANDLE_BAND_TOP` (0xFFFFF) in addition to the tag check, mirroring js_object_get_field_ic_miss and the inline class-field guard. Handle-band receivers now route to js_value_length_f64, which classifies by registry without a raw deref. Also harden that slow path: reject the handle band (is_handle_band) before its POINTER_TAG deref so it is safe on Linux/Android/iOS too (the macOS 2 TB heap_min floor previously masked this; the 0x1000 floor elsewhere did not). Verified: minimal repro crashes rc=139 pre-fix, returns len=0 rc=0 post-fix (lldb status=0). New regression test covers handle-band ids across every sub-band. perry-runtime lib tests 1073 pass; fmt + check clean. Independent of the alias-new fix (a9e41e163), which merely advanced doctor far enough to execute the .length access on a Fetch-typed value. --- crates/perry-codegen/src/expr/property_get.rs | 38 ++++++++++---- .../perry-runtime/src/value/dynamic_object.rs | 50 +++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) 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" + ); + } + } +}