Repository navigation
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
Activity
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
getreturning2where the native lookup returns1:call form node main + dispatcher fix a.get()— no argument2 undefined2 ✅ a.get("k")— one argument2 11❌a.get("k")in a loop6 33❌a.get()in a loop6 NaN6 ✅ f(x){return x.get("k")}— opaque receiver2 11❌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:
- 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. - 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.
- Set — neither an opaque receiver nor a constant-string computed member reaches the dispatcher; only a fully dynamic key does.
Datebehaves the same way.
What the dispatcher fix looks like
A three-valued
OwnShadow { Yes, No, Unknown }withUnknowntreated asYes, 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+4word iscapacity. Sabotage-verified: disabling the guard reddens exactly the fourmap.getrows 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-threadRuntimeStateside table (ObjectMeta's own type docs say those kinds are not migrated). It fixeddefinePropertyand 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.
- Runtime dispatcher — the native
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 overflowPlacing an own-property gate at the top of the dispatch tower and resolving
Get(recv, name)thenCallabortsgc::tests::runtime_roots::hook_dispatch_handles::test_bound_timer_dispatch_roots_args_during_async_hook_init_gcwith a stack overflow. A thread-local re-entrancy guard around the probe (bothjs_object_has_ownand theGet) 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_methodexists in the shape it does. It does not ask "is there an own property" — it asksclassify_own_slot, which separatesUserMethodfromBorrowedBuiltin, and the comment above it records the same class of bug (#5902,obj.concat = String.prototype.concatrunning 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 UserMethodshadow: resolve and call it BorrowedBuiltinlet 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-threadRuntimeStateside 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.getrows redden, which bounds what a dispatcher-side change can buy.
No PR. The branch is local only; nothing has been pushed.
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:
- method name —
"get","set","has","delete","add", the set-ops; - exact arity —
args.len() == 1forget/has/delete/add,== 2forset; - a static receiver-type proof —
is_map_expr/is_declared_map_expr/is_set_expr(type_analysis/strings.rs:280), which acceptsExpr::MapNew, a local whosestable_local_type_proofisMap<…>, or a class field declaredMap<K,V>.
That is the arity table exactly.
a.get()matches no arm (getneedsargs.len() == 1), falls through tojs_native_call_method, and is therefore the only spelling a runtime-side fix can reach.a.get("k")matches, lowers to a directjs_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 tojs_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)}andf(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 = …keepsExpr::MapNew's proof intact. Map and Set are worse off still:map_set.rshas no #10476-style guard at all, proven or not.Can an opaque parameter reach it another way?
For Map/Set,
is_map_exprrequires a proved type, so a barexshould not match — yetf(x){return x.get("k")}still ran the native method in my measurement. That is one loose end I did not close: either HIR infersMapfor 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_PROPSis 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 likeGC_ARRAY_NAMED_PROPS. (3) It is a new GC-header/cell bit, andGcHeader::_reservedis known to be tight.Ownership
map_set.rssays "Pure code move fromproperty_get.rs", andbuiltin_kind_guard.rssits in the same directory — so this isproperty_getterritory, not a free file. Flagging rather than starting.- method name —
Loose end settled: it IS type-driven — the type comes from monomorphisation
Six receiver forms, one Map with an own
getreturning2where the native lookup returns1, 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 — plain9 9 ✅ polymorphic, same f, called with the Map2 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 ananyannotation is not a defence — the clone that receives a Map has aMaplocal type.There are two producers, not one:
crates/perry-hir/src/lower/expr_call/local_array_methods.rs:948— foldsm.get(k)toExpr::MapGetwhenctx.lookup_local_typeisMapand!args.is_empty(). There is the arity gate, in HIR.crates/perry-codegen/src/lower_call/property_get/map_set.rs:43try_lower_map_set_methods—is_map_expr/is_declared_map_exprplusargs.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 inperry-runtimerather than in theproperty_getfiles.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
_reservedis not it (except for arrays, where it already is)gc/types.rs:1380-1415is the authoritative bit map and says "There are no free bits." But it is a per-kind overloaded table — columns forGC_TYPE_OBJECT, for ARRAY, and for all kinds — and that is exactly the room:bit OBJECT ARRAY all kinds 8 OBJ_FLAG_TYPED_ARRAY_PROTOGC_ARRAY_NAMED_PROPS- Arrays: the bit already exists.
GC_ARRAY_NAMED_PROPSis_reservedbit 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 loadsmeta", and this path does not: Map/Set/Date/RegExp keep own named properties in a per-thread side table, so theirmetais usually null and the guard would have to materialise a record to read a flag that is almost always clear._reservedbit 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 likeGC_ARRAY_NAMED_PROPS.Build-ready plan for layer 2, and a committed red differential
Branch
fix/10943-own-override-guardis 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: everynativerow (the builtin must still work unshadowed), the subclassget()override, delete-restores-the-builtin, andhasOwn/typeof.The enabler already exists
builtin_kind_guard.rshas the diamond this needs:guarded_call(line 295) with aphiat 346, andemit_receiver_kind_branch(360), which branches between the builtin and the universal dispatcher on a per-ReceiverKindruntime 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) -> f64Fast path is one relaxed load: flag clear and (for arrays)
GC_ARRAY_NAMED_PROPSclear ⇒false, no own override possible, take the builtin. Only when the flag is set does it do the authoritative own-property check. This is thest.descriptors.accessors_in_useidiom 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-ObjectHeaderreceiver 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 throughguarded_callinstead of bypassing it. Inmap_set.rs, wraptry_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:1380is authoritative: "There are no free bits" inGcHeader::_reserved, and bits 12/13 are actively erased byset_layout_state(#8690 and #10842 both hit that). But the table is per-kind overloaded — bit 8 isOBJ_FLAG_TYPED_ARRAY_PROTOfor OBJECT andGC_ARRAY_NAMED_PROPSfor 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 loadsmeta", and this path does not — Map/Set/Date/RegExp keep own named properties in a per-thread side table, so theirmetais usually null and the guard would have to materialise a record to read an almost-always-clear flag.
Risks to hold while building
- Monotonic only. Never clear the flag/bit on delete, or delete-then-shadow reopens the hole — same discipline as
GC_ARRAY_NAMED_PROPS. - Do not substitute an action for a decline. The previous attempt overflowed the stack because the guard resolved
Getand 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. classify_own_slotmatters.BorrowedBuiltin(m.get = Map.prototype.get) must take the builtin arm, not the dispatcher, for the same recursion reason.array::generic::object_owns_user_methodis the existing precedent and its two-valued shape is deliberate.- 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.
- arrays already have the bit (
- added a commit that references this issue
on Sep 22, 2026 Step 1 landed on the branch; step 2 stopped at an architectural blocker
fix/10943-own-override-guardnow 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 offield_set_by_name's exotic gauntlet, andobject_ops::has_ownopened 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_callso 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 reachedproperty_get.rs:124try_lower_property_get_method_callis an ordered chain, andbuiltin_kind_guardis line 226 of it — behindnumber_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.rscannot fix this, by construction. The guard has to sit above the whole chain, at the top oftry_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-lowersobjectitself), and it lives inproperty_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-runtimefails 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=1on 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 abovetry_lower_property_get_method_call's chain. The three risks still hold — monotonic-only, never substitute an action for a decline, andclassify_own_slot'sBorrowedBuiltinarm must keep taking the builtin.The step-2 attempt is left uncommitted in
/root/wt-10943bon perrymaster if it is useful as a starting point; it is a correct diamond in the wrong place.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:124try_lower_property_get_method_callis an ordered chain of ~12try_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, behindnumber_string(166),lower_array_method(186) andmap_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 phiTwo things every existing arm must stop doing:
- stop lowering the receiver — take a lowered, rooted
recvoperand instead of an&Expr; - 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:
1be714d3onfix/10943-own-override-guard, inert until something calls it./root/wt-10943bon perrymaster holds the step-2 attempt — a correct diamond in the wrong place, useful for the emission details (theI32call,icmp_ne, thekindguard.*block wiring).Risks
- Do not substitute an action for a decline. The diamond's other side must be the existing universal dispatcher. An earlier attempt resolved
Getand called it, and overflowed the stack when the own slot held a builtin thunk that dispatched by name again. BorrowedBuiltinmust take the builtin arm.m.get = Map.prototype.getis an own property but not a user override; routing it to the dispatcher recurses.array::generic::object_owns_user_method/classify_own_slotis the existing precedent and its two-valued shape is deliberate (test262 built-ins/String — 32 (self-contained worklist) #5902).- The arm is monotonic. Never cleared on delete, or delete-then-shadow reopens the hole — as
GC_ARRAY_NAMED_PROPSis monotonic. - 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. - 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=1on 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/typeofmust keep agreeing.1be714d3— the runtime predicate and its arm.
- stop lowering the receiver — take a lowered, rooted
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
objectafterwards, 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
&Expris passed through unchanged, and its lowered value is memoised for the duration of this call site:FnCtxholdsSome((receiver expr identity, rooted slot)), andlower_exprreturns 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.rslowers the receiver once before its match. Any arm that ever bypasses it is a missed memo on an effectful receiver only — aLocalGet/thisreceiver 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. Som.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), theemit_own_override_branchemission (interned key →static_dispatch_global→emit_static_dispatch_id→I32call →icmp_ne), andconsole_promise::emit_native_method_str_dispatchas the own arm's dispatcher — the same callguarded_call's generic block already makes, which is the "choose a branch, never substitute an action" rule holding.Risks carried unchanged: monotonic arm,
BorrowedBuiltintakes the builtin arm, rooting across the arms is the delicate part, and per #10944 both arms of any suite verdict need--test-threads=1.- added 5 commits that reference this issue
on Sep 22, 2026 48 remaining items
- added 15 commits that reference this issue
on Sep 23, 2026
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
mainv0.5.1632 (841b605c9) from ordinary JS.Repro
The own property is visible to every reflective operation —
typeof,hasOwnProperty,Object.keysall agree with node. Only the call dispatches to the native method.m.get()with no argument returnsundefinedbecauseMap.prototype.getran.Blast radius, measured
m.get = () => "own"; m.get()ownundefinedObject.defineProperty(m, "get", {value: () => "own"}); m.get()ownundefinedm.set("k","v"); m.get = () => "own"; m.get()ownundefineds.has = () => "own"; s.has()(Set)ownundefinedr.test = () => "own"; r.test("x")(RegExp)owntrue(native ran)d.getTime = () => "own"; d.getTime()(Date)own0(native ran)a.push = () => "own"; a.push(1)(Array)own1(native ran)o.toString = () => "own"; String(o)(ordinary object)ownown✅e.toString = () => "own"; String(e)(Error)ownown✅p.then = () => "own"; p.then()(Promise)ownown✅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 (
undefinedfrom a zero-argMap.get,truefromRegExp.test, a real timestamp fromDate.getTime).Mechanism
crates/perry-runtime/src/object/native_call_method.rs:164, the own-field shadowing scan that runs before native dispatch:object_shape_descriptorresolves(*obj).parent_class_id. On aMapHeader/SetHeader/ArrayHeaderthat word iscapacity, not a ShapeId (charter §7 lists exactly these kinds), sois_shape_idis false and the descriptor isNone. The?then returnsNonefrom the shadowing check, and the caller reads thatNoneas "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_OBJECTcell already has today.Why the reflective paths are right and the call path is wrong
Enumeration and
hasOwnPropertyreach an exotic cell's own named properties throughObjectMeta::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 treatingunknownas 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
unknowncase 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 pristinemainv0.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.