diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 211d1da55b..24bada35a6 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -363,7 +363,14 @@ pub extern "C" fn js_object_set_field_by_name( { let o = raw as *mut ObjectHeader; let class_id = (*o).class_id; - if class_id != 0 + // #6595: a per-evaluation CLASS OBJECT must never take + // this lane — it skips the #6530 + // `mirror_class_object_static_write` hook every in-body + // completion runs, and (template cid, key) plans recorded + // by instances sharing the cid would falsely certify it. + // See the matching gates at the plan record sites. + if (*o).object_type == crate::error::OBJECT_TYPE_REGULAR + && class_id != 0 && class_id != NATIVE_MODULE_CLASS_ID && super::prop_plan::store_plan_check(class_id, key as usize) { @@ -1035,9 +1042,14 @@ pub extern "C" fn js_object_set_field_by_name( | crate::gc::OBJ_FLAG_NULL_PROTO | crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; let obj_class_id = (*obj).class_id; + // #6595: class objects (`OBJECT_TYPE_CLASS`) are excluded — their + // writes must always reach the `mirror_class_object_static_write` + // completions, and their cid is shared with their instances so a + // plan keyed on it conflates two different prototype chains. let plan_eligible = !key.is_null() && obj_class_id != 0 && obj_class_id != NATIVE_MODULE_CLASS_ID + && (*obj).object_type == crate::error::OBJECT_TYPE_REGULAR && (*gc_header)._reserved & PLAN_BLOCKING_FLAGS == 0; let plan_fast = plan_eligible && super::prop_plan::store_plan_check(obj_class_id, interned_key as usize); @@ -1203,6 +1215,7 @@ pub extern "C" fn js_object_set_field_by_name( if !plan_fast && obj_class_id != 0 && obj_class_id != NATIVE_MODULE_CLASS_ID + && (*obj).object_type == crate::error::OBJECT_TYPE_REGULAR && obj_flags & PLAN_BLOCKING_FLAGS == 0 { super::prop_plan::store_plan_record(obj_class_id, interned_key as usize); diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f4c47d62b7..39fd68b6c4 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1354,8 +1354,23 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) let key_ptr = crate::builtins::js_string_coerce(key) as *const crate::StringHeader; let interned = crate::object::interned_key_ptr(key_ptr); + // #6595: a per-evaluation CLASS OBJECT (what a + // capture-carrying class materializes as, + // `object_type == OBJECT_TYPE_CLASS`) shares its + // template cid with its instances, and its own-data + // writes must reach the #6530 + // `mirror_class_object_static_write` hook in + // `js_object_set_field_by_name`. Recording a plan + // here armed the mirror-free fast lane for the very + // store being vetted, so from the second same-shaped + // class on (shape-transition cache hit) post-class + // statics like bundled zod's `ZodX.create` vanished + // from ClassRef static dispatch. Class objects + // neither record nor honor store plans. let plan_eligible = header._reserved & CHAIN_DIVERGE == 0 && class_id != crate::object::NATIVE_MODULE_CLASS_ID + && (*(addr as *const crate::ObjectHeader)).object_type + == crate::error::OBJECT_TYPE_REGULAR && interned != 0; if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) diff --git a/test-files/test_gap_6595_repeated_capture_class_statics.ts b/test-files/test_gap_6595_repeated_capture_class_statics.ts new file mode 100644 index 0000000000..ad131194c2 --- /dev/null +++ b/test-files/test_gap_6595_repeated_capture_class_statics.ts @@ -0,0 +1,103 @@ +// #6595 (regression of #6530 introduced by #6541): post-class arrow statics +// on REPEATED same-shaped capture-carrying classes. The receiver-based +// `[[Set]]` records a store plan for (template_cid, key) BEFORE the store +// reaches `js_object_set_field_by_name`, so from the SECOND same-shaped class +// object on (identical key-append sequence → shape-transition cache hit) the +// static write took the raw fast lane and skipped the #6530 +// `CLASS_DYNAMIC_PROPS` mirror — sibling-method dispatch through the INT32 +// ClassRef then missed and returned the class ref itself. +// +// Every class here must be CAPTURE-CARRYING (methods referencing outer +// locals) or it stays an INT32 ClassRef and the statics never touch the +// heap-class-object store path. One class alone can't catch the bug either: +// the first class of a shape misses the transition cache and falls to the +// mirroring path. Bundled zod has ~32 same-shaped classes; two suffice. + +function makeModule() { + const tag = (s: string) => "[" + s + "]"; + const isUndef = (x: any) => typeof x === "undefined"; + const isNull = (x: any) => x === null; + + class Base { + _def: any; + constructor(def: any) { + this._def = def; + this.parse = this.parse.bind(this); + this.optional = this.optional.bind(this); + this.nullable = this.nullable.bind(this); + } + parse(x: any) { + return this._parse({ data: x, errorMap: this._def.errorMap }); + } + _parse(ctx: any): any { + return "base:" + ctx.data; + } + optional() { + return (SubOpt as any).create(this, this._def); + } + nullable() { + return (SubNull as any).create(this, this._def); + } + label() { + return tag(this._def.name); + } + } + + class SubOpt extends Base { + _parse(ctx: any) { + if (isUndef(ctx.data)) return undefined; + return "opt:" + ctx.errorMap + ":" + ctx.data; + } + unwrap() { + return this._def.innerType; + } + } + + class SubNull extends Base { + _parse(ctx: any) { + if (isNull(ctx.data)) return null; + return "null:" + ctx.errorMap + ":" + ctx.data; + } + unwrap() { + return this._def.innerType; + } + } + + // Identical post-class static write sequence on same-shaped class objects: + // the first write learns the shape-transition edge, the second must still + // mirror into the ClassRef-world static table. + (SubOpt as any).create = (inner: any, def: any) => + new SubOpt({ innerType: inner, errorMap: "eo", name: "opt" }); + (SubNull as any).create = (inner: any, def: any) => + new SubNull({ innerType: inner, errorMap: "en", name: "null" }); + + return { Base, SubOpt, SubNull }; +} + +const m = makeModule(); +const b = new m.Base({ errorMap: "bm", name: "base" }); + +// First same-shaped class: static via sibling-method ClassRef dispatch. +const o = b.optional(); +console.log("opt typeof:", typeof o); +console.log("opt instanceof SubOpt:", o instanceof m.SubOpt); +console.log("opt _def set:", o._def !== undefined); +console.log("opt parse undef:", o.parse(undefined)); +console.log("opt parse val:", o.parse(7)); +console.log("opt label:", o.label()); + +// SECOND same-shaped class — the transition-cache-hit static write. +const n = b.nullable(); +console.log("null typeof:", typeof n); +console.log("null instanceof SubNull:", n instanceof m.SubNull); +console.log("null _def set:", n._def !== undefined); +console.log("null parse null:", n.parse(null)); +console.log("null parse val:", n.parse(3)); +console.log("null label:", n.label()); +console.log("null unwrap is base:", n.unwrap() === b); + +// Chained: optional-of-nullable exercises both static dispatches in sequence. +const chained = b.nullable().optional(); +console.log("chain typeof:", typeof chained); +console.log("chain parse undef:", chained.parse(undefined)); +console.log("chain parse val:", chained.parse(5));