Skip to content

Two evaluations of one class declaration share #private storage keys: 'Cannot initialize a private field twice' when the second extends the first #11163

Closed

Description

@proggeramlug

Summary

Perry stores a per-evaluation class's #private fields under keys derived from the compile-time template class id, not from the class evaluation. Two evaluations of one class declaration therefore share the same private names on an object. When a factory evaluates a class twice and the second evaluation extends the first, the derived constructor throws while its own field is being initialized:

TypeError: Cannot initialize a private field twice on the same object

Per spec, each ClassDefinitionEvaluation creates fresh Private Names, so the instance legitimately carries both fields.

Repro (no packages)

function make(Base: any, tag: string) {
  class C extends Base {
    #v: string;
    constructor() { super(); this.#v = tag; }
    get() { return this.#v; }
    static has(o: any) { return #v in o; }
    static read(o: any) { return o.#v; }
  }
  return C;
}
class Root {}
const C1: any = make(Root, "first");
const C2: any = make(C1, "second");
const x = new C2();
console.log("own", x.get());
console.log("C1 method on x", C1.prototype.get.call(x));
console.log("C2 method on x", C2.prototype.get.call(x));
console.log("static read", C1.read(x), C2.read(x));
console.log("has", C1.has(x), C2.has(x));
const y = new C1();
console.log("C1 only", y.get(), C1.has(y), C2.has(y));
try { console.log(C2.read(y)); } catch (e) { console.log("C2.read(y) threw", (e as Error).constructor.name); }
const C3: any = make(Root, "third");
console.log("unrelated", C3.has(x), C1.has(new C3()));

Cause

js_private_field_add / js_private_brand_add (crates/perry-runtime/src/object/field_get_set/ic_miss.rs) build the value key #<perry:private-value:{declaring_class_id}:{name}> and the marker #<perry:private-field:{declaring_class_id}:{name}> from the template id alone. The second evaluation's field write finds the first evaluation's marker and throws.

Related follow-up (the brand half)

Once storage is per-evaluation, the brand check also has to match evaluations by identity along the instance's heritage chain. Today private_evaluation_brand_matches compares the expected lexical evaluation with the first evaluation of the template on the chain, so C1.prototype.get.call(x) would be rejected. CodeRabbit raised this on #11141.

A candidate for that half is private_evaluation_brand_is (an identity walk over the pinned heritage chain, with a unit test for two evaluations on one chain). It is on branch wip/11141-identity-uncompiled, commit 264e08b. It has not been compiled or tested, and it is not useful until the storage keys above are per-evaluation.

Activity

  1. proggeramlug commented on Sep 24, 2026

    @proggeramlug
    ContributorAuthor

    The branch wip/11141-identity-uncompiled is being deleted, so here is the brand-half candidate as a patch against #11141 head 1423921. It has never been compiled or tested. Apply it together with the per-evaluation storage-key fix.

    diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs
    index b3dc7da43..cbe8a75ba 100644
    --- a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs
    +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs
    @@ -289,6 +289,40 @@ fn instance_ancestor_evaluation_brand(brand: f64, declaring_class_id: u32) -> Op
             .map(f64::to_bits)
     }
     
    +/// Does `obj` carry the private brand of exactly the class evaluation
    +/// `expected` (an evaluation of `declaring_class_id`'s template)?
    +///
    +/// A class-object receiver (static private members) must BE that evaluation.
    +/// An instance carries every evaluation on its stamp's pinned heritage chain,
    +/// so test chain membership by identity rather than taking the first
    +/// evaluation whose template id matches: a factory that evaluates one class
    +/// declaration twice, with the second evaluation extending the first, puts
    +/// two evaluations of the same template on one chain, and each has its own
    +/// private names.
    +fn private_evaluation_brand_is(obj: f64, declaring_class_id: u32, expected: u64) -> bool {
    +    let receiver = crate::proxy::private_element_receiver(obj);
    +    if super::super::class_registry::is_class_object_value(receiver) {
    +        return private_evaluation_brand(obj, declaring_class_id) == Some(expected);
    +    }
    +    let Some(mut current) = private_evaluation_brand_value(obj) else {
    +        return false;
    +    };
    +    for _ in 0..32 {
    +        if !super::super::class_registry::is_class_object_value(current) {
    +            return false;
    +        }
    +        if current.to_bits() == expected {
    +            return true;
    +        }
    +        let object = JSValue::from_bits(current.to_bits()).as_pointer::<ObjectHeader>();
    +        let Some(parent) = super::super::class_registry::class_object_pinned_parent(object) else {
    +            return false;
    +        };
    +        current = parent;
    +    }
    +    false
    +}
    +
     /// If the lexical class evaluation can be recovered from `brand_owner`,
     /// compare `obj` against that exact evaluation. `None` asks callers to retain
     /// the existing template-class check for ordinary (single-evaluation) classes.
    @@ -313,8 +347,11 @@ fn private_evaluation_brand_matches(
         }
     
         if let Some(expected) = current_private_lexical_brand(declaring_class_id) {
    -        let actual = private_evaluation_brand(obj, declaring_class_id);
    -        return Some(actual == Some(expected));
    +        return Some(private_evaluation_brand_is(
    +            obj,
    +            declaring_class_id,
    +            expected,
    +        ));
         }
     
         let brand_owner = if super::super::class_registry::is_class_object_value(brand_owner) {
    @@ -330,7 +367,7 @@ fn private_evaluation_brand_matches(
             brand_owner
         };
         let expected = private_evaluation_brand(brand_owner, declaring_class_id)?;
    -    Some(private_evaluation_brand(obj, declaring_class_id) == Some(expected))
    +    Some(private_evaluation_brand_is(obj, declaring_class_id, expected))
     }
     
     #[no_mangle]
    @@ -461,4 +498,35 @@ mod instance_ancestor_evaluation_brand_tests {
                 assert_eq!(private_evaluation_brand(f, CID_S), None);
             }
         }
    +
    +    /// Two evaluations of ONE template on one heritage chain (a factory
    +    /// whose second evaluation extends its first): each evaluation's brand is
    +    /// matched by identity, not by the first template-id hit on the chain.
    +    #[test]
    +    fn repeated_template_evaluations_on_one_chain_match_by_identity() {
    +        unsafe {
    +            const CID_T: u32 = 62_521;
    +            let first = class_object(CID_T);
    +            let second = class_object(CID_T);
    +            let unrelated = class_object(CID_T);
    +            pin_parent(second, first);
    +
    +            let instance = crate::object::js_object_alloc(CID_T, 0);
    +            stamp_private_evaluation_brand(instance, second);
    +            let instance = crate::value::js_nanbox_pointer(instance as i64);
    +
    +            assert!(private_evaluation_brand_is(instance, CID_T, second.to_bits()));
    +            assert!(private_evaluation_brand_is(instance, CID_T, first.to_bits()));
    +            assert!(!private_evaluation_brand_is(instance, CID_T, unrelated.to_bits()));
    +
    +            let only_first = crate::object::js_object_alloc(CID_T, 0);
    +            stamp_private_evaluation_brand(only_first, first);
    +            let only_first = crate::value::js_nanbox_pointer(only_first as i64);
    +            assert!(private_evaluation_brand_is(only_first, CID_T, first.to_bits()));
    +            assert!(!private_evaluation_brand_is(only_first, CID_T, second.to_bits()));
    +
    +            assert!(private_evaluation_brand_is(first, CID_T, first.to_bits()));
    +            assert!(!private_evaluation_brand_is(second, CID_T, first.to_bits()));
    +        }
    +    }
     }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions