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
1 change: 1 addition & 0 deletions changelog.d/7052-parity-regressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Fix five TypeScript parity regressions (#6828, #6876, #6884, #6906, #6967):** UTC Date calendar getters no longer depend on the process timezone; static loop unrolling preserves function-scoped `var` values between iterations; out-of-bounds numeric TypedArray reads become `NaN` in arithmetic while retaining the call-free in-bounds path; reassigned typed/class locals fall back to runtime dispatch instead of trusting stale annotations; and dynamic `__proto__` assignment invokes the inherited legacy setter without breaking own descriptors or null-prototype objects.
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
output_type,
};

let module_reassigned_locals = crate::collectors::reassigned_locals_in_module(hir);

for (func_id, closure_expr) in closures {
if cross_module.typed_f64_closures.contains(func_id) {
compile_typed_f64_closure(
Expand Down Expand Up @@ -288,6 +290,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
module_prefix,
module_boxed_vars,
module_receiver_types,
&module_reassigned_locals,
closure_rest_params,
cross_module,
)
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ pub(super) fn compile_closure(
// keeps its declared type at its read sites. NOT the typed-ABI capture
// map — the typed closure clones take `module_local_types` instead.
module_receiver_types: &HashMap<u32, perry_hir::types::Type>,
// Reassignments from every executable body in the module. Captured locals
// inherit module-wide receiver types, so their invalidation scope must be
// module-wide too.
module_reassigned_locals: &HashSet<u32>,
closure_rest_params: &HashMap<u32, usize>,
cross_module: &CrossModuleCtx,
) -> Result<()> {
Expand Down Expand Up @@ -778,6 +782,9 @@ pub(super) fn compile_closure(
std::collections::HashSet::new()
};

let mut reassigned_locals = module_reassigned_locals.clone();
reassigned_locals.extend(crate::collectors::reassigned_locals(body));

let mut ctx = FnCtx {
func: lf,
module_slug: crate::expr::native_region_slug(strings.module_prefix()),
Expand All @@ -787,6 +794,7 @@ pub(super) fn compile_closure(
native_facts: &native_facts,
locals,
local_types,
reassigned_locals,
const_string_locals: std::collections::HashMap::new(),
const_number_locals: std::collections::HashMap::new(),
current_block: 0,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,7 @@ pub(super) fn compile_module_entry(
native_facts: &main_native_facts,
locals: HashMap::new(),
local_types: init_local_types,
reassigned_locals: crate::collectors::reassigned_locals(&hir.init),
const_string_locals: HashMap::new(),
const_number_locals: HashMap::new(),
current_block: 0,
Expand Down Expand Up @@ -1306,6 +1307,7 @@ pub(super) fn compile_module_entry(
native_facts: &init_native_facts,
locals: HashMap::new(),
local_types: HashMap::new(),
reassigned_locals: crate::collectors::reassigned_locals(&hir.init),
const_string_locals: HashMap::new(),
const_number_locals: HashMap::new(),
current_block: 0,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ pub(super) fn compile_function(
native_facts: &native_facts,
locals,
local_types,
reassigned_locals: crate::collectors::reassigned_locals(&f.body),
const_string_locals: std::collections::HashMap::new(),
const_number_locals: std::collections::HashMap::new(),
current_block: 0,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ pub(super) fn compile_method(
native_facts: &native_facts,
locals,
local_types,
reassigned_locals: crate::collectors::reassigned_locals(&method.body),
const_string_locals: std::collections::HashMap::new(),
const_number_locals: std::collections::HashMap::new(),
current_block: 0,
Expand Down Expand Up @@ -1467,6 +1468,7 @@ pub(super) fn compile_static_method(
native_facts: &native_facts,
locals,
local_types,
reassigned_locals: crate::collectors::reassigned_locals(&f.body),
const_string_locals: std::collections::HashMap::new(),
const_number_locals: std::collections::HashMap::new(),
current_block: 0,
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ pub(crate) use shadow_slots::{
collect_declared_shadow_slots_in_stmts, collect_shadow_slot_clear_points,
};
pub(crate) use spec_abi_sites::{
collect_spec_abi_facts, reassigned_locals, SpecParamRep, SpecTaBinding,
collect_spec_abi_facts, reassigned_locals, reassigned_locals_in_module, SpecParamRep,
SpecTaBinding,
};
pub(crate) use this_as_value::{
class_chain_extends_builtin_error, class_chain_has_unmodeled_base, class_uses_this_as_value,
Expand Down
10 changes: 10 additions & 0 deletions crates/perry-codegen/src/collectors/spec_abi_sites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ pub(crate) fn reassigned_locals(stmts: &[Stmt]) -> HashSet<u32> {
scan.writes
}

/// Every local reassigned in any executable body in `hir`.
///
/// Closure codegen seeds receiver types from module-wide declarations, so it
/// must pair that oracle with the equally broad reassignment set. Otherwise a
/// closure can specialize a captured receiver from its declared type even
/// after an enclosing body has replaced the binding with another value.
pub(crate) fn reassigned_locals_in_module(hir: &Module) -> HashSet<u32> {
scan_whole_module(hir).writes
}

/// Single-id convenience over [`reassigned_locals`].
#[cfg(test)]
pub(crate) fn local_is_reassigned(stmts: &[Stmt], id: u32) -> bool {
Expand Down
11 changes: 11 additions & 0 deletions crates/perry-codegen/src/expr/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ use super::temp_root::{lower_operand_pair_rooted, temp_root_release};
use super::{is_known_finite, lower_expr, FnCtx};

fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, bool)> {
// #6884: a statically typed numeric TypedArray read is Number|undefined,
// not an unconditional raw f64. In arithmetic context the OOB `undefined`
// must become canonical NaN. Sink that conversion into the OOB/cold arms
// so the in-bounds hot path remains a guard plus native load.
if let Expr::IndexGet { object, index } = expr {
if let Some(value) =
super::ta_param_f64_read::try_lower_ta_f64_read_for_number_context(ctx, object, index)?
{
return Ok((value, true));
}
}
// Repsel Phase 4a.0 (#6904): a numeric-proven `a || b` / `a && b` /
// `a ?? b` consumed as an arithmetic operand lowers with BOTH sides in
// number context, so the selection is a real-double diamond (`fcmp one` +
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ pub(crate) struct FnCtx<'a> {
/// tracking" extension). Populated from function params and `Stmt::Let`
/// declarations as they're lowered.
pub local_types: std::collections::HashMap<u32, HirType>,
/// Bindings assigned after declaration anywhere in this region.
///
/// A TypeScript annotation describes the source-level contract, but an
/// `as any` assignment can replace the runtime value with an unrelated
/// class. Class-keyed lowering must therefore ignore `local_types` for
/// these ids and use runtime dispatch (#6906).
pub reassigned_locals: std::collections::HashSet<u32>,
/// Immutable locals whose initializer is a string literal. These values
/// can be resolved to the module's interned string global at a use site;
/// unlike a runtime dynamic-key cache, this does not retain a movable
Expand Down
50 changes: 44 additions & 6 deletions crates/perry-codegen/src/expr/ta_param_f64_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ pub(crate) fn try_lower_ta_param_f64_read(
let Some((kind, elem_ty, elem_size, conv)) = checked_typed_array_f64_kind(ctx, object) else {
return Ok(None);
};
let value =
lower_checked_typed_array_f64_load(ctx, object, index, kind, elem_ty, elem_size, conv)?;
let value = lower_checked_typed_array_f64_load(
ctx, object, index, kind, elem_ty, elem_size, conv, false,
)?;
let lowered = LoweredValue::js_value(value.clone());
ctx.record_lowered_value_with_access_mode(
"TypedArrayGet",
Expand All @@ -138,6 +139,26 @@ pub(crate) fn try_lower_ta_param_f64_read(
Ok(Some(value))
}

/// Number-context sibling of [`try_lower_ta_param_f64_read`].
///
/// The in-bounds hot path is the same guard + native load. Only the OOB and
/// cold fallback arms apply `ToNumber`, keeping arithmetic call-free for the
/// common case while making `1000 + ta[99]` produce canonical `NaN` (#6884).
pub(crate) fn try_lower_ta_f64_read_for_number_context(
ctx: &mut FnCtx<'_>,
object: &Expr,
index: &Expr,
) -> Result<Option<String>> {
if !ta_param_f64_read_enabled() || !numeric_index_has_integer_array_index_proof(ctx, index) {
return Ok(None);
}
let Some((kind, elem_ty, elem_size, conv)) = checked_typed_array_f64_kind(ctx, object) else {
return Ok(None);
};
lower_checked_typed_array_f64_load(ctx, object, index, kind, elem_ty, elem_size, conv, true)
.map(Some)
}

/// Emit the checked inline f64 element load. Same runtime-fact guard and header
/// bounds check as [`super::i32_fast_path`]'s `lower_checked_typed_array_i32_load`
/// (pointer + inline-storage `PERRY_TA_VIEW_GUARD == 0` + kind-cache addr/kind),
Expand All @@ -152,6 +173,7 @@ fn lower_checked_typed_array_f64_load(
elem_ty: crate::types::LlvmType,
elem_size: u32,
conv: F64Conv,
number_context: bool,
) -> Result<String> {
let obj_box = lower_expr(ctx, object)?;
let idx_i32 = lower_expr_as_i32(ctx, index)?;
Expand Down Expand Up @@ -231,27 +253,43 @@ fn lower_checked_typed_array_f64_load(
(val, end)
};

// ---- oob: in-kind out-of-bounds -> TAG_UNDEFINED (== js_typed_array_get) --
// ---- oob --------------------------------------------------------------
// Value context preserves the typed-array read (`undefined`). Arithmetic
// context applies ToNumber at the read boundary, yielding a canonical NaN
// instead of allowing TAG_UNDEFINED's NaN payload to leak through fadd
// and remain observably `undefined` (#6884).
ctx.current_block = oob_idx;
let (oob_val, oob_end) = {
let blk = ctx.block();
let end = blk.label.clone();
blk.br(&merge_label);
(double_literal(f64::from_bits(TAG_UNDEFINED)), end)
(
if number_context {
double_literal(f64::NAN)
} else {
double_literal(f64::from_bits(TAG_UNDEFINED))
},
end,
)
};

// ---- slow: view / detached / wrong-kind / non-TA -> memory-safe helper ---
ctx.current_block = slow_idx;
let (slow_val, slow_end) = {
let blk = ctx.block();
let v = blk.call(
let value = blk.call(
DOUBLE,
"js_typed_array_read_f64",
&[(I64, &raw), (I32, &idx_i32)],
);
let value = if number_context {
blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &value)])
} else {
value
};
let end = blk.label.clone();
blk.br(&merge_label);
(v, end)
(value, end)
};

// ---- merge ----
Expand Down
11 changes: 8 additions & 3 deletions crates/perry-codegen/src/type_analysis/pod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,9 +413,14 @@ pub(crate) fn expr_may_return_boxed_value_from_raw_f64_fallback(
.and_then(|class_name| class_field_declared_type(ctx, &class_name, property))
.as_ref()
.is_some_and(crate::typed_shape::type_is_raw_f64_candidate),
Expr::IndexGet { object, .. } => static_type_of(ctx, object)
.as_ref()
.is_some_and(type_has_numeric_pointer_free_array_layout_for_fallback),
Expr::IndexGet { object, .. } => {
receiver_class_name(ctx, object)
.as_deref()
.is_some_and(crate::type_analysis::is_numeric_typed_array_class)
|| static_type_of(ctx, object)
.as_ref()
.is_some_and(type_has_numeric_pointer_free_array_layout_for_fallback)
}
// Repsel Phase 4a.0: `a || b` / `a && b` / `a ?? b` pass ONE operand
// value through, so the result carries the boxed-fallback hazard when
// EITHER operand does (`counts[v] || 0` can surface the read's boxed
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/type_analysis/predicates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ fn declared_type_overrides_shape_proof(ctx: &FnCtx<'_>, id: &u32) -> bool {
/// pick the right `perry_method_<class>_<name>` function.
pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option<String> {
match e {
// A declared class/typed-array type is not a lifetime proof. An
// `as any` reassignment can replace the binding with a different
// runtime value, so class-specific field/index/method lowering would
// be unsound for every use of a reassigned local (#6906).
Expr::LocalGet(id) if ctx.reassigned_locals.contains(id) => None,
// Representation-selection Phase 3b: a shape-proven Ptr<Shape> local
// (or one of its const aliases — the exact-receiver inliner's
// `__cmpd_base_N` receivers are typed `Any`) has a provenance-exact
Expand Down
53 changes: 49 additions & 4 deletions crates/perry-runtime/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,12 @@ pub extern "C" fn js_date_new_local_components(
alloc_date_cell(time_clip(local_ms - (tz_offset * 1000) as f64))
}

// --- UTC getters: same impl as the regular getters since we store UTC internally ---
// --- UTC getters -----------------------------------------------------------
//
// Date cells store a UTC timestamp, but the regular getters intentionally
// convert that timestamp through `localtime`. UTC getters must decompose the
// stored timestamp directly; delegating to `getFullYear`/`getMonth`/`getDate`
// makes them silently timezone-dependent (#6967).

#[no_mangle]
pub extern "C" fn js_date_get_utc_day(timestamp: f64) -> f64 {
Expand All @@ -1090,19 +1095,34 @@ pub extern "C" fn js_date_get_utc_day(timestamp: f64) -> f64 {
#[no_mangle]
pub extern "C" fn js_date_get_utc_full_year(timestamp: f64) -> f64 {
let timestamp = date_cell_timestamp(timestamp);
js_date_get_full_year(timestamp)
if timestamp.is_nan() {
return f64::NAN;
}
let secs = (timestamp as i64).div_euclid(1000);
let (year, _, _, _, _, _) = timestamp_to_components(secs);
year as f64
}

#[no_mangle]
pub extern "C" fn js_date_get_utc_month(timestamp: f64) -> f64 {
let timestamp = date_cell_timestamp(timestamp);
js_date_get_month(timestamp)
if timestamp.is_nan() {
return f64::NAN;
}
let secs = (timestamp as i64).div_euclid(1000);
let (_, month, _, _, _, _) = timestamp_to_components(secs);
(month - 1) as f64
}

#[no_mangle]
pub extern "C" fn js_date_get_utc_date(timestamp: f64) -> f64 {
let timestamp = date_cell_timestamp(timestamp);
js_date_get_date(timestamp)
if timestamp.is_nan() {
return f64::NAN;
}
let secs = (timestamp as i64).div_euclid(1000);
let (_, _, day, _, _, _) = timestamp_to_components(secs);
day as f64
}

#[no_mangle]
Expand Down Expand Up @@ -1689,6 +1709,31 @@ mod tests {
assert_eq!((y, m, d, h, min, s), (2024, 1, 15, 12, 30, 45));
}

#[test]
fn utc_getters_ignore_process_timezone() {
const CHILD_MARKER: &str = "PERRY_DATE_UTC_GETTER_CHILD";
if std::env::var_os(CHILD_MARKER).is_some() {
// 2025-06-20T00:00:00.000Z is still June 19 in Los Angeles.
// The three UTC calendar getters must nevertheless keep the UTC
// date, while the old delegation to local getters returned 19.
let timestamp = 1_750_377_600_000.0;
assert_eq!(js_date_get_date(timestamp), 19.0);
assert_eq!(js_date_get_utc_full_year(timestamp), 2025.0);
assert_eq!(js_date_get_utc_month(timestamp), 5.0);
assert_eq!(js_date_get_utc_date(timestamp), 20.0);
return;
}

let status = std::process::Command::new(std::env::current_exe().expect("current test exe"))
.arg("date::tests::utc_getters_ignore_process_timezone")
.arg("--exact")
.env("TZ", "America/Los_Angeles")
.env(CHILD_MARKER, "1")
.status()
.expect("spawn timezone-isolated date getter test");
assert!(status.success(), "timezone-isolated child failed");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Helpers for the setter API: a plain f64 is already its own NaN-boxed
// number; `undefined` is the boxed sentinel.
fn undef() -> f64 {
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-runtime/src/object/descriptor_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,15 @@ pub(crate) fn object_proto_descriptors_in_use() -> bool {
/// has an own property for THIS key; an absent key cannot be intercepted, so the
/// fast path stays safe even while unrelated descriptors exist on the prototype.
pub(crate) fn object_proto_may_intercept_key(key: f64) -> bool {
// #6828: `%Object.prototype%` always owns the Annex-B `__proto__`
// accessor, even though Perry implements that intrinsic in the ordinary
// [[Set]] walk rather than materializing a closure-backed descriptor.
// Treat it as an interceptor so the plain-object direct-store lane cannot
// create an own enumerable `"__proto__"` property before the walk gets a
// chance to invoke the intrinsic setter.
if unsafe { reflect_support::key_to_rust_string(key) }.as_deref() == Some("__proto__") {
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if !object_proto_descriptors_in_use() {
return false;
}
Expand Down
Loading
Loading