Skip to content

fix(codegen): an inherited property read no longer folds to undefined on a scalar-replaced object (#10689) - #10705

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/10689-escape-read
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/10689-escape-read

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #10689.

const o = { a: 1 };
typeof o.toString       // perry: undefined · node: function
o.toString()            // perry: [object Object] — correct

Silent wrong answer, no error, and order-dependent in a way that made it look like it depended on unrelated earlier statements.

It is escape analysis, not realm population

The issue attributes this to lazy populate_global_this_builtins, and that is wrong — the two were conflated because JSON.stringify(o), used in the issue's repro, both forces the realm and makes o escape. Separating them:

const o = { a: 1 };
JSON.stringify({ b: 2 });   // forces the realm; `o` does NOT escape
typeof o.toString           // still undefined
const o = { a: 1 };
const s = []; s.push(o);    // `o` escapes; realm never forced
typeof o.toString           // function — correct

for...of forces the realm and does not fix it. Escaping is what matters; the realm is incidental.

Mechanism

check_escapes_in_expr's Expr::PropertyGet arm classified every read on a scalar-replacement candidate as // Plain field read — safe, without checking that the class chain declares the key. The local therefore stayed scalar-replaced — no heap object exists at all — and expr/property_get.rs's scalar arm, finding no slot for the key, folds the read to the constant undefined. Calls were always right because a fused method call never consults the elided object.

The same rule was already in this file three times, on the write arms#9024 (PropertySet/PutValueSet), #9460 (PropertyUpdate) — and in the sibling literal analysis escape_objects.rs. Only the read arm was missing it.

Blast radius is wider than the issue reported: a class's own prototype method read as a value (typeof c.mundefined while c.m()1) and user-added Object.prototype members were also invisible.

The change

One file, +73/−19, no runtime code.

  1. Escape the receiver when class_chain_has_field is false — the same helper the write arms use.
  2. Stop routing a fused method-call callee back through the PropertyGet arm, so simple_scalar_method_summary receivers stay scalar-replaced.

Which reads force, and which do not — every js_get_global_this() site is byte-identical. Reads of declared fields stay on the no-heap scalar path, which is spec-correct: an own property shadows the chain and OrdinaryGet never reaches the prototype. Reads of undeclared keys take the ordinary heap path, which resolves the chain and forces the realm exactly where it already would.

Results

  • Fixture ladder, 96 programs, both arms from one tree: base 87 pass / 8 fail95 pass / 0 fail. All 8 failures were this bug; nothing regressed.
  • 6 new tests: 4 fail on base and pass here. The other 2 are the required guards — calls still work (o.toString(), o.hasOwnProperty("a"), "" + o) and genuinely absent keys still read undefined — so a future change cannot "fix" reads by breaking calls.
  • Instructions on r0–r9: flat, max |Δ| 0.08%, sign mixed.
  • Witness on the axis this could ruin — non-escaping new, literal, and summarised-call receivers, 200k iterations each: all four within ±0.001%. Scalar replacement is not weakened for the cases it is meant to serve.

The three programs that were wrong got slower, and that is the point

probe base fixed
plain inherited read 808,118 25,168,549 ×31
toString read first 814,451 22,163,915 ×27
same, but already forces the realm 25,150,803 25,162,090 +0.04%

They were cheap because they were wrong — the folded undefined skipped realm population entirely. The control row is the proof: a program that already forced the realm pays 0.04%. The +24M is #10686's existing constant surfacing where a wrong answer had been dodging it, not a cost this change invents. Fixing #10686 removes it.

Gates

cargo fmt ✅ · check_file_size.sh ✅ · gc_runtime_root_holders.py ✅ · clippy 457 = 457, byte-identical · perry-codegen 2138 pass / 0 fail · perry-runtime 4039 pass / 2 failures, both pre-existing on base (debug-assert sabotage tests that cannot pass under --release; the repo's own Cargo.toml says they want --profile gcaudit, and cargo tree -p perry-runtime | grep -c perry-codegen = 0, so that binary is identical in both arms).

Not included

#10686 is not fixed here. The two do not share a mechanism, so the "opposite directions" concern noted on both issues does not arise — this fix never needed anything to start forcing. #10686 is now unblocked rather than in tension with it, and the table above makes it more valuable, not less. It should be its own change with its own sabotage-proved test.

The version and CLAUDE.md are deliberately not bumped: they are workspace-wide inputs that would invalidate every artifact these numbers came from, and parallel sessions would collide.

Separately found while here, pre-existing and not filed yet: o.__proto__ === Object.prototype is false in perry and true in node, reproducing on base with a fully escaped heap object.

https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect undefined results when reading inherited object properties such as constructor, toString, hasOwnProperty, and other prototype members.
    • Ensured inherited methods and prototype properties behave consistently whether they are read, called, or accessed on class instances.
    • Removed evaluation-order differences where unrelated operations could affect the result of inherited property reads.
  • Tests

    • Added regression coverage for built-in, custom, and class prototype properties, including missing and own-property cases.

… on a scalar-replaced object (PerryTS#10689)

`check_escapes_in_expr`'s `Expr::PropertyGet` arm treated every read on a
scalar-replacement candidate as a plain field read, without checking that
the class chain declares the key. Scalar replacement allocates a slot per
declared field only, so `expr/property_get.rs`'s scalar arm found no slot
and folded the read to the constant `undefined`.

The effect was silent and order-dependent:

    const o = { a: 1 };
    typeof o.toString       // undefined, where node gives "function"
    o.toString()            // correct — a fused call never consults the
                            // elided object

It reached `Object.prototype` members read as values (`toString`,
`constructor`, `hasOwnProperty`), a class's own prototype method read as a
value, and user-added `Object.prototype` properties. Anything that made the
receiver escape — passing it to a function, storing it in an array —
repaired it, which is what made the bug look like it depended on unrelated
earlier statements.

This is the READ half of the rule the write arms already apply: PerryTS#9024 for
`PropertySet`/`PutValueSet` and PerryTS#9460 for `PropertyUpdate`, plus the
sibling literal analysis in `escape_objects.rs`. Only the read arm was
missing it.

Reads of declared fields still take the no-heap scalar path, which is
spec-correct because an own property shadows the chain and OrdinaryGet
never reaches the prototype. Reads of undeclared keys now take the ordinary
heap path. A fused method call is unaffected: its callee is handled in the
`Expr::Call` arm and is no longer routed through `PropertyGet`, so
`simple_scalar_method_summary` receivers stay scalar-replaced.

Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 655a49be-98aa-433c-8890-75ff69429fad

📥 Commits

Reviewing files that changed from the base of the PR and between 8df83f8 and c832aa8.

📒 Files selected for processing (3)
  • changelog.d/10689-inherited-read-escape.md
  • crates/perry-codegen/src/collectors/escape_check.rs
  • crates/perry/tests/object_prototype_value_read_10689.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The change updates escape analysis so inherited property reads on scalar-replacement candidates use the ordinary object path. Fused method calls retain scalar method handling. New integration tests cover built-in, class, user-added, absent, and own properties.

Changes

Inherited member read correctness

Layer / File(s) Summary
Escape analysis for inherited reads
crates/perry-codegen/src/collectors/escape_check.rs, changelog.d/10689-inherited-read-escape.md
PropertyGet now escapes the receiver when the property is not declared on the class chain. Fused method-call callees use one scalar method summary lookup and skip the inherited-read check.
Regression coverage
crates/perry/tests/object_prototype_value_read_10689.rs
Tests cover inherited built-in members, evaluation order, callable members, class prototype methods, user-added prototype members, absent keys, and own fields.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main fix: inherited property reads on scalar-replaced objects no longer fold to undefined.
Description check ✅ Passed The description is comprehensive and covers the summary, implementation changes, related issue, test results, performance impact, scope, and excluded work. It does not use the repository template head…
Linked Issues check ✅ Passed The change satisfies issue #10689. check_escapes_in_expr now escapes a candidate receiver when the property is not declared in its class chain, so inherited reads use the heap path. The call handlin…
Out of Scope Changes check ✅ Passed The changed production code, regression tests, and changelog all support issue #10689. The call-arm adjustment prevents the fix from changing the existing fused method-call behavior. No unrelated prod…
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 2 files. (1 skipped: 1 u…
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10716 (v0.5.1598). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants