Skip to content

An own property shadowing a native method is IGNORED on Map/Set/RegExp/Date/Array — m.get = () => x; m.get() runs the native method (wrong value, plain JS, on main) #10943

Description

@proggeramlug

Found triaging whether #10942's defect class is latent. It is not. This needs no dictionary mode, no flag and no unusual construction — it reproduces on pristine main v0.5.1632 (841b605c9) from ordinary JS.

Repro

const m = new Map();
m.get = () => "own";
console.log(m.get());                                    // node: "own"    perry: undefined
console.log(typeof m.get, Object.prototype.hasOwnProperty.call(m, "get"), Object.keys(m));
//          node & perry BOTH: "function" true ["get"]

The own property is visible to every reflective operation — typeof, hasOwnProperty, Object.keys all agree with node. Only the call dispatches to the native method. m.get() with no argument returns undefined because Map.prototype.get ran.

Blast radius, measured

expression node perry main
m.get = () => "own"; m.get() own undefined
Object.defineProperty(m, "get", {value: () => "own"}); m.get() own undefined
m.set("k","v"); m.get = () => "own"; m.get() own undefined
s.has = () => "own"; s.has() (Set) own undefined
r.test = () => "own"; r.test("x") (RegExp) own true (native ran)
d.getTime = () => "own"; d.getTime() (Date) own 0 (native ran)
a.push = () => "own"; a.push(1) (Array) own 1 (native ran)
o.toString = () => "own"; String(o) (ordinary object) own own ✅
e.toString = () => "own"; String(e) (Error) own own ✅
p.then = () => "own"; p.then() (Promise) own own ✅

Ordinary objects, Error and Promise are correct. Map, Set, RegExp, Date and Array are not.

This is not an obscure pattern — monkey-patching a collection method is common in test doubles, instrumentation and polyfills, and here it fails silently with a plausible value (undefined from a zero-arg Map.get, true from RegExp.test, a real timestamp from Date.getTime).

Mechanism

crates/perry-runtime/src/object/native_call_method.rs:164, the own-field shadowing scan that runs before native dispatch:

let descriptor = crate::object::shapes::object_shape_descriptor(obj)?;
let keys = descriptor.keys as usize as *mut ArrayHeader;
if !keys.is_null() { /* scan for a key matching the method name */ }

object_shape_descriptor resolves (*obj).parent_class_id. On a MapHeader/SetHeader/ArrayHeader that word is capacity, not a ShapeId (charter §7 lists exactly these kinds), so is_shape_id is false and the descriptor is None. The ? then returns None from the shadowing check, and the caller reads that None as "no own field shadows this method" rather than "I could not tell".

That is the same defect as #10942's sites 2 and 3 — a conclusion drawn from absence — with a wider trigger. #10942 needs a shape that says no keys; this needs a shape that cannot be read at all, which every non-GC_TYPE_OBJECT cell already has today.

Why the reflective paths are right and the call path is wrong

Enumeration and hasOwnProperty reach an exotic cell's own named properties through ObjectMeta::expando (object/mod.rs, cell_expando_get) — the bag that #6759 phase 1 introduced precisely so these cells could own named properties. The call path never consults it.

Fix direction

The shadowing check must distinguish "no own field" from "cannot resolve a descriptor", and for a cell with a metadata edge it should consult the expando bag rather than the ShapeId. Making the helper return a three-valued answer (shadowed / not shadowed / unknown) and treating unknown as decline the native fast path is the fail-closed shape; the generic dispatcher already reaches the right property.

Related: #10502 is the performance complaint about this same scan. Whatever replaces it must keep the unknown case fail-closed, or it will preserve this bug at speed. #10942 is the same defect class on keyless shapes.

Verification

Both arms built with PERRY_NO_AUTO_OPTIMIZE=1. Reproduced identically on pristine main v0.5.1632 (841b605c9, worktree with no local changes) and on an unrelated feature branch off train 253, so it is not branch-specific. node v26.8.1 is the reference.

Activity

  1. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Root cause is layered, and the layer that matters most is argument-count dependent

    Working a fix turned up that this is not one bug. Measured on pristine main v0.5.1632 against a branch carrying a runtime-dispatcher fix, one Map with an own get returning 2 where the native lookup returns 1:

    call form node main + dispatcher fix
    a.get() — no argument 2 undefined 2 ✅
    a.get("k") — one argument 2 1 1 ❌
    a.get("k") in a loop 6 3 3 ❌
    a.get() in a loop 6 NaN 6 ✅
    f(x){return x.get("k")} — opaque receiver 2 1 1 ❌

    A zero-argument call reaches js_native_call_method; a call with an argument does not. Not even through an opaque parameter, so it is not receiver-type inference — it looks like a codegen specialisation keyed on arity. That is the dominant spelling in real code, so the runtime-side fix, while real, covers the minority of cases.

    Layers, in the order they must be fixed:

    1. Runtime dispatcher — the native dispatch_* chain claimed the method name before any own-property lookup. Fixable there, and a fix exists (below). Covers zero-argument calls.
    2. Codegen specialisation, arity-keyed — a call with arguments is emitted as a direct native call that never consults own properties. This is where most real calls go, and it is codegen-side, not runtime.
    3. Set — neither an opaque receiver nor a constant-string computed member reaches the dispatcher; only a fully dynamic key does. Date behaves the same way.

    What the dispatcher fix looks like

    A three-valued OwnShadow { Yes, No, Unknown } with Unknown treated as Yes, placed at the top of the dispatch tower, asking the authoritative own-property predicate (js_object_has_own) rather than re-deriving own-ness from a shape descriptor — which is what fails for a cell whose +4 word is capacity. Sabotage-verified: disabling the guard reddens exactly the four map.get rows it owns and no others. Cost on a dispatcher-routed call: 194.0 instructions/call before and after, i.e. below measurement noise.

    Two things that cost me a cycle each and are worth recording:

    • My first version kept a "cheap pre-filter" — an exotic cell keeps its own named properties in ObjectMeta::expando, so a null metadata edge proves there are none. Wrong for Map, Set, Date and RegExp, whose expando payloads still live in a per-thread RuntimeState side table (ObjectMeta's own type docs say those kinds are not migrated). It fixed defineProperty and left plain assignment broken — a cheap guess about where own properties live, inside a fix whose whole subject is a cheap guess about where own properties live.
    • My first parity file used no-argument calls throughout and therefore went fully green on a fix that covers only no-argument calls. A differential shaped to the fix's coverage is not evidence. The table above is now the scope statement.

    Do not close this issue on the dispatcher fix landing. Layer 2 is the common case.

  2. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Attempted fix, and why it is NOT being landed

    I built the dispatcher-side fix and am reporting it as not shippable in this shape. Two independent reasons, both measured.

    1. It recurses — fatal runtime error: stack overflow

    Placing an own-property gate at the top of the dispatch tower and resolving Get(recv, name) then Call aborts gc::tests::runtime_roots::hook_dispatch_handles::test_bound_timer_dispatch_roots_args_during_async_hook_init_gc with a stack overflow. A thread-local re-entrancy guard around the probe (both js_object_has_own and the Get) does not fix it, because the recursion is not in the probe — it is in the call:

    the receiver owns a property whose value is a builtin thunk, so Get-then-Call re-enters the by-name dispatcher with the same receiver and name, the gate fires again, and it never bottoms out.

    This is exactly why array::generic::object_owns_user_method exists in the shape it does. It does not ask "is there an own property" — it asks classify_own_slot, which separates UserMethod from BorrowedBuiltin, and the comment above it records the same class of bug (#5902, obj.concat = String.prototype.concat running the Array algorithm). I dismissed that helper for being two-valued and reintroduced the hazard it was built to avoid.

    Shape of a correct fix: three-valued on the slot classification, not on presence.

    own slot action
    UserMethod shadow: resolve and call it
    BorrowedBuiltin let the native arm run — calling it by name is what recurses
    absent let the native arm run
    cannot classify decline the native arm, but do not Get-and-Call; fall through to the existing generic path

    The last row is the fail-closed one, and it must not be implemented as "call whatever Get returns".

    2. Even working, it covers the minority spelling

    Per the table in my previous comment, the gate only ever runs for zero-argument calls; anything with an argument is specialised by codegen and never reaches the dispatcher. So the maximum value of a correct version of this fix is the no-argument case, while m.get(k) — the spelling people actually write — needs layer 2 regardless.

    What is worth keeping from the attempt

    • the layered diagnosis and the arity table (previous comment);
    • the pre-filter trap: "an exotic cell keeps its own named properties in ObjectMeta::expando" is false for Map, Set, Date and RegExp, whose payloads are still in a per-thread RuntimeState side table;
    • the test-shape trap: a differential written with no-argument calls goes green against a fix that only covers no-argument calls. Any parity file for this issue must include the with-argument forms, and they are the ones that matter;
    • the measured cost of the gate on a dispatcher-routed call: 194.0 instr/call before and after, so cost is not the obstacle — correctness of the call step is;
    • the sabotage result: with the gate disabled, exactly the four map.get rows redden, which bounds what a dispatcher-side change can buy.

    No PR. The branch is local only; nothing has been pushed.

  3. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Layer 2 scoped: the site, what it keys on, and why #10476 already half-solved it

    Read-only scoping, no code written.

    The site

    crates/perry-codegen/src/lower_call/property_get/map_set.rs, try_lower_map_set_methods (line 43) — Map and Set.

    It keys on three things, and an own-property check is not among them:

    1. method name — "get", "set", "has", "delete", "add", the set-ops;
    2. exact arity — args.len() == 1 for get/has/delete/add, == 2 for set;
    3. a static receiver-type proof — is_map_expr / is_declared_map_expr / is_set_expr (type_analysis/strings.rs:280), which accepts Expr::MapNew, a local whose stable_local_type_proof is Map<…>, or a class field declared Map<K,V>.

    That is the arity table exactly. a.get() matches no arm (get needs args.len() == 1), falls through to js_native_call_method, and is therefore the only spelling a runtime-side fix can reach. a.get("k") matches, lowers to a direct js_map_get, and never sees the dispatcher.

    For Date / Number / Array the equivalent site is
    lower_call/property_get/builtin_kind_guard.rs.

    #10476 already built the right machinery — for the other half

    builtin_kind_guard.rs's own header is this bug, already fixed once:

    A method NAME is not proof of its receiver's kind. dayjs and moment own toISOString/getTime … Lowering such a call straight to js_date_* / js_number_to_* / js_array_* ran the builtin on the user's object and never called the user's method.

    The direct builtin call is kept for a receiver the compiler has proven to be a Date / number. Any other receiver without a known class … its runtime kind selects the builtin or the universal method dispatcher, which finds an own or inherited user method.

    So the unproven-receiver path is correct, and that is measurable: f(x){return x.push(1)} and f(x){return x.test("x")} both answer the own method today. The hole is the sentence in bold: a proof of KIND is being treated as a proof of NO OWN OVERRIDE. It is not — const m = new Map(); m.get = … keeps Expr::MapNew's proof intact. Map and Set are worse off still: map_set.rs has no #10476-style guard at all, proven or not.

    Can an opaque parameter reach it another way?

    For Map/Set, is_map_expr requires a proved type, so a bare x should not match — yet f(x){return x.get("k")} still ran the native method in my measurement. That is one loose end I did not close: either HIR infers Map for the single call site, or a second arm (dynamic_dispatch_tower.rs / dispatch_receiver_class.rs) claims it. Worth ten minutes before anyone builds, because it decides whether the fix is one site or two.

    Fix shape

    Routing proven receivers through the universal dispatcher would work and would regress exactly the paths #10446 and these fast paths exist for. The cheap version:

    A one-bit "this cell has own named properties" flag, set when a named property is first installed, tested by the direct-call guard.

    GC_ARRAY_NAMED_PROPS is already precisely that bit for arrays, which makes the array arm nearly free and gives the pattern for Map/Set/Date. Clear bit → direct builtin call, unchanged. Set bit → #10476's runtime-kind path, which already resolves own methods. Monomorphic cost on the hot path is one flag test; the slow path is only taken by receivers that really do own a named property.

    Risks. (1) The flag must be set by every installer — plain assignment, defineProperty, and whatever writes the per-thread side table for Map/Set/Date/RegExp, which is a different writer per kind; a missed installer is this same silent wrong value. (2) It must be set-only and never cleared on delete, or a delete-then-shadow sequence reopens the hole; monotonic like GC_ARRAY_NAMED_PROPS. (3) It is a new GC-header/cell bit, and GcHeader::_reserved is known to be tight.

    Ownership

    map_set.rs says "Pure code move from property_get.rs", and builtin_kind_guard.rs sits in the same directory — so this is property_get territory, not a free file. Flagging rather than starting.

  4. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Loose end settled: it IS type-driven — the type comes from monomorphisation

    Six receiver forms, one Map with an own get returning 2 where the native lookup returns 1, pristine main v0.5.1632:

    form node perry
    monomorphic f(x){x.get("k")} 2 1
    polymorphic f(x), called with a plain object and a Map — plain 9 9 ✅
    polymorphic, same f, called with the Map 2 1
    explicit f(x: any) 2 1
    arr[0].get("k") 2 1
    make().get("k") 2 1
    proven local 2 1

    Not single-call-site inference, and not an unguarded second arm: the polymorphic function is right for the plain object and wrong for the Map in the same program, which is two specialised clones. Both producers are type-gated; the type is supplied by HIR monomorphisation (crates/perry-hir/src/monomorph/), so an any annotation is not a defence — the clone that receives a Map has a Map local type.

    There are two producers, not one:

    1. crates/perry-hir/src/lower/expr_call/local_array_methods.rs:948 — folds m.get(k) to Expr::MapGet when ctx.lookup_local_type is Map and !args.is_empty(). There is the arity gate, in HIR.
    2. crates/perry-codegen/src/lower_call/property_get/map_set.rs:43 try_lower_map_set_methods — is_map_expr / is_declared_map_expr plus args.len() == 1 / == 2.

    This changes where the fix belongs

    Two producers (and monomorphisation minting more type proofs over time) means guarding the lowering sites is a sweep that can regress. Both funnel into the same runtime helpers — js_map_get, js_set_has, js_date_*, js_array_push, … — so the containment point is there: one guard per method family, covering every producer including future ones, and it sits in perry-runtime rather than in the property_get files.

    That also answers the ownership question: guarded in the runtime helpers, this is not lane 7's slice-2 territory.

    The bit's home — answered, and _reserved is not it (except for arrays, where it already is)

    gc/types.rs:1380-1415 is the authoritative bit map and says "There are no free bits." But it is a per-kind overloaded table — columns for GC_TYPE_OBJECT, for ARRAY, and for all kinds — and that is exactly the room:

    bit OBJECT ARRAY all kinds
    8 OBJ_FLAG_TYPED_ARRAY_PROTO GC_ARRAY_NAMED_PROPS
    • Arrays: the bit already exists. GC_ARRAY_NAMED_PROPS is _reserved bit 8 in the ARRAY column, set when an array takes a named property. The array arm of the guard is free today.
    • Map / Set / Date / RegExp: bits 7–12 are claimed only in the OBJECT and ARRAY columns, so for those cell kinds the same bit 8 is unclaimed and can carry the identical meaning. One bit number, one meaning, three columns — and it avoids 12/13, which the doc flags as silently erased by set_layout_state (perf(codegen): loop-version stable packed Array and Array-subclass iteration #8690, perf(runtime): one validity word replaces the per-hop chain walk, and one flags word replaces two registry probes #10842 both hit that).
    • Not ObjectMeta::flags — even though the doc recommends it for a new per-object fact (bits 5/6/7 free, out of reach of the layout machinery). Its own condition is "whenever the path already loads meta", and this path does not: Map/Set/Date/RegExp keep own named properties in a per-thread side table, so their meta is usually null and the guard would have to materialise a record to read a flag that is almost always clear. _reserved bit 8 is one header load the direct-call path already has in hand.

    Risks, unchanged in kind: every installer must set it — plain assignment, defineProperty, and the per-kind side-table writer — because a missed installer is this same silent wrong value; and it must be monotonic (never cleared on delete) or delete-then-shadow reopens the hole, exactly like GC_ARRAY_NAMED_PROPS.

  5. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Build-ready plan for layer 2, and a committed red differential

    Branch fix/10943-own-override-guard is pushed (no PR — it is one commit, a deliberately RED parity file, which cannot land on its own). 4e4988af, off train 253.

    test-files/test_parity_own_override_beats_builtin.ts: 17 of 30 rows red on pristine main. Every call passes an argument, which is the correction to my previous attempt — that differential used zero-argument calls and went green against a fix covering only zero-argument calls. The 13 green rows are the constraint set: every native row (the builtin must still work unshadowed), the subclass get() override, delete-restores-the-builtin, and hasOwn/typeof.

    The enabler already exists

    builtin_kind_guard.rs has the diamond this needs: guarded_call (line 295) with a phi at 346, and emit_receiver_kind_branch (360), which branches between the builtin and the universal dispatcher on a per-ReceiverKind runtime test. #10476 built it. Nothing new has to be invented for the branch — only the condition changes, and the entry gate (receiver_is_unproven, line 136) has to stop excluding proven receivers.

    The three pieces

    1. Runtime — the predicate (new file, object/own_override.rs).

    static EXOTIC_OWN_NAMED_PROP_INSTALLED: AtomicBool   // thread/process global
    pub fn note_exotic_named_prop_install()
    #[no_mangle] pub extern "C" fn js_receiver_may_own_named_method(recv, name_ptr, name_len) -> f64
    

    Fast path is one relaxed load: flag clear and (for arrays) GC_ARRAY_NAMED_PROPS clear ⇒ false, no own override possible, take the builtin. Only when the flag is set does it do the authoritative own-property check. This is the st.descriptors.accessors_in_use idiom already used on the read path.

    2. Runtime — one line to set it. There is no single install funnel: field_set_by_name.rs's 'exotic_gauntlet: block (line 305) has a per-kind branch each for buffers, stream handles, and the rest, and a missed installer is this same silent wrong value. So set the flag at the top of the gauntlet, before the per-kind branches — it is entered by every non-ObjectHeader receiver with a named key, covers every kind including future ones, and over-approximates, which is the fail-closed direction: a spuriously-set flag only costs the diamond's slow side.

    3. Codegen — the condition. In builtin_kind_guard.rs, AND the predicate into the branch condition and let proven receivers through guarded_call instead of bypassing it. In map_set.rs, wrap try_lower_map_set_methods' arms in the same diamond.

    Where the bit lives — settled, and it is not a new header bit

    gc/types.rs:1380 is authoritative: "There are no free bits" in GcHeader::_reserved, and bits 12/13 are actively erased by set_layout_state (#8690 and #10842 both hit that). But the table is per-kind overloaded — bit 8 is OBJ_FLAG_TYPED_ARRAY_PROTO for OBJECT and GC_ARRAY_NAMED_PROPS for ARRAY — so:

    • arrays already have the bit (GC_ARRAY_NAMED_PROPS), so that arm is free today;
    • the global flag above avoids needing a new per-cell bit for Map/Set/Date/RegExp at all. If a per-cell bit is later wanted for precision, bit 8 is unclaimed in those kinds' columns and carrying the identical meaning there keeps one bit number to one meaning.
    • Not ObjectMeta::flags, despite the doc recommending it for new per-object facts: its stated condition is "whenever the path already loads meta", and this path does not — Map/Set/Date/RegExp keep own named properties in a per-thread side table, so their meta is usually null and the guard would have to materialise a record to read an almost-always-clear flag.

    Risks to hold while building

    1. Monotonic only. Never clear the flag/bit on delete, or delete-then-shadow reopens the hole — same discipline as GC_ARRAY_NAMED_PROPS.
    2. Do not substitute an action for a decline. The previous attempt overflowed the stack because the guard resolved Get and called it; an own slot can hold a builtin thunk that dispatches by name again. Here the diamond's other side is the existing universal dispatcher, which already handles this — the guard only chooses a branch, it does not invent a call.
    3. classify_own_slot matters. BorrowedBuiltin (m.get = Map.prototype.get) must take the builtin arm, not the dispatcher, for the same recursion reason. array::generic::object_owns_user_method is the existing precedent and its two-valued shape is deliberate.
    4. Cost. One predicate call plus a branch per specialised site. It must be measured on a Map/Array method loop and stated, not tuned away — correct-and-slower beats fast-and-wrong.
  6. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Step 1 landed on the branch; step 2 stopped at an architectural blocker

    fix/10943-own-override-guard now has two commits, both pushed:

    • 4e4988af — the RED differential (17 of 30 rows, every call passing an argument).
    • 1be714d3 — step 1: the runtime predicate and its arm. js_receiver_may_own_named_method(recv, method_id), the process-global arm set at the top of field_set_by_name's exotic gauntlet, and object_ops::has_own opened to the crate. Inert — nothing calls the predicate yet — so it changes no behaviour.

    The blocker: the diamond is the LAST arm in the chain

    I extended guarded_call so a proven receiver takes the same diamond #10476 built for unproven ones, with the own-override test as its condition. It compiles, and it is inert for every row in the differential. Asserted rather than assumed, per the campaign's marker rule:

    symbol in runtime archive : 1
    call emitted in fixture   : 0     <-- guarded_call is never reached
    

    property_get.rs:124 try_lower_property_get_method_call is an ordered chain, and builtin_kind_guard is line 226 of it — behind number_string (166), lower_array_method (186), map_set (196) and six others. Every proven receiver is claimed upstream and never reaches the diamond. The file's own comments say so once you read them in chain order: "A proven number keeps number_string.rs's direct lowering", "A proven array keeps lower_array_method, and HIR folds most."

    So extending builtin_kind_guard.rs cannot fix this, by construction. The guard has to sit above the whole chain, at the top of try_lower_property_get_method_call — one diamond, every kind, present and future. That is a restructure of the chain's orchestration (the receiver must be evaluated once and threaded, because every downstream lowering currently re-lowers object itself), and it lives in property_get.rs, not in the two files I was granted.

    That is the fifth surprise of the kind this work has produced all day, so I am stopping here as agreed rather than starting a chain restructure.

    A second finding, which blocks anyone's verification

    cargo test --release -p perry-runtime fails on pristine main: 13 failures, in my own worktree with my changes stashed. With step 2 applied it is 14 — but the two sets are not nested, differing in both directions (4 fail only in the baseline, 5 only with the change). They are memo/counter assertions of the form "an unchanged live signature must reuse the prior negative verdict — left: 2, right: 1", i.e. process-global memo counts shared across tests in one binary.

    The failing set is not stable between runs, so this suite cannot currently attribute a regression. I could not tell whether step 2 broke anything, which is the honest reason it is not pushed. Anyone continuing here needs --test-threads=1 on both arms, or repeated runs, before trusting a verdict — and the pre-existing 13 probably deserve their own issue.

    For whoever takes it forward

    The design in my earlier comment is unchanged and step 1 implements its runtime half. What changes is where the codegen half goes: not builtin_kind_guard.rs's diamond, but a single guard above try_lower_property_get_method_call's chain. The three risks still hold — monotonic-only, never substitute an action for a decline, and classify_own_slot's BorrowedBuiltin arm must keep taking the builtin.

    The step-2 attempt is left uncommitted in /root/wt-10943b on perrymaster if it is useful as a starting point; it is a correct diamond in the wrong place.

  7. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Restructure plan for layer 2 — handing off, not building

    For lane 7 after slice 2. The reason it belongs there rather than here: this restructure and regions are the same idea — materialise the receiver once and let everything downstream use it, instead of each lowering re-deriving it. Building a second hoist beside the region one would be inventing a mechanism that already exists.

    The constraint, established

    property_get.rs:124 try_lower_property_get_method_call is an ordered chain of ~12 try_lower_* arms. Each arm that fires lowers the receiver itself (lower_expr(ctx, object)) and returns a value. builtin_kind_guard — the only arm with a runtime diamond — is line 226, behind number_string (166), lower_array_method (186) and map_set (196).

    So every proven receiver is claimed upstream of the only place that can branch, which is why extending that diamond emits zero calls (measured: symbol present in the archive, call count 0 in the fixture). The guard cannot be added to an arm; it has to be above all of them.

    What the orchestration should become

    try_lower_property_get_method_call:
        if property is claimable by any builtin arm:
            recv  = lower_expr(object)          // ONCE, rooted across the args
            args  = lower args                  // ONCE
            if js_receiver_may_own_named_method(recv, <method id>):
                -> universal dispatcher (recv, args)
            else:
                -> the existing chain, on the ALREADY-LOWERED recv/args
            phi
    

    Two things every existing arm must stop doing:

    1. stop lowering the receiver — take a lowered, rooted recv operand instead of an &Expr;
    2. stop lowering its own arguments — take &[String] instead of &[Expr].

    That is the whole change, and it is mechanical per arm but touches all of them. guarded_call (builtin_kind_guard.rs:295) is already exactly this shape for one arm — receiver rooted across argument evaluation, both re-read below, open_rooted_group's diamond case, released in the merge block. It is the template; the work is lifting it to the chain and converting the arms to operand form.

    The predicate is already built and pushed: 1be714d3 on fix/10943-own-override-guard, inert until something calls it. /root/wt-10943b on perrymaster holds the step-2 attempt — a correct diamond in the wrong place, useful for the emission details (the I32 call, icmp_ne, the kindguard.* block wiring).

    Risks

    1. Do not substitute an action for a decline. The diamond's other side must be the existing universal dispatcher. An earlier attempt resolved Get and called it, and overflowed the stack when the own slot held a builtin thunk that dispatched by name again.
    2. BorrowedBuiltin must take the builtin arm. m.get = Map.prototype.get is an own property but not a user override; routing it to the dispatcher recurses. array::generic::object_owns_user_method / classify_own_slot is the existing precedent and its two-valued shape is deliberate (test262 built-ins/String — 32 (self-contained worklist) #5902).
    3. The arm is monotonic. Never cleared on delete, or delete-then-shadow reopens the hole — as GC_ARRAY_NAMED_PROPS is monotonic.
    4. Rooting is the delicate part. Hoisting the receiver means one rooted group spanning the chain instead of one per arm; guarded_call's existing handling is the reference, and gc: native-method-call arguments (map.set(a, b)) are not precise roots -- aborts under precise-roots-only #6970/fix(codegen): root the URLSearchParams receiver across the name lowering #7462 are the bugs that shape it.
    5. Verification is currently blocked — see cargo test -p perry-runtime cannot attribute a regression: the failing SET differs between runs in both directions (0 / 11 / 13 failures on comparable trees) #10944: the runtime suite's failing set moves between runs in both directions, so use --test-threads=1 on both arms or no verdict is trustworthy.

    Ready to use

    • 4e4988af — the RED differential, 17/30, every call passing an argument. The 13 green rows are a constraint set, not scaffolding: the builtin must still work unshadowed, the subclass override must resolve, delete must restore the builtin, hasOwn/typeof must keep agreeing.
    • 1be714d3 — the runtime predicate and its arm.
  8. proggeramlug commented on Sep 22, 2026

    @proggeramlug
    ContributorAuthor

    Layer 2, taken up by lane 7: where the guard goes, and why the hoist is only the RECEIVER

    Confirming lane 16's constraint and narrowing the restructure. The guard goes above the chain, but converting every arm to operand form is not required — only the receiver has to be hoisted.

    Why only the receiver

    In a diamond, each arm evaluates its own arguments once at runtime; two emitted copies of the argument lowering cost code size, not correctness. The receiver is different: the condition needs its value before the branch, so if an arm re-lowers object afterwards, an effectful receiver (make().get("k"), row 20 of the differential) is evaluated twice. So:

    • receiver — lowered once above the branch, rooted, re-read in both arms;
    • arguments — left to each arm, lowered inside the arm that runs.

    That drops the "every arm takes &[String]" half of the restructure.

    How downstream arms see the hoisted receiver

    Not by rewriting the receiver to a synthetic LocalGet: every arm's proof is keyed on the receiver expression (is_array_expr, receiver_class_name, is_date_receiver, ptr-shape facts, scalar_replaced), so substituting a synthetic local erases those proofs and every specialised lowering stops firing — a large perf regression in exchange for a correctness fix.

    Instead the original &Expr is passed through unchanged, and its lowered value is memoised for the duration of this call site: FnCtx holds Some((receiver expr identity, rooted slot)), and lower_expr returns a fresh re-read of that slot when it is asked for that exact node. Facts intact, no double evaluation, no arm signatures touched. This is the region's materialisation idea at the call-site scope — one materialisation, everything downstream reads it — rather than a second mechanism.

    Audited, because the memo is only authoritative if every arm lowers the receiver through lower_expr(ctx, object): map_set.rs (4 sites), number_string.rs (6), builtin_kind_guard.rs (1) all do; lower_array_method.rs lowers the receiver once before its match. Any arm that ever bypasses it is a missed memo on an effectful receiver only — a LocalGet/this receiver is unaffected either way — and a debug assertion on the memo's hit count catches a new arm that does.

    When the diamond is emitted

    Only when a specialised arm would claim the call, so the guard never emits a diamond around a call that was going to the dispatcher anyway. That needs a small claims() predicate per affected arm (Map/Set, Array, Date/Number, RegExp) — a few lines each, no lowering changes. Strings are excluded: a primitive cannot carry an own property.

    The seam with the region work, stated so a reviewer need not reconstruct it

    A region ends at a call. Slice 1 admits only guarded-receiver reads, locals and numeric literals as leaves; slice 2 admits only Lets of static-key property reads, and any other statement — a call included — terminates the run. So m.get("k") can never sit inside a region: it ends one. This guard cannot hide an overridden method behind a region, and a region cannot move because of this guard. The two changes are independent at the seam.

    Reused as-is from step 1 and the parked attempt

    js_receiver_may_own_named_method(recv: f64, method_id: i64) -> i32 (1be714d3), the emit_own_override_branch emission (interned key → static_dispatch_global → emit_static_dispatch_id → I32 call → icmp_ne), and console_promise::emit_native_method_str_dispatch as the own arm's dispatcher — the same call guarded_call's generic block already makes, which is the "choose a branch, never substitute an action" rule holding.

    Risks carried unchanged: monotonic arm, BorrowedBuiltin takes the builtin arm, rooting across the arms is the delicate part, and per #10944 both arms of any suite verdict need --test-threads=1.

  9. 48 remaining items

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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions