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
10 changes: 10 additions & 0 deletions changelog.d/7862-declared-array-length-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Fixed

- **Guarded `.length` reads now preserve ordinary property semantics when a
declared Array/String/Named receiver holds a different runtime value**
(#7853). The inline layout check was already safe, but its numeric fallback
collapsed a missing `length` to `0` and let `null`/`undefined` continue.
Source-level reads now use a property-semantic sibling that returns
`undefined` for a missing property, preserves non-numeric values, delegates
normal object/function/native/proxy lookup, and throws a catchable TypeError
for nullish receivers. Array-internal length coercion remains unchanged.
37 changes: 22 additions & 15 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
.is_some_and(is_numeric_typed_array_class) =>
{
let recv_box = lower_expr(ctx, object)?;
Ok(ctx
.block()
.call(DOUBLE, "js_value_length_f64", &[(DOUBLE, &recv_box)]))
Ok(ctx.block().call(
DOUBLE,
"js_value_length_property_f64",
&[(DOUBLE, &recv_box)],
))
}

// `arr.length` / `str.length` — INLINE. Both ArrayHeader and
Expand Down Expand Up @@ -342,8 +344,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// (`lshr 40; and 0xFF`, matching `js_value_length_f64`'s SSO
// branch), heap STRING_TAG → `load i32` of `utf16_len` at
// offset 0, anything else (annotation lie, nullable-union
// receiver) → the same `js_value_length_f64` slow call the
// generic tower's slow arm uses.
// receiver) → the property-semantic slow call used by the
// generic tower's slow arm.
{
if crate::expr::static_string_lowering_enabled()
&& is_string_expr(ctx, object)
Expand Down Expand Up @@ -388,9 +390,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
ctx.block().br(&merge_label);

ctx.current_block = slow_idx;
let slow_len =
ctx.block()
.call(DOUBLE, "js_value_length_f64", &[(DOUBLE, &recv_box)]);
let slow_len = ctx.block().call(
DOUBLE,
"js_value_length_property_f64",
&[(DOUBLE, &recv_box)],
);
let slow_pred = ctx.block().label.clone();
ctx.block().br(&merge_label);

Expand Down Expand Up @@ -517,14 +521,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let fast_pred_label = ctx.block().label.clone();
ctx.block().br(&merge_label);

// Runtime slow path: handles Buffer / TypedArray via side-
// table registries, returns 0 for non-length-bearing
// receivers (Closure / BigInt / Promise / Error / plain
// Object) and for non-pointer NaN-boxes.
// Runtime slow path: preserve ordinary property semantics when the
// annotation lies. It handles Buffer / TypedArray / Closure and
// objects through their normal dispatch, returns `undefined` for
// a missing property, preserves a non-numeric property value, and
// throws for a nullish receiver.
ctx.current_block = slow_idx;
let slow_len = ctx
.block()
.call(DOUBLE, "js_value_length_f64", &[(DOUBLE, &recv_box)]);
let slow_len = ctx.block().call(
DOUBLE,
"js_value_length_property_f64",
&[(DOUBLE, &recv_box)],
);
let slow_pred_label = ctx.block().label.clone();
ctx.block().br(&merge_label);

Expand Down
13 changes: 9 additions & 4 deletions crates/perry-codegen/src/gc_call_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect {
// callback-type validators (type check + static-message throw; their
// throw path is the audited noreturn funnel). Deliberately NOT
// admitted: js_value_length_f64 — its plain-object arm calls
// js_object_get_field_by_name_f64, a transitive getter path; and
// js_array_get_f64 — hole/accessor paths.
// js_object_get_field_by_name_f64, a transitive getter path;
// js_value_length_property_f64 deliberately delegates the full
// property/getter path; and js_array_get_f64 has hole/accessor paths.
| "js_ctor_return_override"
| "js_array_indexOf_jsvalue"
| "js_validate_array_comparator"
Expand Down Expand Up @@ -245,9 +246,13 @@ mod tests {
);
}
// Transitive re-entry paths found by the body audit must stay out:
// js_value_length_f64 reaches js_object_get_field_by_name_f64 for
// Both length helpers can reach js_object_get_field_by_name_f64 for
// plain objects; js_array_get_f64 has hole/accessor paths.
for name in ["js_value_length_f64", "js_array_get_f64"] {
for name in [
"js_value_length_f64",
"js_value_length_property_f64",
"js_array_get_f64",
] {
assert_eq!(
classify_direct_callee(name),
GcCallEffect::Unknown,
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/runtime_decls/arrays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) {
// for the inline PropertyGet length path when the GC-type check
// can't prove the receiver is an Array/String.
module.declare_function("js_value_length_f64", DOUBLE, &[DOUBLE]);
// #7853: property-semantic sibling used when a static Array/String/Named
// claim fails its runtime layout guard. Unlike the numeric helper above,
// it preserves `undefined` and non-numeric property values and throws for
// nullish receivers.
module.declare_function("js_value_length_property_f64", DOUBLE, &[DOUBLE]);

// Shadow stack for precise root tracking (gen-GC Phase A per
// docs/generational-gc-plan.md). Declared now so codegen can
Expand Down
65 changes: 65 additions & 0 deletions crates/perry-runtime/src/value/dynamic_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,49 @@ pub extern "C" fn js_value_length_f64(value: f64) -> f64 {
0.0
}

/// Read `.length` with ordinary JavaScript property semantics.
///
/// [`js_value_length_f64`] is deliberately numeric: array internals feed its
/// result through `ToLength`, so a missing property historically collapses to
/// zero there. A source-level `receiver.length` read cannot use that sentinel
/// when its inline layout guard misses. TypeScript annotations are erased, so
/// a receiver declared as an array may hold a number (whose `length` is
/// `undefined`), an object with a non-numeric `length`, or a nullish value
/// (which must throw).
///
/// Keep the SSO case here because the general dynamic property getter has no
/// heap pointer to dispatch through. Everything else delegates to that getter
/// so Proxy traps, native handles, accessors, inherited properties, functions,
/// buffers, typed arrays, and ordinary objects all share the normal property
/// lookup rather than a second `.length` implementation.
#[no_mangle]
pub extern "C" fn js_value_length_property_f64(value: f64) -> f64 {
let jsval = JSValue::from_bits(value.to_bits());
if jsval.is_undefined() || jsval.is_null() {
crate::error::js_throw_type_error_property_access(
jsval.is_null() as u32,
b"length".as_ptr(),
6,
);
}

if let Some((_, payload)) = crate::builtins::boxed_primitive_payload(value) {
if matches!(
crate::builtins::boxed_primitive_to_string_tag(value),
Some("String")
) {
return js_value_length_property_f64(payload);
}
}

if jsval.is_short_string() {
let string = crate::string::js_string_materialize_to_heap(value);
return crate::string::js_string_length(string) as f64;
}

unsafe { js_dynamic_object_get_property(value, b"length".as_ptr() as *const i8, 6) }
}

/// Unified object property access that handles both JS handle objects and native objects.
/// Also handles strings for property access like `.length`.
#[no_mangle]
Expand Down Expand Up @@ -761,4 +804,26 @@ mod length_handle_band_tests {
1.0
);
}

#[test]
fn property_length_preserves_missing_and_non_numeric_values() {
assert_eq!(
js_value_length_property_f64(42.0).to_bits(),
crate::value::TAG_UNDEFINED,
"a number has no length property"
);

let obj = crate::object::js_object_alloc(0, 1);
let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6);
let seven = crate::string::js_string_from_bytes(b"seven".as_ptr(), 5);
let seven_value = crate::value::js_nanbox_string(seven as i64);
crate::object::js_object_set_field_by_name(obj, length_key, seven_value);
let boxed_obj = crate::value::js_nanbox_pointer(obj as i64);

assert_eq!(
js_value_length_property_f64(boxed_obj).to_bits(),
seven_value.to_bits(),
"a source-level property read must not coerce its value"
);
}
}
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/value/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,5 @@ pub use dynamic_array::{
// ----- Dynamic object property / collection method / Object.keys -----
pub use dynamic_object::{
js_collection_method_dispatch, js_dynamic_object_get_property, js_dynamic_object_keys,
js_get_property, js_value_length_f64,
js_get_property, js_value_length_f64, js_value_length_property_f64,
};
49 changes: 49 additions & 0 deletions test-files/test_gap_7853_declared_array_length_runtime_value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// #7853: an erased array annotation is not a runtime proof that the value has
// an Array layout. The guarded inline `.length` read must preserve ordinary
// JavaScript property semantics on its fallback: missing properties are
// `undefined`, strings and array-like objects expose their real value, and a
// nullish receiver throws a catchable TypeError.

type Bag = { items: string[]; label: string };

function makeBag(value: any): Bag {
return { items: value, label: "bag" };
}

function readLength(value: any): any {
const bag = makeBag(value);
const items: string[] = bag.items;
return items.length;
}

function show(label: string, value: any): void {
const length = readLength(value);
console.log(label, String(length), typeof length, length === undefined);
}

show("array", ["a", "b"]);
show("number", 42);
show("string", "hi");
show("array-like number", { length: 7, 0: "z" });
show("array-like string", { length: "seven" });

function twoArgs(a: any, b: any): void {
void a;
void b;
}
show("function", twoArgs);
show("typed array", new Uint8Array(3));

for (const value of [null, undefined]) {
try {
readLength(value);
console.log("nullish", String(value), "no throw");
} catch (error) {
const caught = error as Error;
console.log(
"nullish",
String(value),
caught.constructor.name + ": " + caught.message,
);
}
}