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: 9 additions & 1 deletion crates/perry-runtime/src/intl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1563,7 +1563,15 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
if JSValue::from_bits(proto.to_bits()).is_pointer() {
crate::object::prototype_chain::object_set_static_prototype(obj as usize, proto.to_bits());
}
js_nanbox_pointer(obj as i64)
let instance = js_nanbox_pointer(obj as i64);
// ChainNumberFormat / ChainDateTimeFormat only (see `chain_legacy_constructed`):
// Intl.Collator ignores its this-value, so it is deliberately excluded.
if matches!(kind, KIND_NUMBER | KIND_DATE_TIME) {
if let Some(this_value) = ctor_guard::chain_legacy_constructed(closure, instance) {
return this_value;
}
}
instance
}

fn install_bound_instance_function(
Expand Down
113 changes: 110 additions & 3 deletions crates/perry-runtime/src/intl/ctor_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
//! `intl.rs` to keep that file under the repository's 2,000-line gate.

use crate::closure::ClosureHeader;
use crate::object::{js_object_get_field_by_name_f64, ObjectHeader};
use crate::object::{js_object_get_field_by_name_f64, ObjectHeader, PropertyAttrs};
use crate::string::js_string_from_bytes;
use crate::value::JSValue;

const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;

/// `GetPrototypeFromConstructor(new.target, "%<Ctor>Prototype%")`: a
/// `Reflect.construct(Intl.X, args, CustomCtor)` call should install
/// `CustomCtor.prototype` on the result (test262 `ctor-custom-prototype.js`),
/// falling back to the invoked closure's own `"prototype"` when `new.target`
/// is absent (a bare `new Intl.X()`) or its `prototype` isn't an object.
pub(super) fn constructor_target_prototype(closure: *const ClosureHeader) -> f64 {
const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
let new_target = crate::object::js_new_target_get();
let bits = new_target.to_bits();
if (bits & !POINTER_MASK) == POINTER_TAG {
Expand All @@ -38,3 +39,109 @@ pub(super) fn require_new_target(name: &str) {
super::throw_type_error(&format!("Constructor Intl.{name} requires 'new'"));
}
}

/// The immediate `[[Prototype]]` pointer of an object at `obj_ptr`, resolved
/// across Perry's two prototype-recording mechanisms:
/// * the static-prototype side table (`object_set_static_prototype`, used by
/// Intl instances and `Object.setPrototypeOf`), and
/// * a synthetic `class_id` → `CLASS_PROTOTYPE_OBJECTS` entry (used by
/// `Object.create(proto)`).
/// Returns `None` when the object has its default `Object.prototype` (or a null
/// prototype), which is never a service constructor's own `.prototype`.
fn object_proto_ptr(obj_ptr: usize) -> Option<usize> {
if let Some(proto_bits) = crate::object::prototype_chain::object_static_prototype(obj_ptr) {
if (proto_bits & !POINTER_MASK) == POINTER_TAG {
let p = (proto_bits & POINTER_MASK) as usize;
return (p != 0).then_some(p);
}
return None;
}
let class_id = unsafe {
let obj = obj_ptr as *const ObjectHeader;
if !crate::object::is_valid_obj_ptr(obj as *const u8) {
return None;
}
(*obj).class_id
};
if class_id == 0 {
return None;
}
let proto = crate::object::class_prototype_object(class_id);
(!proto.is_null()).then_some(proto as usize)
}

/// True when `receiver`'s prototype chain contains `Ctor.prototype` — the
/// `OrdinaryHasInstance(constructor, receiver)` test used by ChainNumberFormat /
/// ChainDateTimeFormat. Walks the resolved prototype chain (bounded to avoid a
/// cyclic-proto hang).
fn receiver_on_constructor_chain(receiver: f64, closure: *const ClosureHeader) -> bool {
let ctor_proto = crate::closure::closure_get_dynamic_prop(closure as usize, "prototype");
let ctor_proto_bits = ctor_proto.to_bits();
if (ctor_proto_bits & !POINTER_MASK) != POINTER_TAG {
return false;
}
let ctor_proto_ptr = (ctor_proto_bits & POINTER_MASK) as usize;
if ctor_proto_ptr == 0 {
return false;
}
let mut cur = (receiver.to_bits() & POINTER_MASK) as usize;
for _ in 0..64 {
match object_proto_ptr(cur) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Some(proto_ptr) => {
if proto_ptr == ctor_proto_ptr {
return true;
}
cur = proto_ptr;
}
None => return false,
}
}
false
}

/// ChainNumberFormat / ChainDateTimeFormat (ECMA-402 normative-optional): when a
/// legacy Intl service constructor is invoked as a *plain function* (no `new`)
/// whose `this` is an object on the constructor's prototype chain, stash the
/// freshly-built internal `instance` under `%Intl%.[[FallbackSymbol]]` on `this`
/// (a non-writable / non-enumerable / non-configurable data property) and return
/// `this`. Returns `Some(this)` when the chain applied, else `None` so the caller
/// returns the plain instance.
///
/// V8/Node implement this for `NumberFormat` and `DateTimeFormat` only —
/// `Intl.Collator` ignores its this-value and always returns a fresh object
/// (test262 `Collator/this-value-ignored.js`), so the caller must not invoke
/// this for the Collator kind.
pub(super) fn chain_legacy_constructed(
closure: *const ClosureHeader,
instance: f64,
) -> Option<f64> {
// Only the legacy path: a `new`-invocation (new.target present) always
// returns the fresh instance.
if crate::object::js_new_target_get().to_bits() != crate::value::TAG_UNDEFINED {
return None;
}
let this_value = crate::object::js_implicit_this_get();
let this_bits = this_value.to_bits();
// `this` must be an Object (pointer-tagged, valid heap object).
if (this_bits & !POINTER_MASK) != POINTER_TAG {
return None;
}
let this_ptr = (this_bits & POINTER_MASK) as usize;
if this_ptr == 0 || !crate::object::is_valid_obj_ptr(this_ptr as *const u8) {
return None;
}
if !receiver_on_constructor_chain(this_value, closure) {
return None;
}
let fallback_sym = crate::symbol::intl_legacy_constructed_symbol();
unsafe {
crate::symbol::js_object_set_symbol_property(this_value, fallback_sym, instance);
}
let sym_key = (fallback_sym.to_bits() & POINTER_MASK) as usize;
crate::symbol::set_symbol_property_attrs(
this_ptr,
sym_key,
PropertyAttrs::new(false, false, false),
);
Comment on lines +136 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the descriptor-aware property definition APIs to reuse for symbol keys.
rg -n -C4 'DefineProperty|define_property|PropertyDescriptor|non_configurable|is_extensible|preventExtensions|set_symbol_property_attrs' crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the target context and the symbol property helpers.
sed -n '1,220p' crates/perry-runtime/src/intl/ctor_guard.rs
printf '\n--- symbol properties ---\n'
sed -n '1,220p' crates/perry-runtime/src/symbol/properties.rs
printf '\n--- ordinary define-property path ---\n'
sed -n '146,220p' crates/perry-runtime/src/object/reflect_support.rs
printf '\n--- object symbol helpers ---\n'
rg -n -C3 'js_object_set_symbol_property|set_symbol_property_attrs|reflect_define_property|js_object_define_property|PropertyAttrs::new' crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the concrete symbol write path and the ordinary define-property implementation.
sed -n '360,430p' crates/perry-runtime/src/symbol/properties.rs
printf '\n---\n'
rg -n -C4 'pub extern "C" fn js_object_define_property|pub(crate) fn js_object_define_property|define_property' crates/perry-runtime/src/object crates/perry-runtime/src/symbol
printf '\n---\n'
sed -n '1,260p' crates/perry-runtime/src/object/mod.rs

Repository: PerryTS/perry

Length of output: 48096


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the symbol write helper internals and the define-property symbol handling.
sed -n '240,370p' crates/perry-runtime/src/symbol/properties.rs
printf '\n---\n'
sed -n '110,220p' crates/perry-runtime/src/object/object_ops/define_property.rs
printf '\n---\n'
sed -n '720,920p' crates/perry-runtime/src/object/object_ops/define_property.rs

Repository: PerryTS/perry

Length of output: 22082


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where the Intl fallback symbol is created and read back.
rg -n -C3 'intl_legacy_constructed_symbol|legacy_constructed_symbol|FallbackSymbol|legacy_constructed' crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 7345


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the symbol-key branch inside Object.defineProperty.
rg -n -C6 'js_is_symbol|symbol key|sym_key|set_symbol_property_attrs|js_object_set_symbol_property|throw_object_type_error' crates/perry-runtime/src/object/object_ops/define_property.rs crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs crates/perry-runtime/src/symbol/properties.rs

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact symbol-key branch in Object.defineProperty and the surrounding helper behavior.
sed -n '140,260p' crates/perry-runtime/src/object/object_ops/define_property.rs
printf '\n---\n'
sed -n '260,420p' crates/perry-runtime/src/object/object_ops/define_property.rs

Repository: PerryTS/perry

Length of output: 15182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect symbol-key handling in the ordinary object define-property implementation.
rg -n -C4 'js_is_symbol\(key_value\)|js_object_set_symbol_property\(|set_symbol_property_attrs\(|PropertyAttrs::new\(false, false, false\)' crates/perry-runtime/src/object/object_ops/define_property.rs crates/perry-runtime/src/object/tests.rs

Repository: PerryTS/perry

Length of output: 12239


Install the fallback slot through the descriptor path crates/perry-runtime/src/intl/ctor_guard.rs:136-145

chain_legacy_constructed still does a best-effort symbol write and then unconditionally returns Some(this_value), so a non-extensible receiver is accepted even when the fallback slot was not created. Route this through the define-property path (or propagate the store result) so the legacy call either installs the non-writable/non-enumerable/non-configurable data property or throws.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/ctor_guard.rs` around lines 136 - 145, The
fallback-slot install in chain_legacy_constructed currently writes the symbol
directly and still returns Some(this_value) even if the receiver cannot accept
it. Update the logic around chain_legacy_constructed,
js_object_set_symbol_property, and set_symbol_property_attrs so the fallback is
created through the descriptor/define-property path, or otherwise propagate the
store result; if the property cannot be installed on a non-extensible receiver,
the call should fail/throw instead of succeeding silently. Ensure the installed
fallback slot remains non-writable, non-enumerable, and non-configurable.

Some(this_value)
}
39 changes: 38 additions & 1 deletion crates/perry-runtime/src/intl/number_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,14 @@ pub(crate) fn intl_object_from_value(
method: &str,
expected_kind: &str,
) -> *mut ObjectHeader {
let Some(obj) = object_ptr_from_value(value) else {
// UnwrapNumberFormat / UnwrapDateTimeFormat: a receiver produced by the
// legacy `Intl.X.call(obj)` chain isn't itself an initialized instance — it
// carries the real instance under `%Intl%.[[FallbackSymbol]]`. A `Get` of
// that symbol also fires a Proxy `get` trap (so `resolvedOptions.call(proxy)`
// observes the symbol read, per intl-legacy-constructed-symbol-on-unwrap).
let unwrapped = unwrap_legacy_constructed(value);
let receiver = unwrapped.unwrap_or(value);
let Some(obj) = object_ptr_from_value(receiver) else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
throw_type_error(&format!(
"Intl.{expected_kind}.prototype.{method} called on incompatible receiver"
));
Expand All @@ -1216,6 +1223,36 @@ pub(crate) fn intl_object_from_value(
obj
}

/// If `value` is not itself an initialized Intl instance but carries
/// `%Intl%.[[FallbackSymbol]]` (the legacy-constructed wrapper), return the
/// wrapped instance value. Reads through a Proxy `get` trap when `value` is a
/// Proxy. Returns `None` when there is nothing to unwrap.
fn unwrap_legacy_constructed(value: f64) -> Option<f64> {
let js = JSValue::from_bits(value.to_bits());
if !js.is_pointer() {
return None;
}
// A Proxy is a small registered id, not a heap object — `object_ptr_from_value`
// would mis-classify it (its band overlaps the heap range on Linux) and
// dereference garbage. Route straight to the symbol `Get`, which fires the
// proxy `get` trap (intl-legacy-constructed-symbol-on-unwrap).
if crate::proxy::js_proxy_is_proxy(value) == 0 {
// A direct, already-initialized instance never needs unwrapping.
if let Some(obj) = object_ptr_from_value(value) {
if get_string_field(obj, KEY_KIND).is_some() {
return None;
}
}
}
let fallback_sym = crate::symbol::intl_legacy_constructed_symbol();
let wrapped = unsafe { crate::symbol::js_object_get_symbol_property(value, fallback_sym) };
if JSValue::from_bits(wrapped.to_bits()).is_pointer() {
Some(wrapped)
} else {
None
}
}

/// `get Intl.NumberFormat.prototype.format` — the ECMA-402 accessor. Validates
/// that `this` is an initialized NumberFormat (TypeError otherwise) and returns
/// the instance's bound format function ([[BoundFormat]]). It reads the hidden
Expand Down
34 changes: 34 additions & 0 deletions crates/perry-runtime/src/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,40 @@ pub(crate) fn register_symbol_pointer(ptr: usize) {
guard.as_mut().unwrap().insert(ptr);
}

// The `%Intl%.[[FallbackSymbol]]` — a single per-realm symbol whose description
// is exactly `"IntlLegacyConstructedSymbol"` (no `Symbol.` prefix, so it is
// *not* a well-known symbol). It is stashed on the receiver when a legacy Intl
// service constructor (`Intl.NumberFormat` / `Intl.DateTimeFormat`) is called as
// a plain function whose `this` is on the constructor's prototype chain (the
// ChainNumberFormat / ChainDateTimeFormat normative-optional path), and read
// back by the UnwrapXxx step so `nf.resolvedOptions()` on the wrapped object
// still works.
static INTL_FALLBACK_SYMBOL: Mutex<Option<usize>> = Mutex::new(None);

/// Return the process-wide `%Intl%.[[FallbackSymbol]]` as a NaN-boxed
/// (POINTER_TAG) JSValue, allocating & registering it lazily on first use.
pub fn intl_legacy_constructed_symbol() -> f64 {
let mut guard = INTL_FALLBACK_SYMBOL.lock().unwrap();
if let Some(ptr) = *guard {
return f64::from_bits(POINTER_TAG | (ptr as u64 & POINTER_MASK));
}
// Persistent (leaked) symbol so it outlives every GC cycle — its identity is
// realm-global. Description text lives in REGISTERED_SYMBOL_DESCRIPTIONS
// (readers materialize a fresh StringHeader on demand), matching the
// well-known-symbol contract.
let boxed = Box::new(SymbolHeader {
magic: SYMBOL_MAGIC,
registered: 0,
description: std::ptr::null_mut(),
id: next_id(),
});
let sym_ptr = Box::into_raw(boxed) as usize;
record_registered_symbol_description(sym_ptr, "IntlLegacyConstructedSymbol");
register_symbol_pointer(sym_ptr);
*guard = Some(sym_ptr);
f64::from_bits(POINTER_TAG | (sym_ptr as u64 & POINTER_MASK))
}

/// O(1) check whether a raw pointer (already untagged) is a known Symbol.
/// Safe to call on any pointer-shaped value — no dereference is performed.
pub fn is_registered_symbol(ptr: usize) -> bool {
Expand Down
Loading