Skip to content

fix(runtime): link a native-base subclass prototype to the real builtin prototype - #10614

Closed
proggeramlug wants to merge 4 commits into
fix/10478-10479-instanceof-value-kindsfrom
fix/10599-eventemitter-prototype-identity
Closed

proggeramlug wants to merge 4 commits into
fix/10478-10479-instanceof-value-kindsfrom
fix/10599-eventemitter-prototype-identity

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

class Sub extends EventEmitter {} left Object.getPrototypeOf(Sub.prototype) !== EventEmitter.prototype (and
same for EventEmitterAsyncResource). instanceof, prototype-chain identity for in/for...in, and dispatch
all work off other mechanisms; this is specifically the [[Prototype]] object-identity link.

This PR depends on #10592 (open, not yet merged) and is based on its branch
(fix/10478-10479-instanceof-value-kinds), not main. Reason found during validation, below.

Root cause

Two independent gaps, both required for the fix to actually take effect:

  1. Runtime (crates/perry-runtime/src/object/class_registry/state.rs, already fixed in this branch's history
    by a prior pass).
    class_decl_prototype_value resolves a registered parent class id by recursing into
    itself, which bails for a RESERVED native-builtin parent id (class_name_for_id returns None, since
    EventEmitter has no js_register_class_name registration of its own). The lookup silently fell through to
    the Object.prototype default. Fixed by resolving the real, closure-identity-keyed prototype through
    js_function_prototype_value_for_read — the same helper the existing runtime-function-valued-parent branch
    already uses.

  2. Codegen (crates/perry-codegen/src/expr/instance_misc1.rs, this PR's own commit). The runtime fix above
    only runs if get_parent_class_id(Sub) actually resolves to the reserved id in the first place — that
    requires builtin_parent_reserved_class_id("EventEmitter") to return Some(0xFFFF0076) so codegen emits the
    js_register_class_parent call. That entry does not exist on main; fix(runtime): resolve instanceof and Object.create constructor per receiver value kind #10592 adds it (for instanceof's
    sake). Its EventEmitterAsyncResource counterpart is not added by fix(runtime): resolve instanceof and Object.create constructor per receiver value kind #10592 either — this PR adds it
    ("EventEmitterAsyncResource" => 0xFFFF0077), the one piece genuinely new here.

Found by validation, not assumed: with only the runtime fix and main's current
builtin_parent_reserved_class_id table (no #10592), get_parent_class_id never resolves at all for
EventEmitter/EventEmitterAsyncResource — the runtime fix is dead code. Confirmed by building against main
directly first (gap test failed identically with or without the runtime fix), then rebasing onto #10592's
branch, where the runtime fix's own effect became visible.

Tests

test-files/test_gap_10599_eventemitter_prototype_identity.ts. Covers: direct subclass with field + ctor,
fieldless no-ctor subclass, two-level (indirect) subclass, unnamed and named-via-indirection class expressions,
EventEmitterAsyncResource, in/for...in walking the same chain, own-enumeration non-regression on the
subclass's own field, dispatch (on/emit) still works, an instanceof-still-holds control
(new Sub() instanceof EventEmitter: true, guarding against a #10592 regression), and a
class ArraySub extends Array {} control (dedicated ArrayHeader path, should be unaffected).

Fails without the fix, confirmed two ways:

  • Reverted the 55-line runtime fix via a real commit + full rebuild: every getPrototypeOf/instanceof/in
    assertion in the test read false where Node reads true.
  • Reverted just the runtime file's content (no commit, incremental archive-only rebuild) as a second,
    independent check: same result.
  • Restored the fix, rebuilt: output is byte-for-byte identical to node --experimental-strip-types (manual diff
    AND PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_10599_eventemitter_prototype_identity
    PASS, 100% parity).

Two pre-existing, unrelated bugs found while writing this test — deliberately left OUT of it (both verified
present identically with the runtime fix reverted, so neither is caused by this change):

  • Object.getPrototypeOf(EventEmitterAsyncResource.prototype) === EventEmitter.prototype is false in Perry.
    This is EventEmitterAsyncResource's own internal chain to EventEmitter.prototype (a native-to-native link
    set up wherever its .prototype first materializes), not a user extends subclass — outside this fix's
    mechanism (class_decl_prototype_value never runs for it; it has no declared-class registration).
  • Object.keys(new Sub()) returns
    ["tag","on","once","prependListener",...] (every EventEmitter.prototype method as a literal OWN
    enumerable property) instead of Node's ["_events","_eventsCount","_maxListeners","tag"]. Perry's native-base
    super() handling installs the native surface directly onto the instance as own properties (CLAUDE.md
    "Known-weak areas: Native base-class subclassing — a native base's surface is installed at super() time")
    and never sets the real internal fields. An own-property-enumeration defect, orthogonal to the [[Prototype]]
    chain identity this PR fixes.

python3 scripts/check_test_registration.py: OK, 332 files checked against 4 registries, none newly dark.

Validation

  • RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --tests: 3982 passed, 4 ignored, 2 pre-existing
    failures unrelated to this change
    gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check
    and gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds. Both assert
    debug_assert!-gated behavior by name and design (their own doc comments say so); --release compiles
    debug_assert! out (see CLAUDE.md's own callout on this), so they fail under --release on any commit,
    independent of this change. Not touched by this diff (GC internals, unrelated file).
  • cargo test --release -p perry-codegen --tests: all green, 0 failures (checked every test result: ok line).
  • Gap test vs Node 26.5.1 (/opt/node-v26.5.1-linux-x64, matching .node-version): byte-identical, harness
    PASS, 100% parity.
  • check_test_registration.py: OK (above).
  • Not run: full run_lint_gates.sh (owner directive: ignore CI/lint for this PR — lint's "Public
    benchmark evidence freshness" step is known-red on every open PR right now, pre-existing and unrelated), full
    gap suite (owner directive to not chase CI), perf A/B (this resolves a prototype object once at
    class-registration time — a one-time, per-class-declaration cost, not a per-operation hot path — so no
    measurable regression is expected; not measured), CodeRabbit review pass, package-level repro (issue does not
    name a specific npm package).

What I did NOT verify

Fixes #10599

Ralph Küpper added 3 commits September 18, 2026 09:20
…in prototype

class Sub extends EventEmitter {} left Sub.prototype's [[Prototype]] on
Object.prototype instead of EventEmitter.prototype. class_decl_prototype_value
resolves a registered parent class id by recursing into itself, which bails
for a RESERVED native-builtin parent id (builtin_parent_reserved_class_id in
perry-codegen wires this edge for a native base with no declared-class
registration), silently falling through to the Object.prototype default.

Resolve EventEmitter/EventEmitterAsyncResource's real, closure-identity-keyed
prototype object through the same js_function_prototype_value_for_read path
the existing runtime-function-valued-parent branch already uses, so
Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype holds by
identity. Fixes #10599.
…s-parent id

builtin_parent_reserved_class_id (perry-codegen) already gained an
"EventEmitter" => 0xFFFF0076 entry (#10592), but not its AsyncResource
variant: class Sub extends EventEmitterAsyncResource {} left
get_parent_class_id(Sub) unresolved entirely (no parent-edge call is ever
emitted), so the perry-runtime getPrototypeOf-identity fallback for
reserved native-builtin parents (#10599) never runs for it -- the same gap
#10592 closed for plain EventEmitter, one id over.
Covers: direct subclass with field+ctor, fieldless no-ctor subclass,
two-level (indirect) subclass, unnamed and named-via-indirection class
expressions, EventEmitterAsyncResource, in/for-in walking the same chain,
own-enumeration non-regression, dispatch still works, an
instanceof-still-holds control (guards #10592), and a class-extends-Array
control (dedicated ArrayHeader path, unaffected by this fix).

Verified: fails on the runtime fix alone (state.rs reverted, standalone)
with every getPrototypeOf/instanceof/`in` assertion false where Node says
true; passes with both the runtime fix and both codegen table entries.

Two pre-existing, unrelated gaps hit while writing this were deliberately
left uncovered (documented inline, not fixed here):
EventEmitterAsyncResource.prototype's own [[Prototype]] does not chain to
EventEmitter.prototype (a native-to-native link, not a user `extends`
subclass), and Object.keys(new Sub()) leaks EventEmitter's prototype
methods as literal own enumerable instance properties instead of Node's
real _events/_eventsCount/_maxListeners own fields (CLAUDE.md "Native
base-class subclassing -- a native base's surface is installed at
super() time"). Both reproduce identically with the fix reverted, so
neither is caused by it.
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f7cc2cd5-ba7a-4892-93c7-0c45c2313ef1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 #10652 (v0.5.1596). 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

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant