fix(runtime): resolve a shadowed inherited field to its most-derived slot - #10607
proggeramlug wants to merge 2 commits into
Conversation
…slot
An overridden field (`class Sub extends Base { tag = ... }` where `Base`
also declares `tag`) is not deduplicated in the packed inline-slot layout:
the object holds one slot per declaration, ancestor first. The
compile-time-typed read path already resolves to the most-derived slot
("TS shadowing"); every dynamic by-name lookup returned the first
(ancestor's, never-written) slot instead -- observable from an inherited
accessor's `this.field`, a computed `obj[key]` read, and Reflect/has-own
checks.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (7)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughChangesThe runtime now resolves duplicate field names to the most-derived slot across indexed shape lookup, key-array scans, and IC-miss lookup. Tests cover direct, computed, symbol-keyed, and inherited accessor reads. Derived field lookup
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The derived-field lookup fix is covered across its stated runtime paths, with no actionable merge risk identified. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Landed via merge train #10652 (v0.5.1596). All source commits preserve authorship; merged main matches the validated train exactly. |
Summary
A field a subclass overrides used to read as the base class's value from
inside an inherited accessor (
Object.definePropertygetter), even though adirect (non-accessor) read of the same field on the same instance already saw
the subclass's override correctly. The bug is not Symbol- or accessor-specific:
any dynamic (name-not-known-at-compile-time) read of a shadowed field — a
computed
obj[key],Reflect.get,hasOwnProperty— hit the same defect.Root cause
crates/perry-codegen/src/codegen/mod.rs(~line 1290) builds a class's packedinline-slot layout by walking the
extendschain root → leaf and appendingevery keyable field name it finds, without deduplicating a name a subclass
re-declares:
So
class Sub extends Base { tag = "sub-tag" }(withBase { tag = "base-tag" })gives
Subtwo inline slots named"tag"— the ancestor's (index 0) andthe override's (index 1) — instead of one shared slot. Every writer in the
constructor chain resolves a field's index via
crates/perry-codegen/src/type_analysis_class_fields.rs::class_field_global_index,which already searches "leaf → root" (most-derived wins, TS shadowing) —
so only index 1 is ever written; index 0 sits uninitialized. A
compile-time-typed
obj.fieldread/write (the direct-read path) uses thatsame most-derived index and was always correct.
Every dynamic by-name field lookup in
perry-runtimeinstead scannedroot-to-leaf and returned the first match — the ancestor's uninitialized
slot — disagreeing with the compile-time path:
crates/perry-runtime/src/object/keys_lookup.rskeys_find_slot_by_bytes/keys_find_slot_by_key_ptr— the sharedby-name lookup behind
own_data_field_by_name,Reflect.*,hasOwnProperty,delete, and a computedobj[key]read/write.crates/perry-runtime/src/object/shapes.rs::shape_slot_lookup_verdict—the indexed lookup for objects with ≥32 keyable fields
(
KEYS_INDEX_THRESHOLD).crates/perry-runtime/src/object/field_get_set/ic_miss.rs::get_field_ic_miss_impl—the fast-path inline scan the emitted per-callsite inline cache falls into
on a miss for a static string-keyed
this.fieldread. This is the onethe issue's repro (an inherited getter's
this.tag) actually hits.An inherited accessor's receiver was already threaded correctly (confirmed via
runtime instrumentation before writing the fix) — the getter really does run
with
this === sub; the defect is purely in howthis.tagresolves onceinside it.
Fix
Three call sites, no storage-layout change:
keys_lookup.rs: both linear scans now iterate(0..n).rev()— for thecommon (no-shadowing) case this is a no-op cost-wise (one candidate either
way); for a duplicate name it now returns the most-derived (last) slot,
matching
class_field_global_index.shapes.rs:shape_slot_lookup_verdictscans every hash-bucket candidateand keeps the highest slot index among matches, instead of returning on
the probe order's first hit — robust to open-addressing table growth
reshuffling insertion order.
ic_miss.rs: same(0..key_count).rev()in the inline fast-path scan.This file was already sitting at exactly the 2000-line file-size gate;
the 1-line fix pushed it 1 line over, so
scripts/check_file_size.sh'sALLOWLISTgets a new entry with rationale (see below) rather than aforced structural split.
The write side (
obj[key] = von a small object) already routes throughkeys_find_slot_by_key_ptrfor the common< KEYS_INDEX_THRESHOLD(32keyable fields) case, so it is fixed transitively — no separate write-path
defect was found.
Gate:
scripts/check_file_size.shic_miss.rswas already exactly 2000 lines (the gate's own threshold) beforethis change. The 1-line fix (plus a 1-line comment) puts it 1 line over. Per
this repo's own precedent for the same situation (see the existing
body_stmt.rs/then.rsallowlist entries), I addedic_miss.rsto theALLOWLISTwith a short rationale rather than force a structural split of anunrelated ~2000-line IC-miss ladder into this fix's diff.
check_file_size.shpasses clean after the addition.
Tests
test-files/test_gap_10595_inherited_accessor_field_shape.ts— covers astring-key getter, a Symbol-key getter, a getter+setter pair, a two-level
subclass (
SubSub), a field overridden with a different runtime type(
string→number), a computedobj[key]read, and direct-read controls(which already passed pre-fix, proving the accessor/computed-key path is
the only thing regressing).
9df5075fbe, built fresh, unmodified): 6 of 15printed lines diverge from Node — e.g.
sub.tagViaGetter base-taginstead of
sub-tag,subsub[tagSym] sub-taginstead ofsubsub-tag.crates/perry-runtime/src/object/keys_lookup.rs— 3 new unit tests(
tests_10595module): duplicate-name resolves to the last occurrence,single-occurrence names are unaffected by scan direction, an absent key
still misses.
crates/perry-runtime/src/object/shapes_tests.rs— 1 new unit test for theindexed (
≥32-key) lookup path, same duplicate-name assertion.Validation
cargo test --release -p perry-runtime --lib(RUST_TEST_THREADS=1):3996 passed, 2 failed — both failures reproduce identically on a fresh,
unmodified baseline build at the same commit (
9df5075fbe):gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_checkand
gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds— both are
debug_assert!-gated guards that a--releasebuild compilesout (see CLAUDE.md's "Verifying a runtime change" /
--profile gcauditnote); pre-existing, unrelated to this change. The 4 new tests all pass.
Lint: 76/77 script gates passed (compile tier not run, per this
campaign's host notes). The one failure, "Public benchmark evidence
freshness" (
benchmarks/ci_public_baseline_check.py), is known-red onmainfor everyone.scripts/check_file_size.shpasses (see above).cargo fmt --allmade no changes.check_test_registration.py: clean(334 files checked against 4 registries, no new entries needed).
Gap suite (
--filter field, 55 tests,PERRY_SKIP_BUILD=1 PERRY_NO_AUTO_OPTIMIZE=1): baseline 50 pass / 2 fail / 3 compile-fail →fixed 51 pass / 1 fail / 3 compile-fail. The fixed-run's one remaining
failure (
test_issue_341_typed_field_native, a Nodepackage_json_reader-related module-resolution mismatch) and all 3compile failures (
test_issue_10155_textfield_singleline,test_issue_640_navstack_textfield,test_issue_763_reactive_textfield—native-UI
TextFieldtests needing an AppKit/iOS SDK this Linux hostdoesn't have) reproduce identically on the untouched baseline. No new
failures.
Performance (
perf stat -e instructions,task-clock,--profile perry-dev— this host's disk was at 96–97% free during validation, so Iavoided a second
--releaseruntime build rather than risk exhausting ashared box; the instruction-count comparison is still baseline-vs-fix on
identical build settings), 3 runs each, baseline vs fixed:
this.ain a 20M-iteration loop (IC-primed after warmup)w[key](key="a", the first-declared field of an 8-field class — the deliberate worst case for a back-to-front scan) in a 5M-iteration loopBoth are within the ±1% noise floor; compiled
.ll/binary sizes arebyte-identical between baseline and fixed builds for both probes (codegen
is untouched — only the runtime
.adiffers), confirming the comparisonisolates the runtime change. The static-key case is unaffected because the
emitted per-callsite inline cache amortizes the one-time scan after the
first hit; the dynamic-key worst case shows no measurable cost because the
handful of extra short-string comparisons is dwarfed by the call's existing
per-iteration overhead (shape/hash resolution, typed-feedback bookkeeping).
What I did NOT verify
≥32-keyable-field indexed shape-lookup fix (shapes.rs) is coveredby a unit test but not by an end-to-end gap test (no gap fixture in this
PR has a class that large).
guidance — relying on CI's gap-suite shards plus the targeted
--filter fieldsweep above (55 tests spanning fields/inheritance/prototypes).--profile gcaudit(the two pre-existingdebug_assert!-gated test failures noted above were only confirmedpre-existing via an identical
--releaseA/B, not resolved).Related, out of scope
Confirmed, not fixed here:
Object.keys(sub)on the repro above prints["tag","tag"](Node:["tag"]) — the underlying storage still has twophysical inline slots for the shadowed name even though every lookup now
resolves to the correct one, and
Object.keys/for...inenumerate the rawkeys array rather than deduplicating by name.
JSON.stringify(sub)isunaffected (
{"tag":"sub-tag"}, correct) — it must already dedupe or resolvedifferently. Worth a follow-up issue; left out of this PR to keep the diff to
the read-path defect #10595 actually reports.
Fixes #10595
Summary by CodeRabbit
Bug Fixes
Tests