From 586acedebcf353f73b1b3030ee2767a75ce8274b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 09:56:41 +0100 Subject: [PATCH 1/5] runtime: a class prototype read never returns the INT32-space prototype word (P3 B2) --- .../runtime-p3-b2-class-prototype-word.md | 1 + .../src/object/class_registry/construct.rs | 5 +- .../perry-runtime/src/object/field_get_set.rs | 3 + .../object/field_get_set/get_field_by_name.rs | 10 ++-- .../undeclared_prototype_tests.rs | 25 +++++++++ .../object/native_module/class_ref_values.rs | 5 +- .../class/test_p3_b2_class_prototype.ts | 55 +++++++++++++++++++ 7 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 changelog.d/runtime-p3-b2-class-prototype-word.md create mode 100644 crates/perry-runtime/src/object/field_get_set/undeclared_prototype_tests.rs create mode 100644 tests/parity/language/class/test_p3_b2_class_prototype.ts 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..63c2d1e6f35 --- /dev/null +++ b/changelog.d/runtime-p3-b2-class-prototype-word.md @@ -0,0 +1 @@ +Fix: reading `prototype` on a registered class id that has no declaration 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 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..4bd2c3e16db 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -796,7 +796,10 @@ fn new_target_custom_object_prototype(new_target: f64) -> Option { 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..b51d2335249 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/undeclared_prototype_tests.rs @@ -0,0 +1,25 @@ +//! P3 B2 (DESIGN-p3 rev 3.1 §3, REVIEW-p3 D3): a registered class id with no +//! declaration (`!class_identity_has_surface`) reads `prototype` as +//! `undefined`, the declaration path's own answer, decided at the read. Before +//! B2 this fallback returned a JS-visible `0x7FFE | CLASS_PROTOTYPE_REF_FLAG` +//! word from the INT32 tag space. + +#[test] +fn an_undeclared_class_reads_prototype_as_undefined() { + let cid = 0x6b21; + { + let mut guard = crate::object::REGISTERED_CLASS_IDS.write().unwrap(); + guard + .get_or_insert_with(crate::fast_hash::new_ptr_hash_set) + .insert(cid); + } + assert_eq!( + crate::object::class_registry::class_decl_prototype_value(cid).to_bits(), + crate::value::TAG_UNDEFINED, + "an id with no declaration has no prototype object" + ); + let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); + let bits = crate::object::class_constructor_key_bits(cid); + let value = super::get_field_by_name::class_value_get_field(std::ptr::null(), key, bits, cid); + assert_eq!(value.bits(), crate::value::TAG_UNDEFINED, "C.prototype of an undeclared id"); +} 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); From 234d60be78864c8437db4882bf8f38933c7f151b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 09:56:58 +0100 Subject: [PATCH 2/5] rustfmt --- .../src/object/field_get_set/undeclared_prototype_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index b51d2335249..4512a7a8ef0 100644 --- 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 @@ -21,5 +21,9 @@ fn an_undeclared_class_reads_prototype_as_undefined() { let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); let bits = crate::object::class_constructor_key_bits(cid); let value = super::get_field_by_name::class_value_get_field(std::ptr::null(), key, bits, cid); - assert_eq!(value.bits(), crate::value::TAG_UNDEFINED, "C.prototype of an undeclared id"); + assert_eq!( + value.bits(), + crate::value::TAG_UNDEFINED, + "C.prototype of an undeclared id" + ); } From 1589f70a4fa6a5d4c42cb6c6f8f768aea2c9fb10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 11:56:34 +0100 Subject: [PATCH 3/5] B2 test: pin the anonymous-shape id (no prototype object) and a registered class (its prototype object, never the word) --- .../undeclared_prototype_tests.rs | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) 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 index 4512a7a8ef0..4bbe653266c 100644 --- 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 @@ -1,29 +1,61 @@ -//! P3 B2 (DESIGN-p3 rev 3.1 §3, REVIEW-p3 D3): a registered class id with no -//! declaration (`!class_identity_has_surface`) reads `prototype` as -//! `undefined`, the declaration path's own answer, decided at the read. Before +//! 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_undeclared_class_reads_prototype_as_undefined() { +fn an_anonymous_shape_id_reads_prototype_as_undefined() { let cid = 0x6b21; - { - let mut guard = crate::object::REGISTERED_CLASS_IDS.write().unwrap(); - guard - .get_or_insert_with(crate::fast_hash::new_ptr_hash_set) - .insert(cid); - } + 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 id with no declaration has no prototype object" + "an anonymous-shape id has no prototype object" ); - let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); - let bits = crate::object::class_constructor_key_bits(cid); - let value = super::get_field_by_name::class_value_get_field(std::ptr::null(), key, bits, cid); + let value = read_prototype(cid); assert_eq!( value.bits(), crate::value::TAG_UNDEFINED, - "C.prototype of an undeclared id" + "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" ); } From 431b62b3dd3f5b8b56d2b3b75d2c17174deaf2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 11:56:42 +0100 Subject: [PATCH 4/5] B2 changelog: the undefined case is the anonymous-shape id --- changelog.d/runtime-p3-b2-class-prototype-word.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/runtime-p3-b2-class-prototype-word.md b/changelog.d/runtime-p3-b2-class-prototype-word.md index 63c2d1e6f35..da11fe79c37 100644 --- a/changelog.d/runtime-p3-b2-class-prototype-word.md +++ b/changelog.d/runtime-p3-b2-class-prototype-word.md @@ -1 +1 @@ -Fix: reading `prototype` on a registered class id that has no declaration 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 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). +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). From 3d4a2f54d7fe0d7080cbded46e9d331b6890b44b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 12:46:54 +0100 Subject: [PATCH 5/5] runtime: keep new_target_custom_object_prototype out of line (B2 .text) --- crates/perry-runtime/src/object/class_registry/construct.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 4bd2c3e16db..ee69c642c9d 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -790,6 +790,10 @@ 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);