fix(hir): give each evaluation of a dynamic-heritage class expression its own class (#11042) - #11122
proggeramlug wants to merge 3 commits into
Conversation
…xprs; hide pinned heritage keys from inspect; gap test (#11042)
|
Heads-up while this is being reduced: #11014's version of the The landed version is worth a look before you rebase, because the diagnosis went past "hide two keys":
Your perry-hir class-expression half is independent and still wanted — send the reduced head and it goes in the next train. |
|
Closing as superseded. Main fixed the #11042 Rebased onto main, this PR's extra The remaining per-evaluation prototype leak is filed as #11134, and the fix will carry this PR's gap test as coverage. |
Part of #11042 — fixes the reported
client.on("error", …)TypeError; the real package then stops on #11120 and #11121 (see Tests), so this does not close the issue.Root cause
A class expression with dynamic heritage (
class extends <runtime value> {}) evaluated inside a function was one shared class across every evaluation. It had one class id, one parent edge (the last evaluation'sextendswon) and oneClass.prototype[name] = …table.arm_class.rssent a function-body class expression through the per-evaluationClassExprFreshpath only when it also carried statics, computed keys, captures, private elements or a used self-binding. #10622 widened that to one shape: an exported factory whose whole body isreturn class extends X {}. Same-module direct call sites are cloned byspecialize_captured_class_factories. Everything else kept the shared template.@redis/client'scommander.jsattachConfigfalls outside all of those. It is a comma declarator (const RESP = …, Class = class extends BaseClass {}) followed by aClass.prototype[name] = …loop, and it is called across modules.RedisClient.factoryevaluates it forRedisClient, then forRedisClientMultiCommand(Client.prototype.Multi = …extend(config)), and only then runsnew Client(options). So:client.on("error", cb)resolved to theeventsmodule's staticon(emitter, name), which threwThe "emitter" argument must be an instance of EventEmitter. Received type string ('error').The earlier diagnosis on the issue was right about the symptom: the receiver reaching
js_native_call_method's namespace fast path was the realeventsnamespace object. The fast path is correct for a genuine namespace. The defect is upstream, in the clobbered class.Fix
crates/perry-hir/src/lower/lower_expr/arm_class.rs: every function-body class expression with a dynamicextends_exprnow takes theClassExprFreshpath. That path already existed, with a per-evaluation heritage pin (effect: HttpApi server dies at startup with "TypeError: undefined is not iterable" (logger/fork already work) #6438/SIGSEGV: factory returningclass D extends <param>, chained two levels, then instantiated (zod v4 $constructor shape) #9364/instanceof against a ClassExprFresh parent resolves by shared class id, so an earlier evaluation's class can fail instanceof after a later evaluation #10624) and a per-evaluation.prototype. A statically resolved parent (extends_expr == None) cannot differ between evaluations, so it keeps the cheaper shared template. Module-top expressions are unchanged.perry_hir::class_value_template_name(ir/class_value.rs) recognizes such a bareClassExprFresh(no statics, keys, captures or self-binding) as naming its template.factory_specialize.rsandlower/misc.rsnow use it where they read a factory's returnedClassRef. This keeps same-module per-call-site specialization and staticextends Factory(...)parent hoisting exactly as before.crates/perry-runtime/src/builtins/formatting.rs: routing more classes through the fresh path exposed a pre-existing leak.console.log/util.inspectprinted the internal__perry_ctor_class_object/__perry_parent_classpin keys, which every other reflective surface already hides. The first A/B run caught it as a regression intest_gap_9440_error_name_ownership. The formatter now skipsis_internal_runtime_key_byteskeys.Tests
Command and result for each check:
test_gap_11042_class_expr_dynamic_heritage_per_evaluation.ts(+gap_11042_…_helper.ts, a cross-moduleattachConfig). Stdout is byte-identical to Node 26.5.1 (/opt/node-v26.5.1-linux-x64) with the fix. Onorigin/maind8f24f1 (built the same way,--profile perry-dev,PERRY_NO_AUTO_OPTIMIZE=1) it fails:distinct classes: false, thenTypeError: a is not a function.test_gap_*whose source containsextends, plus names matchingclass_expr|mixin|factory|event|emitter|inspect|console|util_|format|weak|map_set|private|error. Each was compiled by the baseline and by this branch and compared to Node 26.5.1. 190 pass on both arms. The other 6 fail identically on both arms (console_methodsdiffers only in aconsole.timereading). Zero regressions. I ran my own loop, notrun_parity_tests.sh: the harness's fixed port collides with other agents' sweeps on the shared host.redis@6.1.0(pinned) against a liveredis-server: theclient.on("error", …)TypeError from this issue is gone. The fixture then stops on two separate pre-existing defects, filed as Subclass field initializer 'new events_1()' with a private-field parent throws 'Cannot initialize a private field twice' (blocks redis createClient) #11120 and redis: RedisClient.parseURL throws 'Value of URL.prototype.hostname called on an incompatible receiver' (new node_url_1.URL(...) in @redis/client) #11121. Both reproduce byte-identically onorigin/mainonce the.oncrash is out of the way. So this PR fixes the reported error, but the fixture does not yet run end-to-end.createClient({ socket })fails withCannot initialize a private field twice on the same object. The cause is a subclass field initializerevents = new events_1()over a parent with private fields, inlinked-list.js. It has a package-free repro.createClient({ url })fails becauseRedisClient.parseURLthrowsValue of URL.prototype.hostname called on an incompatible receiver. It reproduces with no server.cargo test --profile perry-dev -p perry-hir -p perry-transform: all pass.RUSTFLAGS="-D warnings" cargo check -p perry-hir -p perry-transform -p perry-runtime --all-targets(dev profile): clean.cargo fmt --all -- --check,scripts/check_file_size.sh: clean.SKIP_COMPILE_GATES=1 scripts/run_lint_gates.sh: 87 of 88 script gates pass. The one failure iscargo xwin check, becausecargo-xwinis not installed on the Linux host (environmental). The compile tier was not run.git diff --statwas empty afterwards.Known remaining (pre-existing, not changed here)
Instances still carry their template's class id. A class-id-keyed lookup can therefore still see a sibling evaluation's parent:
mk(A)instance reading a method that onlymk(B)'s base defines gets it, where Node givesundefined.Object.create(new (mk(R))()) instanceof Risfalseafter a later evaluation.These were already true for fresh class expressions with statics. Giving each evaluation its own class id would fix them, but that touches every cid-keyed runtime table, so it is not attempted here.
Not run
cargo test --workspaceandperry-runtime's own suite (only a formatter branch changed there).run_lint_gates.shcompile tier and the Windowscargo xwin check.perry-transformfactory-specialization tests pass unchanged.