diff --git a/changelog.d/runtime-p3-b2-class-prototype-word.md b/changelog.d/runtime-p3-b2-class-prototype-word.md new file mode 100644 index 00000000000..da11fe79c37 --- /dev/null +++ b/changelog.d/runtime-p3-b2-class-prototype-word.md @@ -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). diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 26e73ad7fdf..ee69c642c9d 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -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 { 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) } diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 041b16ee0a5..0842450704c 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -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; diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 70af7c0ba93..393b1c5efbf 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -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 diff --git a/crates/perry-runtime/src/object/field_get_set/undeclared_prototype_tests.rs b/crates/perry-runtime/src/object/field_get_set/undeclared_prototype_tests.rs new file mode 100644 index 00000000000..4bbe653266c --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/undeclared_prototype_tests.rs @@ -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" + ); +} diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index 69a20b93e18..f91240e9732 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -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 diff --git a/tests/parity/language/class/test_p3_b2_class_prototype.ts b/tests/parity/language/class/test_p3_b2_class_prototype.ts new file mode 100644 index 00000000000..b92dc8b69aa --- /dev/null +++ b/tests/parity/language/class/test_p3_b2_class_prototype.ts @@ -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([[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);