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
14 changes: 12 additions & 2 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use crate::native_value::{
use crate::type_analysis::{
compute_auto_captures, is_array_expr, is_bigint_expr, is_bool_expr, is_map_expr,
is_numeric_expr, is_numeric_typed_array_class, is_set_expr, is_string_expr,
is_url_search_params_expr, receiver_class_name,
is_url_search_params_expr, receiver_class_name, receiver_is_error_type,
};
#[allow(unused_imports)]
use crate::types::{DOUBLE, I1, I32, I64, I8, PTR};
Expand Down Expand Up @@ -129,7 +129,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// ArrayHeader pointer from the ErrorHeader struct. Returns a
// NaN-boxed pointer so downstream length / index operations
// see an array.
Expr::PropertyGet { object, property } if property == "errors" => {
//
// Gated on a statically-known Error receiver (#6588): the helper's
// `ArrayHeader*` return can't represent a stored `null`, so applying
// it to a function/plain-object `.errors` expando that holds `null`
// produced a bogus pointer sentinel (`f.errors === null` → false,
// `String(f.errors)` → "[object Object]"). Non-error receivers fall
// through to the generic property read below, which returns the
// stored value — including `null` — correctly.
Expr::PropertyGet { object, property }
if property == "errors" && receiver_is_error_type(ctx, object) =>
{
let recv_box = lower_expr(ctx, object)?;
let blk = ctx.block();
let recv_handle = unbox_to_i64(blk, &recv_box);
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/type_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub(crate) use pod::{
};
pub(crate) use predicates::{
is_array_expr, is_global_builtin_named, is_native_module_dynamic_index, is_promise_expr,
receiver_class_name, static_type_of,
receiver_class_name, receiver_is_error_type, static_type_of,
};
// Re-exported so the `#[cfg(test)] mod tests` (which reaches trunk items via
// `super::*`) can keep calling `tuple_index_literal` directly.
Expand Down
90 changes: 90 additions & 0 deletions crates/perry-codegen/src/type_analysis/predicates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,55 @@ pub(crate) fn is_promise_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
}
}

/// The built-in JS error constructors. `AggregateError` is the only one
/// with an `errors` data field, but a user subclass can extend any of
/// them, so the whole family is recognized as "error-shaped".
pub(crate) fn is_error_class_name(name: &str) -> bool {
matches!(
name,
"Error"
| "TypeError"
| "RangeError"
| "SyntaxError"
| "ReferenceError"
| "EvalError"
| "URIError"
| "AggregateError"
| "SuppressedError"
)
}

/// True when the receiver is statically known to be an Error (or an Error
/// subclass). Gates the `.errors` codegen fast-path (`js_error_get_errors`)
/// so it only fires for genuine error receivers: that helper returns a raw
/// `ArrayHeader*`, which can't represent a stored `null`, so applying it to
/// a function/plain-object `.errors` expando that holds `null` yielded a
/// bogus pointer sentinel instead of null (#6588). Every other receiver
/// falls through to the generic property read, which returns the stored
/// value (including `null`) correctly.
pub(crate) fn receiver_is_error_type(ctx: &FnCtx<'_>, e: &Expr) -> bool {
let Some(name) = receiver_class_name(ctx, e) else {
return false;
};
if is_error_class_name(&name) {
return true;
}
// Walk the user-declared `extends` chain: `class MyErr extends Error`.
let mut current = Some(name);
let mut hops = 0;
while let Some(n) = current {
if is_error_class_name(&n) {
return true;
}
hops += 1;
if hops > 64 {
break;
}
current = ctx.classes.get(&n).and_then(|c| c.extends_name.clone());
}
false
}

/// If the expression is a known instance of a Named class type, return
/// the class name. Used by the class method dispatch in lower_call to
/// pick the right `perry_method_<class>_<name>` function.
Expand Down Expand Up @@ -620,3 +669,44 @@ pub(crate) fn static_type_of(ctx: &FnCtx<'_>, e: &Expr) -> Option<HirType> {
_ => hir_inferred_static_type(ctx, e),
}
}

#[cfg(test)]
mod error_class_tests {
use super::is_error_class_name;

#[test]
fn recognizes_every_builtin_error_constructor() {
for name in [
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"EvalError",
"URIError",
"AggregateError",
"SuppressedError",
] {
assert!(is_error_class_name(name), "{name} should be error-shaped");
}
}

#[test]
fn rejects_non_error_receivers() {
// Function / plain-object / typed-array receivers must NOT be treated
// as error-shaped — that is exactly the #6588 mis-routing that sent a
// stored-null `.errors` expando through the Error-only helper.
for name in [
"Function",
"Object",
"Array",
"Map",
"Set",
"Promise",
"Uint8Array",
"MyError",
] {
assert!(!is_error_class_name(name), "{name} is not a builtin error");
}
}
}
38 changes: 38 additions & 0 deletions test-files/test_gap_fn_expando_null_6588.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// #6588: a compiled dot-read of a `.errors` expando that stores `null`
// returned a hole/pointer sentinel instead of `null`. The codegen fast-path
// unconditionally routed EVERY static `X.errors` read through the Error-only
// `js_error_get_errors` helper (whose `ArrayHeader*` return can't represent a
// stored null). Now gated to statically-Error receivers; everything else uses
// the generic property read, which preserves null. Real AggregateError.errors
// must keep working.

// --- function expando storing null (the reported case) ---
function f() {}
f.errors = null;
console.log(f.errors === null); // true
console.log(typeof f.errors); // object
console.log(String(f.errors)); // null
console.log(f["err" + "ors"] === null); // computed lane — true

// --- plain object with a null `errors` expando (same bug class) ---
const o: any = {};
o.errors = null;
console.log(o.errors === null); // true
console.log(String(o.errors)); // null

// --- non-null function expando (control) ---
function g() {}
g.errors = 42;
console.log(g.errors === 42); // true
console.log(g.errors); // 42

// --- null on a differently-named field (control) ---
const p: any = {};
p.e = null;
console.log(p.e === null); // true

// --- real AggregateError.errors must keep working (no regression) ---
const agg = new AggregateError([new Error("a"), new Error("b")], "multi");
console.log(agg.errors.length); // 2
console.log(agg.errors[0].message); // a
console.log(JSON.stringify(new AggregateError([1, 2], "m").errors)); // [1,2]
Loading