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
5 changes: 5 additions & 0 deletions changelog.d/8659-follow-up-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

Validate fractional `Intl.PluralRules` digit options before flooring, reject
invalid superclass prototypes, and keep emitted network errors alive across
successive listeners.
1 change: 1 addition & 0 deletions changelog.d/8724-captured-arraybuffer-construct.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a regression where constructing `ArrayBuffer`, `SharedArrayBuffer`, or `DataView` through a captured value — `const D = DataView; new D(buf)`, `Reflect.construct(DataView, …)`, `class X extends DataView {}`, `new globalThis.ArrayBuffer(n)` — threw `TypeError: Constructor requires 'new'`. Their global [[Call]] behavior had moved to the shared construct-only thunk without adding that thunk to `identify_global_builtin_constructor`'s allow-list, so the dynamic-`new` path no longer recognized a captured constructor and fell through to the bare-call thunk. Minified bundles capture these globals into locals pervasively; this broke module init (e.g. the natively-compiled Claude Code cli.js bundle failed at startup on nearly every command). Direct `new DataView(buf)` was unaffected.
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/bun_ffi/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const READERS: &[(&str, u8)] = &[
("f64", super::types::T_F64),
];

thread_local! {
crate::perry_thread_local! {
static READ_OBJECT_CACHE: Cell<u64> = const { Cell::new(0) };
}

Expand Down
18 changes: 15 additions & 3 deletions crates/perry-runtime/src/intl/list_relative_plural.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,18 +670,21 @@ pub(crate) extern "C" fn rtf_bound_resolved_options_thunk(closure: *const Closur

// ---- Intl.PluralRules ------------------------------------------------------

fn plural_digit_integer(number: f64, min: f64, max: f64) -> Option<f64> {
(number.is_finite() && number >= min && number <= max).then(|| number.floor())
}

fn plural_digit_option(options: f64, key: &str, min: f64, max: f64) -> Option<f64> {
let value = get_option_value(options, key);
if JSValue::from_bits(value.to_bits()).is_undefined() {
return None;
}
let number = to_number_reject_bigint(value);
let integer = number.trunc();
if integer.is_nan() || integer < min || integer > max {
let Some(integer) = plural_digit_integer(number, min, max) else {
throw_range_error(&format!(
"Value {number} out of range for Intl.PluralRules options property {key}"
));
}
};
Some(integer)
}

Expand Down Expand Up @@ -929,6 +932,15 @@ pub(crate) fn plural_rules_select(obj: *const ObjectHeader, value: f64) -> f64 {
mod plural_category_tests {
use super::*;

#[test]
fn digit_options_validate_before_flooring() {
assert_eq!(plural_digit_integer(20.9, 1.0, 21.0), Some(20.0));
assert_eq!(plural_digit_integer(21.9, 1.0, 21.0), None);
assert_eq!(plural_digit_integer(100.5, 0.0, 100.0), None);
assert_eq!(plural_digit_integer(-0.5, 0.0, 100.0), None);
assert_eq!(plural_digit_integer(f64::INFINITY, 0.0, 100.0), None);
}

#[test]
fn locale_selectors_only_return_advertised_categories() {
let samples = [
Expand Down
13 changes: 13 additions & 0 deletions crates/perry-runtime/src/object/class_registry/class_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s
let is_global_builtin_func = func_ptr
== global_this_builtin_noop_thunk as *const u8 as usize
|| func_ptr == typed_array_constructor_call_thunk as *const u8 as usize
// ArrayBuffer / SharedArrayBuffer / DataView carry the shared
// construct-only call thunk (populate.rs). When one is captured into
// a variable and constructed — `const D = DataView; new D(buf)`,
// `Reflect.construct(DataView, …)`, `class X extends DataView` — the
// dynamic-`new` path lands here and must recognize the thunk so the
// singleton walk recovers the name and construct.rs's
// "ArrayBuffer"/"SharedArrayBuffer"/"DataView" arms build it, instead
// of falling through to invoke the bare-call thunk (which throws
// "Constructor requires 'new'"). Minified bundles capture these
// globals into locals pervasively (the Claude Code cli.js bundle
// fails at module init without this). Regression from the thunk swap
// in 06e1ab349 — before it these carried the recognized noop thunk.
|| func_ptr == construct_only_builtin_call_thunk as *const u8 as usize
// #4102: `Array`/`Object`/`Date` constructor *values* carry their own
// coercion thunks (not the shared noop thunk), so the dynamic
// `instanceof` / reflective `@@hasInstance` path could not recover
Expand Down
80 changes: 63 additions & 17 deletions crates/perry-runtime/src/object/class_registry/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,17 @@ fn install_class_decl_prototype_method_fields(proto: *mut ObjectHeader, class_id
}
}

fn class_parent_prototype_bits(value: f64) -> Option<u64> {
let bits = value.to_bits();
if bits == crate::value::TAG_NULL {
return Some(bits);
}
if !unsafe { super::super::object_ops::value_is_object_like(value) } {
return None;
}
(unsafe { crate::symbol::js_is_symbol(value) } == 0).then_some(bits)
}

pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 {
// #7757: a specialization answers with its generic's prototype.
let class_id = decl_prototype_identity_id(class_id);
Expand Down Expand Up @@ -710,42 +721,51 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 {
unsafe { mirror_prototype_method_on_object(proto, &name, value_bits, enumerable) };
}

let dynamic_parent = js_get_dynamic_parent_value(class_id);
let null_heritage = dynamic_parent.to_bits() == crate::value::TAG_NULL;
let scope = crate::gc::RuntimeHandleScope::new();
let dynamic_parent = scope.root_nanbox_f64(js_get_dynamic_parent_value(class_id));
let null_heritage = dynamic_parent.get_nanbox_f64().to_bits() == crate::value::TAG_NULL;
let parent_proto_bits = if null_heritage {
// A class extending null creates a prototype object whose
// [[Prototype]] is null, not Object.prototype. Record TAG_NULL
// explicitly so "no custom link" is not mistaken for the ordinary
// Object.prototype default.
Some(crate::value::TAG_NULL)
} else {
get_parent_class_id(class_id)
let registered_parent_proto = get_parent_class_id(class_id)
.filter(|parent_id| *parent_id != 0 && *parent_id != class_id)
.and_then(|parent_id| {
let parent_proto = class_decl_prototype_value(parent_id);
let parent_bits = parent_proto.to_bits();
((parent_bits >> 48) == 0x7FFD).then_some(parent_bits)
})
});
if registered_parent_proto.is_some() {
registered_parent_proto
} else {
// A runtime function-valued superclass (including Intl service
// constructors) has no class-id edge. Link the declared prototype
// to the parent's own `.prototype` exactly once, while this fresh
// class prototype is initialized. Construction must never rewrite
// this edge after user code mutates it.
.or_else(|| {
let parent = JSValue::from_bits(dynamic_parent.to_bits());
if !parent.is_pointer() {
return None;
}
let parent = JSValue::from_bits(dynamic_parent.get_nanbox_f64().to_bits());
if parent.is_pointer() {
let parent_addr = parent.as_pointer::<u8>() as usize;
if !crate::closure::is_closure_ptr(parent_addr) {
return None;
if crate::closure::is_closure_ptr(parent_addr) {
let parent_proto =
crate::closure::closure_get_dynamic_prop(parent_addr, "prototype");
if let Some(bits) = class_parent_prototype_bits(parent_proto) {
Some(bits)
} else {
super::super::object_ops::throw_object_type_error(
b"Class extends value does not have valid prototype property",
);
}
} else {
global_object_prototype_bits()
}
let parent_proto =
crate::closure::closure_get_dynamic_prop(parent_addr, "prototype");
let bits = parent_proto.to_bits();
((bits >> 48) == 0x7FFD).then_some(bits)
})
.or_else(global_object_prototype_bits)
} else {
global_object_prototype_bits()
}
}
};
if let Some(bits) = parent_proto_bits {
let proto = class_decl_prototype_object(class_id);
Expand Down Expand Up @@ -905,3 +925,29 @@ mod class_dynamic_prop_store_tests {
assert_eq!(stored(cid, "k"), Some(3.0));
}
}

#[cfg(test)]
mod class_parent_prototype_tests {
use super::*;

#[test]
fn only_object_and_null_parent_prototypes_are_valid() {
let object_ptr = crate::object::js_object_alloc(0, 0);
assert!(!object_ptr.is_null());
let object = crate::value::js_nanbox_pointer(object_ptr as i64);
assert_eq!(class_parent_prototype_bits(object), Some(object.to_bits()));
assert_eq!(
class_parent_prototype_bits(f64::from_bits(crate::value::POINTER_TAG | 0x1234)),
None
);
assert_eq!(
class_parent_prototype_bits(f64::from_bits(crate::value::TAG_NULL)),
Some(crate::value::TAG_NULL)
);
assert_eq!(
class_parent_prototype_bits(f64::from_bits(crate::value::TAG_UNDEFINED)),
None
);
assert_eq!(class_parent_prototype_bits(1.0), None);
}
}
5 changes: 3 additions & 2 deletions crates/perry-stdlib/src/net/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1856,10 +1856,11 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 {
// so user code can read `err.message`. Pre-fix the listener
// received a raw NaN-boxed string and `err.message` came
// back as `undefined`.
let err_f64 = build_error_object(&msg);
let scope = perry_runtime::gc::RuntimeHandleScope::new();
let error = scope.root_nanbox_f64(build_error_object(&msg));
for cb in cbs {
if cb != 0 {
js_closure_call1(cb as *const ClosureHeader, err_f64);
js_closure_call1(cb as *const ClosureHeader, error.get_nanbox_f64());
}
}
}
Expand Down
33 changes: 33 additions & 0 deletions tests/issue_8724_captured_arraybuffer_construct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// #8724: constructing ArrayBuffer / SharedArrayBuffer / DataView through a
// CAPTURED value (a local holding the builtin, Reflect.construct, or a
// subclass) must build the object, not throw "Constructor requires 'new'".
// Regression from routing their [[Call]] to the shared construct-only thunk
// without teaching `identify_global_builtin_constructor` about it, so the
// dynamic-`new` path stopped recognizing a captured constructor. Minified
// bundles capture these globals into locals everywhere.

function run(label: string, fn: () => void): void {
try {
fn();
console.log(label, "ok");
} catch (e: any) {
console.log(label, "THREW:", e.message);
}
}

const ab = () => new ArrayBuffer(8);

run("direct DataView", () => { const dv = new DataView(ab()); if (dv.byteLength !== 8) throw new Error("len"); });
run("captured DataView", () => { const D = DataView; const dv = new D(ab()); if (dv.byteLength !== 8) throw new Error("len"); });
run("Reflect.construct DataView", () => { const dv = Reflect.construct(DataView, [ab()]); if ((dv as DataView).byteLength !== 8) throw new Error("len"); });
run("subclass DataView", () => { class MV extends DataView {} const dv = new MV(ab()); if (dv.byteLength !== 8) throw new Error("len"); });
run("captured ArrayBuffer", () => { const AB = ArrayBuffer; const b = new AB(8); if (b.byteLength !== 8) throw new Error("len"); });
run("captured ArrayBuffer resizable", () => { const AB = ArrayBuffer; const b = new AB(8, { maxByteLength: 16 }); if (b.byteLength !== 8) throw new Error("len"); });
run("captured SharedArrayBuffer", () => { const SAB = SharedArrayBuffer; const s = new SAB(8); if (s.byteLength !== 8) throw new Error("len"); });
run("globalThis ArrayBuffer", () => { const b = new (globalThis as any).ArrayBuffer(8); if (b.byteLength !== 8) throw new Error("len"); });

// The captured DataView must be fully functional, not a branded-but-empty stub.
const D2 = DataView;
const dv2 = new D2(ab());
dv2.setInt32(0, 0x41424344);
console.log("readback", dv2.getInt32(0).toString(16));
Loading