diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index a1366f7403..d916211ed7 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -1193,21 +1193,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::ArrayEntries(arr) => { let arr_box = lower_expr(ctx, arr)?; let blk = ctx.block(); - let arr_handle = unbox_to_i64(blk, &arr_box); + // Full bits, not the 48-bit mask: the runtime router classifies + // non-pointer receivers (Web Streams handle ids are plain + // doubles whose masked bits look like heap addresses). + let arr_handle = blk.bitcast_double_to_i64(&arr_box); let result = blk.call(I64, "js_array_entries_iter_obj", &[(I64, &arr_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } Expr::ArrayKeys(arr) => { let arr_box = lower_expr(ctx, arr)?; let blk = ctx.block(); - let arr_handle = unbox_to_i64(blk, &arr_box); + let arr_handle = blk.bitcast_double_to_i64(&arr_box); let result = blk.call(I64, "js_array_keys_iter_obj", &[(I64, &arr_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } Expr::ArrayValues(arr) => { let arr_box = lower_expr(ctx, arr)?; let blk = ctx.block(); - let arr_handle = unbox_to_i64(blk, &arr_box); + let arr_handle = blk.bitcast_double_to_i64(&arr_box); let result = blk.call(I64, "js_array_values_iter_obj", &[(I64, &arr_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index 9eea66b4f3..6f034a3a74 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -997,7 +997,10 @@ pub(crate) fn lower_array_method( let _ = lower_expr(ctx, a)?; } let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); + // Full bits, not the 48-bit mask: the runtime router classifies + // non-pointer receivers (Web Streams handle ids are plain + // doubles whose masked bits look like heap addresses). + let recv_handle = blk.bitcast_double_to_i64(&recv_box); let result = blk.call(I64, "js_array_entries_iter_obj", &[(I64, &recv_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } @@ -1006,7 +1009,7 @@ pub(crate) fn lower_array_method( let _ = lower_expr(ctx, a)?; } let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); + let recv_handle = blk.bitcast_double_to_i64(&recv_box); let result = blk.call(I64, "js_array_keys_iter_obj", &[(I64, &recv_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } @@ -1015,7 +1018,7 @@ pub(crate) fn lower_array_method( let _ = lower_expr(ctx, a)?; } let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); + let recv_handle = blk.bitcast_double_to_i64(&recv_box); let result = blk.call(I64, "js_array_values_iter_obj", &[(I64, &recv_handle)]); Ok(nanbox_pointer_inline(blk, &result)) } diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 054fb5f5ca..6019a4730c 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -358,9 +358,106 @@ unsafe fn collection_iter_obj_for_receiver(arr: *const ArrayHeader, kind: u8) -> } } +/// Receiver router for the any-typed `.values()/.keys()/.entries()` fold +/// (#597). Codegen passes the receiver's FULL NaN-box bits (bitcast, no +/// 48-bit mask) so a non-pointer receiver stays distinguishable from a heap +/// address. Before this, a Web Streams handle id (a raw numeric f64, band +/// `0x100000+`, #1545) was masked to `(id - 2^20) * 2^32`; once the id +/// offset crossed 512 that "address" passed the macOS 2 TB heap floor and +/// the registry shape probes dereferenced unmapped memory (the gscmaster +/// request-12 SIGSEGV family; on Linux the 0x1000 floor makes low ids probe +/// low memory immediately). Raw heap pointers — runtime-internal callers +/// and objects compiled before the codegen change — arrive with top16 == 0 +/// and keep the legacy path bit-for-bit. +enum IterReceiver { + Ptr(*const ArrayHeader), + Done(i64), +} + +unsafe fn route_iter_obj_receiver(arr: *const ArrayHeader, kind: u8) -> IterReceiver { + let bits = arr as u64; + let top16 = bits >> 48; + if top16 == 0 { + // Runtime-internal callers (and objects from pre-change codegen) + // pass raw heap pointers here. `0.0` and denormal-range doubles + // share the untagged shape — only a plausible heap address may be + // treated as a pointer, so `(0 as any).entries()` reaches the + // TypeError below instead of dereferencing null (#6599 review). + if crate::value::addr_class::is_plausible_heap_addr(bits as usize) { + return IterReceiver::Ptr(arr); + } + } + let masked = (bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader; + if top16 == 0x7FFD { + return IterReceiver::Ptr(masked); + } + if top16 == 0x7FFC { + // undefined (1) / null (2): keep the small payload so + // `guard_coercible_this` renders its coercibility TypeError. + // Booleans (3/4) are ordinary non-iterable primitives — they used + // to fall through to the junk-pointer deref path; route them to + // the TypeError below instead (#6599 review). + let payload = masked as usize; + if payload == 1 || payload == 2 { + return IterReceiver::Ptr(masked); + } + } + let method: &[u8] = match kind { + 1 => b"keys", + 2 => b"entries", + _ => b"values", + }; + // A Web Streams handle is a plain finite whole-number f64 that owns the + // requested method — route it through the dynamic dispatch that reaches + // the stdlib stream arms (mirrors the fetch-band block in + // `collection_iter_obj_for_receiver`; `js_readable_stream_values` + // returns a heap iterator object, so the pointer extraction below is + // sound). + let value = f64::from_bits(bits); + if value.is_finite() && value > 0.0 && value.fract() == 0.0 { + if let Some(probe) = crate::object::stream_handle_probe() { + if probe(value as usize) { + let result = crate::object::js_native_call_method( + value, + method.as_ptr() as *const i8, + method.len(), + std::ptr::null(), + 0, + ); + let rv = JSValue::from_bits(result.to_bits()); + if !rv.is_undefined() && !rv.is_null() { + let ptr = (result.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + if ptr != 0 { + return IterReceiver::Done(ptr); + } + } + } + } + } + // Any other primitive receiver (number, string, int32, bigint) does not + // own these methods — spec TypeError, as Node throws. The old masked + // path either dereferenced the primitive's bits as an ArrayHeader (UB) + // or iterated garbage. + let method_str = match kind { + 1 => "keys", + 2 => "entries", + _ => "values", + }; + crate::error::js_throw_type_error_not_a_function( + std::ptr::null(), + 0, + method_str.as_ptr(), + method_str.len(), + ); +} + #[no_mangle] pub extern "C" fn js_array_values_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + let arr = match route_iter_obj_receiver(arr, 0) { + IterReceiver::Ptr(p) => p, + IterReceiver::Done(it) => return it, + }; if let Some(it) = collection_iter_obj_for_receiver(arr, 0) { return it; } @@ -373,6 +470,10 @@ pub extern "C" fn js_array_values_iter_obj(arr: *const ArrayHeader) -> i64 { #[no_mangle] pub extern "C" fn js_array_keys_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + let arr = match route_iter_obj_receiver(arr, 1) { + IterReceiver::Ptr(p) => p, + IterReceiver::Done(it) => return it, + }; if let Some(it) = collection_iter_obj_for_receiver(arr, 1) { return it; } @@ -385,6 +486,10 @@ pub extern "C" fn js_array_keys_iter_obj(arr: *const ArrayHeader) -> i64 { #[no_mangle] pub extern "C" fn js_array_entries_iter_obj(arr: *const ArrayHeader) -> i64 { unsafe { + let arr = match route_iter_obj_receiver(arr, 2) { + IterReceiver::Ptr(p) => p, + IterReceiver::Done(it) => return it, + }; if let Some(it) = collection_iter_obj_for_receiver(arr, 2) { return it; } diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 89b7d3092f..25c6d5000f 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -875,15 +875,34 @@ pub unsafe extern "C" fn js_native_call_method( | "sort" | "forEach" ) { - let recv_ptr = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; - if crate::url::search_params::shape_is_url_search_params(recv_ptr) { - if let Some(result) = crate::url::search_params::url_search_params_dynamic_call( - recv_ptr, - method_name, - args_ptr, - args_len, - ) { - return result; + // Only a pointer-shaped receiver (NaN-boxed pointer above the handle + // band, or a raw untagged heap address) may be shape-probed. A plain + // double must NOT have its low 48 bits read as an address: a Web + // Streams handle id (`1049102.0`) extracts to `(id - 2^20) * 2^32`, + // which passes the macOS 2 TB heap floor once ~512 stream ids are + // live and the probe then dereferences unmapped memory — the + // gscmaster request-12 SIGSEGV (`for await` resolves @@asyncIterator + // to a bound `values` re-dispatch landing here with the numeric + // handle as receiver; Linux's 0x1000 floor probes low memory from + // id 1). Numeric stream receivers fall through to the + // primitive-methods stream dispatch that owns them. + let bits = object.to_bits(); + let top16 = bits >> 48; + let payload = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + let pointer_shaped = (top16 == 0x7FFD + && crate::value::addr_class::is_above_handle_band(payload)) + || (top16 == 0 && payload >= 0x10000); + if pointer_shaped { + let recv_ptr = payload as *mut ObjectHeader; + if crate::url::search_params::shape_is_url_search_params(recv_ptr) { + if let Some(result) = crate::url::search_params::url_search_params_dynamic_call( + recv_ptr, + method_name, + args_ptr, + args_len, + ) { + return result; + } } } } diff --git a/test-files/test_gap_stream_id_band_dynamic_dispatch.ts b/test-files/test_gap_stream_id_band_dynamic_dispatch.ts new file mode 100644 index 0000000000..10e7c41b9d --- /dev/null +++ b/test-files/test_gap_stream_id_band_dynamic_dispatch.ts @@ -0,0 +1,85 @@ +// gscmaster request-12 SIGSEGV: Web Streams handle ids are raw numeric f64s +// allocated from 0x100000 (one shared counter across the five stream +// registries). A dynamic method call on a stream handle — e.g. the +// `@@asyncIterator` -> bound `values` re-dispatch a `for await` performs — +// reached the URLSearchParams fast-path in `js_native_call_method`, which +// reinterpreted the receiver double's low 48 bits as a heap address. For a +// stream id `0x100000 + k` those bits decode to `k * 2^32`; once k >= 512 +// the address crosses the macOS 2 TB heap floor, passes the plausibility +// check, and the shape probe dereferences unmapped memory (on Linux the +// floor is 0x1000, so low ids probe low memory immediately). Requests 1-11 +// of a Next.js app each burn ~48 ids; request 12's render stream was the +// first with k >= 512. + +async function main() { + const decoder = new TextDecoder(); + + // A plain number receiver with a URLSearchParams-list method name must + // throw TypeError, not have its double bits probed as a pointer. + // 1049102.0 = bits 0x4130_020E_0000_0000; low 48 bits = 0x20E00000000 + // (2.26 TB) — the exact crashing "address". Run before any stream exists + // so the value cannot alias a live stream id. + const n: any = 1049102; + try { + n.entries(); + console.log("number entries: no throw"); + } catch (e) { + console.log("number entries throws TypeError:", e instanceof TypeError); + } + + // Zero and booleans share dangerous bit shapes (top16 == 0 → "raw + // pointer" null; 0x7FFC payloads 3/4 → tiny "pointers") — all three + // iterator names must throw TypeError, matching Node (#6599 review). + // Direct `.entries()` syntax so the #597 any-typed fold fires. + const zero: any = 0; + const t: any = true; + const f: any = false; + const check = (label: string, fn: () => void) => { + try { + fn(); + console.log(`${label}: no throw`); + } catch (e) { + console.log(`${label} throws TypeError:`, e instanceof TypeError); + } + }; + check("0.entries()", () => zero.entries()); + check("0.keys()", () => zero.keys()); + check("0.values()", () => zero.values()); + check("true.entries()", () => t.entries()); + check("true.keys()", () => t.keys()); + check("true.values()", () => t.values()); + check("false.entries()", () => f.entries()); + check("false.keys()", () => f.keys()); + check("false.values()", () => f.values()); + + function makeStream(i: number): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("chunk-" + i + ";")); + controller.close(); + }, + }); + } + + // Burn stream-family ids well past the k = 512 threshold. + const burned: ReadableStream[] = []; + for (let i = 0; i < 700; i++) burned.push(makeStream(i)); + + // Type-erased for-await: resolves @@asyncIterator to a bound `values` + // dynamic dispatch with the numeric handle as receiver. + const erased: any = makeStream(9999); + let text = ""; + for await (const chunk of erased) text += decoder.decode(chunk); + console.log("for-await:", text); + + // Direct dynamic `.values()` — the method name that collided with the + // URLSearchParams fast-path list. + const erased2: any = makeStream(4242); + const iter = erased2.values(); + const first = await iter.next(); + console.log("values():", decoder.decode(first.value), first.done); + const last = await iter.next(); + console.log("done:", last.done, last.value === undefined); +} + +main();