Skip to content
1 change: 1 addition & 0 deletions changelog.d/runtime-p3-b2-class-prototype-word.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix: reading `prototype` through a class reference whose id has no class holder (an anonymous-shape id: registered, but with an anonymous declaration and no CLASS word) returned an internal word from the INT32 tag space, which some runtime tests read as the Number `class_id`. It now returns what the declaration path returns, `undefined`, decided at the read. `Reflect.construct` and typed-array constructors given such a `newTarget` use the default prototype, as `GetPrototypeFromConstructor` specifies. Declared and registered classes are unchanged: their prototype object is built lazily on first read, as before. The `C.prototype` reference word stays runtime-internal, as a method's home for `super` and brand lookup (P3 B2).
9 changes: 8 additions & 1 deletion crates/perry-runtime/src/object/class_registry/construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,13 +790,20 @@ unsafe fn construct_registered_class_ref_entry(
/// it is an object" rule: returns `newTarget.prototype`'s bits when that value
/// is an object (so a typed-array view should adopt it as its `[[Prototype]]`),
/// or `None` when it is a primitive (so the default per-kind prototype applies).
// One out-of-line copy for its three construct paths: P3 B2 made the body
// small enough for LLVM to inline it into each, which grew `.text` by
// ~1.2 KB (CWN: binaries must not grow).
#[inline(never)]
fn new_target_custom_object_prototype(new_target: f64) -> Option<u64> {
if let Some(class_id) = constructor_class_ref_id(new_target) {
let declared = super::class_decl_prototype_value(class_id);
if unsafe { super::super::value_is_object_like(declared) } {
return Some(declared.to_bits());
}
return Some(super::super::class_prototype_ref_value(class_id).to_bits());
// P3 B2 (REVIEW-p3 D3): `newTarget.prototype` is not an object (an id
// with no declaration reads `undefined`, the same answer as the
// `prototype` read), so the default per-kind prototype applies.
return None;
}
let proto = new_target_prototype_get(new_target)?;
if unsafe { super::super::value_is_object_like(proto) }
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/object/field_get_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ pub(crate) mod ic_miss;
#[cfg(test)]
#[path = "field_get_set/ic_miss_array_length_tests.rs"]
mod ic_miss_array_length_tests;
#[cfg(test)]
#[path = "field_get_set/undeclared_prototype_tests.rs"]
mod undeclared_prototype_tests;
mod map_set_receiver;
#[cfg(test)]
mod map_set_receiver_tests;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ pub(crate) fn class_value_get_field(
&& is_class_id_registered(class_id)
&& !is_prototype_ref
{
// P3 B2 (DESIGN-p3 rev 3.1 §3, REVIEW-p3 D3): the declaration
// path is the answer. A declared class builds its prototype
// lazily on this first read (`class_decl_prototype_value`); an id
// with no declaration (`!class_identity_has_surface`) has none and
// reads `undefined`, decided here at the read and never at
// registration. No INT32-space prototype word reaches JS.
let value = super::super::class_registry::class_decl_prototype_value(class_id);
if value.to_bits() == crate::value::TAG_UNDEFINED {
let value = super::super::class_prototype_ref_value(class_id);
return JSValue::from_bits(value.to_bits());
}
return JSValue::from_bits(value.to_bits());
}
// A capture-carrying class declaration is materialized as a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! P3 B2 (DESIGN-p3 rev 3.1 §3, REVIEW-p3 D3): the `prototype` read of a
//! registered class id is the declaration path's answer, decided at the read.
//!
//! The one id with no prototype object is an anonymous-shape id: registered
//! (for `typeof`, literal methods) with an ANONYMOUS declaration, so it has no
//! declaration holder and publishes no CLASS word
//! (`!class_identity_has_surface`). Its `prototype` reads `undefined`. Before
//! B2 this fallback returned a JS-visible `0x7FFE | CLASS_PROTOTYPE_REF_FLAG`
//! word from the INT32 tag space.

fn register(cid: u32) {
let mut guard = crate::object::REGISTERED_CLASS_IDS.write().unwrap();
guard
.get_or_insert_with(crate::fast_hash::new_ptr_hash_set)
.insert(cid);
}

fn read_prototype(cid: u32) -> crate::value::JSValue {
let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9);
let bits = crate::object::class_constructor_key_bits(cid);
super::get_field_by_name::class_value_get_field(std::ptr::null(), key, bits, cid)
}

#[test]
fn an_anonymous_shape_id_reads_prototype_as_undefined() {
let cid = 0x6b21;
register(cid);
unsafe { crate::object::declarations::test_declare_anonymous(cid) };
assert_eq!(
crate::object::class_registry::class_decl_prototype_value(cid).to_bits(),
crate::value::TAG_UNDEFINED,
"an anonymous-shape id has no prototype object"
);
let value = read_prototype(cid);
assert_eq!(
value.bits(),
crate::value::TAG_UNDEFINED,
"prototype of an anonymous-shape id"
);
}

#[test]
fn a_registered_class_reads_prototype_as_its_object_never_the_ref_word() {
let cid = 0x6b22;
register(cid);
let value = read_prototype(cid);
assert!(
value.is_pointer(),
"a registered class's prototype is an object: {:#x}",
value.bits()
);
assert!(
!crate::value::is_int32_bits(value.bits()),
"never an INT32-space word"
);
assert_eq!(
value.bits(),
crate::object::class_registry::class_decl_prototype_value(cid).to_bits(),
"the read is the declaration path's prototype"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
//
// A class constructor value is its function object (`class_value`); the
// `C.prototype` half is a `0x7FFE` word with `CLASS_PROTOTYPE_REF_FLAG` set,
// which is never an int32 number. Extracted from `native_module.rs` to keep that file
// which is never an int32 number. Since P3 B2 that word is runtime-internal
// only (a method's home for super/brand lookup): a `C.prototype` read returns
// the declaration's prototype object, or `undefined` for an id with no
// declaration (`get_field_by_name.rs`, `construct.rs`), never this word. Extracted from `native_module.rs` to keep that file
// under the repository's 2000-line cap. Textually `include!`d (like
// `class_method_values.rs`), so every item keeps the module path and visibility
// it had before -- hence line comments, not `//!` inner docs, which are illegal
Expand Down
55 changes: 55 additions & 0 deletions tests/parity/language/class/test_p3_b2_class_prototype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// P3 B2 controls (DESIGN-p3 rev 3.1 §3): `C.prototype` is the class's
// prototype object everywhere a program can see it, never a word that reads
// as a Number. Own keys are exactly the declaration's (no key added or
// dropped by materializing the prototype).

function describe(p: any): string {
return [typeof p, p === null, Number(p), String(p), p + 1, Object.getOwnPropertyNames(p).join("|")].join(",");
}

// Read before any `new`, then after.
class A { m(): number { return 1; } get g(): number { return 2; } static s(): number { return 3; } }
const before = A.prototype;
console.log("A before", describe(before), before === A.prototype);
const a = new A();
console.log("A after", Object.getPrototypeOf(a) === A.prototype, Object.getPrototypeOf(a) === before, a instanceof A);
console.log("A keys", Object.keys(A.prototype).length, Object.getOwnPropertyNames(A.prototype).join("|"),
Object.getOwnPropertyDescriptor(A.prototype, "constructor")!.enumerable, A.prototype.constructor === A);

// `new` first, then the read.
class B { x = 1; }
const b = new B();
console.log("B", Object.getPrototypeOf(b) === B.prototype, describe(B.prototype), b instanceof B);

// Static initializers see the prototype.
class C {
static seen = this.prototype;
static ok: boolean;
static { C.ok = Object.getPrototypeOf(new C()) === this.prototype; }
n(): string { return "n"; }
}
console.log("C static", C.seen === C.prototype, C.ok, typeof C.seen);

// Derived classes, class expressions, anonymous classes.
class D extends A { m(): number { return 10; } }
const E = class { e(): number { return 5; } };
const F = (() => class {})();
console.log("D", Object.getPrototypeOf(D.prototype) === A.prototype, new D() instanceof A, new D().m(), describe(D.prototype));
console.log("E F", describe(E.prototype), describe(F.prototype), new E() instanceof E, Object.getPrototypeOf(new F()) === F.prototype);

// defineProperty on the prototype, visible through instances.
Object.defineProperty(B.prototype, "tag", { value: "t", enumerable: false, configurable: true });
console.log("define", (b as any).tag, (new B() as any).tag, Object.getOwnPropertyNames(B.prototype).join("|"));

// The prototype as a Map key, an object key and an index.
const m = new Map<any, string>([[A.prototype, "a"], [B.prototype, "b"]]);
const o: any = {};
o[A.prototype as any] = 1;
const arr: any[] = [];
arr[C.prototype as any] = 2;
console.log("keys", m.get(A.prototype), m.get(B.prototype), m.get(Object.getPrototypeOf(a)), Object.keys(o).join(","),
arr.length, Object.keys(arr).join(","), new Set([A.prototype, A.prototype]).size);

// Reflect.construct with a class newTarget takes its prototype.
const viaReflect = Reflect.construct(A, [], B);
console.log("reflect", Object.getPrototypeOf(viaReflect) === B.prototype, viaReflect instanceof B);
Loading