Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/expr/instance_misc1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
"Event" | "globalThis.Event" => 0xFFFF2403u32,
"CustomEvent" | "globalThis.CustomEvent" => 0xFFFF2404u32,
"DOMException" | "globalThis.DOMException" => 0xFFFF2405u32,
// #6301. Keep in sync with
// perry-runtime/src/event_target.rs::CLASS_ID_EVENT_TARGET.
// A plain `new EventTarget()` carries this id on its header; a
// `class X extends EventTarget` instance reaches it through the
// parent edge the class registry wires at definition time.
"EventTarget" | "globalThis.EventTarget" => 0xFFFF2406u32,
// node:fs constructor exports. Keep these ids in sync with
// perry-runtime/src/fs/mod.rs and instanceof.rs.
"fs.Dir" => 0xFFFF0086u32,
Expand Down
251 changes: 248 additions & 3 deletions crates/perry-runtime/src/event_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ use std::sync::{Mutex, OnceLock};
pub const CLASS_ID_EVENT: u32 = 0xFFFF_2403;
pub const CLASS_ID_CUSTOM_EVENT: u32 = 0xFFFF_2404;
pub const CLASS_ID_DOM_EXCEPTION: u32 = 0xFFFF_2405;
/// `EventTarget` base class. Stamped on `new EventTarget()` instances and used
/// as the PARENT class id of a user `class X extends EventTarget` (wired by
/// `js_register_class_parent_dynamic` via `global_builtin_constructor_class_id`).
/// Walking to it through the class chain is what lets a subclass instance be
/// recognized as an event target (#6301). Keep in sync with the reserved id in
/// perry-codegen/src/expr/instance_misc1.rs.
pub const CLASS_ID_EVENT_TARGET: u32 = 0xFFFF_2406;

const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;
const TAG_NULL: u64 = 0x7FFC_0000_0000_0002;
Expand Down Expand Up @@ -491,7 +498,35 @@ pub(crate) fn abort_dom_exception_value() -> f64 {
crate::value::js_nanbox_pointer(err as i64)
}

unsafe fn is_event_target(target: *const ObjectHeader) -> bool {
/// True for `EventTarget`'s own reserved class id and for any user class whose
/// registered parent chain reaches it — `class Bus extends EventTarget {}` and
/// `class B extends A extends EventTarget` alike. The edge is wired at class-
/// definition time by `js_register_class_parent_dynamic`, which resolves the
/// `EventTarget` global through `global_builtin_constructor_class_id` (#6301).
/// Mirrors `is_event_instance`'s walk for the `Event` base.
pub(crate) fn class_chain_is_event_target(class_id: u32) -> bool {
if class_id == 0 {
return false;
}
if class_id == CLASS_ID_EVENT_TARGET {
return true;
}
let mut cur = class_id;
for _ in 0..64 {
match crate::object::get_parent_class_id(cur) {
Some(parent) if parent != 0 && parent != cur => {
if parent == CLASS_ID_EVENT_TARGET {
return true;
}
cur = parent;
}
_ => return false,
}
}
false
}

pub(crate) unsafe fn is_event_target(target: *const ObjectHeader) -> bool {
if target.is_null() {
return false;
}
Expand All @@ -507,16 +542,68 @@ unsafe fn is_event_target(target: *const ObjectHeader) -> bool {
if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT {
return false;
}
// A subclass instance carries its own class id and never ran
// `js_event_target_new`, so it has no `_eventTarget` marker field until
// `seed_event_target_state` lazily installs one. The class-chain edge is
// what identifies it (#6301).
if class_chain_is_event_target((*target).class_id) {
return true;
}
let marker = js_object_get_field_by_name_f64(target, key(b"_eventTarget"));
marker.to_bits() == JSValue::bool(true).bits()
}

/// Install the hidden EventTarget state (marker + listener bag + max-listener
/// count) on an object that is an event target by class chain but has never
/// been seeded — i.e. a `class Bus extends EventTarget` instance, whose
/// constructor path allocates a plain subclass object rather than calling
/// `js_event_target_new`. Seeding on first listener/dispatch use keeps the
/// subclass ctor lowering untouched.
///
/// `js_object_alloc` and `key` (which interns a string) both allocate and can
/// therefore GC-move `target`, so every heap value is rooted up front and
/// re-read from its handle at each use. The bag and all three key strings are
/// allocated BEFORE the first store on purpose: the inline form
/// `js_object_set_field_by_name(target, key(b"…"), …)` allocates the key
/// *after* Rust has already evaluated `target` (arguments evaluate
/// left-to-right), which would leave a stale receiver pointer if interning that
/// key triggered a collection.
unsafe fn seed_event_target_state(target: *mut ObjectHeader) -> *mut ObjectHeader {
let scope = crate::gc::RuntimeHandleScope::new();
let target_handle = scope.root_raw_mut_ptr(target);
let bag_handle = scope.root_raw_mut_ptr(js_object_alloc(0, 0));
let marker_key = scope.root_string_ptr(key(b"_eventTarget"));
let listeners_key = scope.root_string_ptr(key(b"_eventTargetListeners"));
let max_listeners_key = scope.root_string_ptr(key(b"_eventTargetMaxListeners"));

js_object_set_field_by_name(
target_handle.get_raw_mut_ptr::<ObjectHeader>(),
marker_key.get_raw_mut_ptr::<StringHeader>(),
bool_value(true),
);
js_object_set_field_by_name(
target_handle.get_raw_mut_ptr::<ObjectHeader>(),
listeners_key.get_raw_mut_ptr::<StringHeader>(),
boxed_ptr(bag_handle.get_raw_mut_ptr::<ObjectHeader>()),
);
js_object_set_field_by_name(
target_handle.get_raw_mut_ptr::<ObjectHeader>(),
max_listeners_key.get_raw_mut_ptr::<StringHeader>(),
10.0,
);

bag_handle.get_raw_mut_ptr::<ObjectHeader>()
}

unsafe fn listeners_bag(target: *mut ObjectHeader) -> Option<*mut ObjectHeader> {
if !is_event_target(target) {
return None;
}
let bag = js_object_get_field_by_name_f64(target, key(b"_eventTargetListeners"));
value_as_ptr::<ObjectHeader>(bag)
if let Some(bag) = value_as_ptr::<ObjectHeader>(bag) {
return Some(bag);
}
Some(seed_event_target_state(target))
}

unsafe fn event_array(
Expand Down Expand Up @@ -655,9 +742,16 @@ extern "C" fn event_target_abort_remove_listener(
}

/// `new EventTarget()`.
///
/// The instance carries `CLASS_ID_EVENT_TARGET` on its header (mirroring
/// `construct_event`'s `CLASS_ID_EVENT`), so `t instanceof EventTarget` holds
/// for the base the same way it holds for a subclass through the registered
/// parent edge (#6301). The `_eventTarget` marker field stays: the Node
/// `events` helpers' target probe predates the class id, and a subclass
/// instance is seeded with the same marker on first use.
#[no_mangle]
pub extern "C" fn js_event_target_new() -> *mut ObjectHeader {
let target = js_object_alloc(0, 0);
let target = js_object_alloc(CLASS_ID_EVENT_TARGET, 0);
let bag = js_object_alloc(0, 0);
js_object_set_field_by_name(
target,
Expand Down Expand Up @@ -913,3 +1007,154 @@ pub unsafe extern "C" fn js_event_target_set_max_listeners(
js_object_set_field_by_name(target, key(b"_eventTargetMaxListeners"), n);
1
}

// ─────────────────────────────────────────────────────────────────
// #6301 — the EventTarget method surface as real function VALUES.
//
// `addEventListener` / `removeEventListener` / `dispatchEvent` used to exist
// only as compile-time lowerings keyed on a receiver whose static class name
// was literally `EventTarget` (`lower_call/event_target.rs`). Nothing lived on
// a prototype, so `typeof t.addEventListener` was `undefined` even on a plain
// `new EventTarget()`, and a `class Bus extends EventTarget {}` instance —
// whose static class name is `Bus` — inherited nothing at all and died with
// `TypeError: value is not a function` (the real root cause of #5931: cac v7's
// `class CAC extends EventTarget` calls `this.dispatchEvent(...)`).
//
// The methods are now resolved off the receiver's *class chain* — the same
// mechanism `Event` subclasses already use — and materialized as bound-method
// closures (the AbortSignal `abort_signal_method_bind` shape, and the
// value-read-materializes-a-bound-method shape of #6281). Both the value-read
// path (`object/field_get_set/get_field_by_name_tail.rs`) and the dynamic-call
// path (`object/native_call_method.rs`) consult `event_target_method_bind`, so
// a subclass instance at any depth reads them as functions AND calls them.
// ─────────────────────────────────────────────────────────────────

/// The receiver a bound EventTarget method closure captured in slot 0 (stored
/// as NaN-boxed bits so the GC's closure scan keeps it alive and relocates it).
unsafe fn bound_event_target(closure: *const crate::closure::ClosureHeader) -> *mut ObjectHeader {
let bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64;
crate::value::js_nanbox_get_pointer(f64::from_bits(bits)) as *mut ObjectHeader
}

/// Shared body of the add/remove listener thunks. `string_from_value` can
/// allocate (a non-string `type` is coerced), so the receiver and the value
/// arguments are rooted across it and re-read afterwards.
unsafe fn bound_listener_call(
closure: *const crate::closure::ClosureHeader,
event_type: f64,
listener: f64,
options: f64,
add: bool,
) -> f64 {
let listener_value = JSValue::from_bits(listener.to_bits());
if !listener_value.is_pointer() {
// Node ignores a nullish listener and rejects a non-object one; a
// non-pointer here is neither a closure nor a handler object, so there
// is nothing to register or remove.
return undefined_value();
}
let scope = crate::gc::RuntimeHandleScope::new();
let target_handle = scope.root_raw_mut_ptr(bound_event_target(closure));
let listener_handle = scope.root_nanbox_f64(listener);
let options_handle = scope.root_nanbox_f64(options);

let name_handle = scope.root_string_ptr(string_from_value(event_type));

let target = target_handle.get_raw_mut_ptr::<ObjectHeader>();
let name = name_handle.get_raw_const_ptr::<StringHeader>();
let callback_ptr = crate::value::js_nanbox_get_pointer(listener_handle.get_nanbox_f64());
let options = options_handle.get_nanbox_f64();
if add {
js_event_target_add_event_listener_with_options(target, name, callback_ptr, options);
} else {
js_event_target_remove_event_listener_with_options(target, name, callback_ptr, options);
}
undefined_value()
}

extern "C" fn event_target_add_event_listener_thunk(
closure: *const crate::closure::ClosureHeader,
event_type: f64,
listener: f64,
options: f64,
) -> f64 {
unsafe { bound_listener_call(closure, event_type, listener, options, true) }
}

extern "C" fn event_target_remove_event_listener_thunk(
closure: *const crate::closure::ClosureHeader,
event_type: f64,
listener: f64,
options: f64,
) -> f64 {
unsafe { bound_listener_call(closure, event_type, listener, options, false) }
}

extern "C" fn event_target_dispatch_event_thunk(
closure: *const crate::closure::ClosureHeader,
event: f64,
) -> f64 {
unsafe { js_event_target_dispatch_event(bound_event_target(closure), event) }
}

/// The EventTarget method names, in the order Node's `EventTarget.prototype`
/// declares them. Callers gate on this before paying for the receiver probe.
pub(crate) fn is_event_target_method_name(name: &[u8]) -> bool {
matches!(
name,
b"addEventListener" | b"removeEventListener" | b"dispatchEvent"
)
}

/// Materialize a bound EventTarget method for `name` when `target` is an event
/// target (a plain `new EventTarget()` or a subclass instance at any depth).
/// Returns `None` for any other name or receiver, so an unknown property still
/// reads as `undefined` and a non-target object keeps its existing dispatch.
pub(crate) fn event_target_method_bind(target: *mut ObjectHeader, name: &[u8]) -> Option<f64> {
let (func, arity): (*const u8, u32) = match name {
b"addEventListener" => (event_target_add_event_listener_thunk as *const u8, 3),
b"removeEventListener" => (event_target_remove_event_listener_thunk as *const u8, 3),
b"dispatchEvent" => (event_target_dispatch_event_thunk as *const u8, 1),
_ => return None,
};
if !unsafe { is_event_target(target) } {
return None;
}
crate::closure::js_register_closure_arity(func, arity);
// `js_closure_alloc` can GC-move the receiver — root it and re-read.
let scope = crate::gc::RuntimeHandleScope::new();
let target_handle = scope.root_raw_mut_ptr(target);
let closure = crate::closure::js_closure_alloc(func, 1);
let target_bits = boxed_ptr(target_handle.get_raw_mut_ptr::<ObjectHeader>()).to_bits();
crate::closure::js_closure_set_capture_ptr(closure, 0, target_bits as i64);
Some(boxed_ptr(closure))
}

/// Value-read entry point for the object field-get tail (#6301): resolve
/// `addEventListener` / `removeEventListener` / `dispatchEvent` off the
/// receiver's class chain when nothing earlier in the property walk claimed the
/// name. Gated on the name first, so a non-EventTarget read pays only a
/// length+compare, never the receiver probe.
///
/// The call site sits AFTER the tail's `keys_array.is_null()` early return, and
/// that placement is correct: a `class X extends EventTarget` instance never
/// takes the keyless branch. Every class-instance allocator
/// (`js_object_alloc_class_inline_keys`, `js_object_alloc_class_with_keys`,
/// `js_object_alloc_class_dynamic_parent`) installs the shape-cached keys array
/// unconditionally, and `js_build_class_keys_array` hands back a zero-LENGTH
/// array — never NULL — for a class that declares no fields. So even the
/// emptiest subclass (`class Bus extends EventTarget {}`, no fields, no ctor)
/// lands on the shaped path with an empty keys array and reaches this fallback.
///
/// The keyless branch is for `Object.create(proto)` / bare
/// `js_new_function_construct` receivers, whose inherited methods resolve one
/// hop earlier through `resolve_proto_chain_field_with_receiver` (the prototype
/// object is itself a shaped, class-id-stamped receiver) — so they never reach
/// that branch's dead end either. The empty-shape cases in
/// `test-files/test_gap_6301_event_target_subclass.ts` pin this.
pub(crate) fn event_target_value_read(target: *mut ObjectHeader, name: &[u8]) -> Option<f64> {
if !is_event_target_method_name(name) {
return None;
}
event_target_method_bind(target, name)
}
Original file line number Diff line number Diff line change
Expand Up @@ -1968,6 +1968,21 @@ pub(crate) fn get_field_by_name_object_tail(
}
}

// #6301: `EventTarget`'s method surface, read as a VALUE
// (`typeof b.dispatchEvent`, `const add = t.addEventListener`).
// Deliberately LAST in the tail so an own property and a real
// class-vtable method (a subclass that *overrides* `dispatchEvent`) are
// resolved earlier and keep winning. See
// `event_target::event_target_value_read` for why placing it after the
// `keys_array.is_null()` early return above is correct.
if !key.is_null() {
if let Some(bound) =
crate::event_target::event_target_value_read(obj as *mut ObjectHeader, key_bytes)
{
return JSValue::from_bits(bound.to_bits());
}
}
Comment on lines +1971 to +1984

@coderabbitai coderabbitai Bot Jul 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move this fallback before the keyless early return.

For class instances with keys_array == NULL, get_field_by_name_object_tail returns at Line [1521] before reaching this block. Since EventTarget methods have no prototype home, the keyless path will still return undefined for inherited addEventListener, removeEventListener, and dispatchEvent.

Invoke the same binding logic in the keyless branch after existing override/prototype checks, preserving subclass overrides.

Proposed fix
@@ keyless `keys.is_null()` branch, before its final return
+        if !key.is_null()
+            && crate::event_target::is_event_target_method_name(key_bytes)
+        {
+            if let Some(bound) = crate::event_target::event_target_method_bind(
+                obj as *mut ObjectHeader,
+                key_bytes,
+            ) {
+                return JSValue::from_bits(bound.to_bits());
+            }
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #6301: `EventTarget`'s method surface, read as a VALUE
// (`typeof b.dispatchEvent`, `const add = t.addEventListener`). The
// methods have no prototype home — they were only ever compile-time
// lowerings keyed on a receiver statically named `EventTarget` — so a
// plain `new EventTarget()` read them as `undefined` and a
// `class Bus extends EventTarget {}` instance inherited nothing.
// Materialize a bound method for a receiver that is an event target by
// marker (the plain instance) or by class chain (a subclass at any
// depth). Deliberately LAST in the tail: an own property and a real
// class-vtable method (a subclass that *overrides* `dispatchEvent`)
// are resolved earlier and keep winning.
if !key.is_null() && crate::event_target::is_event_target_method_name(key_bytes) {
if let Some(bound) =
crate::event_target::event_target_method_bind(obj as *mut ObjectHeader, key_bytes)
{
return JSValue::from_bits(bound.to_bits());
}
}
if !key.is_null()
&& crate::event_target::is_event_target_method_name(key_bytes)
{
if let Some(bound) = crate::event_target::event_target_method_bind(
obj as *mut ObjectHeader,
key_bytes,
) {
return JSValue::from_bits(bound.to_bits());
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs`
around lines 1971 - 1988, Move the EventTarget fallback binding from the
tail-only block into the keyless branch of get_field_by_name_object_tail, after
existing own-property, override, and prototype checks. Reuse
is_event_target_method_name and event_target_method_bind for keyless class
instances so inherited addEventListener, removeEventListener, and dispatchEvent
resolve correctly while subclass overrides continue to win; retain the existing
tail fallback for keyed objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified empirically, and this one doesn't hold — the premise that a class instance can reach here with keys_array == NULL is false. Not moving the fallback, but I've documented the invariant and pinned it with fixtures so the next reader doesn't have to re-derive it.

Why a class X extends EventTarget instance never takes the keyless branch

Every class-instance allocator installs the shape-cached keys array unconditionally:

  • js_object_alloc_class_inline_keysset_object_keys_array(ptr, keys_array) (object/alloc.rs:240)
  • js_object_alloc_class_with_keysset_object_keys_array(ptr, keys_arr) (object/alloc.rs:402)
  • js_object_alloc_class_dynamic_parent — merges parent+own keys, else delegates to _with_keys (object/alloc.rs:436)

and the array they install is never NULL for a fieldless class — js_build_class_keys_array returns a zero-length array:

if field_count == 0 || packed_keys_len == 0 || packed_keys.is_null() {
    let arr = crate::array::js_array_alloc_with_length_longlived(0);   // <- empty, not null
    ...
    return arr;
}

(object/alloc.rs:284)

keys_array == NULL is the state of a freshly bump-allocated object (js_object_alloc, js_object_alloc_fast) before its first by-name store — i.e. Object.create(proto) results and bare js_new_function_construct instances. A class instance is never in that state.

Empirical check. I instrumented the tail with a temporary print at the keys.is_null() test and at the keyless dead-end return, and ran every shape I could think of that might produce an empty-shaped event target:

shape keys_array typeof x.dispatchEvent
class Bus extends EventTarget {} (no fields, no ctor) non-null (empty) function
subclass with a ctor that assigns nothing non-null (empty) function
two-level B extends A extends EventTarget non-null (empty) function
new Ctor() through a variable (dynamic new) non-null function
Reflect.construct(Bus, []) non-null function
const K = class extends EventTarget {} non-null function
class returned from a factory non-null function
subclass declared inside a function body non-null function
instance whose only own prop was deleted non-null function
Object.freeze(new Bus()) non-null function
Object.create(Bus.prototype) NULL function

The KEYLESS-DEAD-END probe never fired once — for any of these, for any of the three method names.

The only receiver that does reach the keyless branch is Object.create(Bus.prototype), and it already resolves correctly inside that branch: resolve_proto_chain_field_with_receiver (the first thing the keyless branch tries, well before its return undefined()) hops to the prototype object, which is itself a shaped, class-id-stamped receiver, and that hop reaches this fallback. I also checked it doesn't leak listeners onto the shared prototype — a listener added via an Object.created receiver does not fire on a sibling new Bus(). (Node actually throws Cannot read properties of undefined (reading 'get') on that shape, since there's no internal slot — so we're strictly more permissive there, not less.)

What I changed in response

Rather than move the block (which would put it ahead of the own-property and class-vtable lookups and break the subclass-override ordering this PR exists to get right), I:

  1. Documented the invariant on event_target::event_target_value_read, so the placement is justified where the mechanism lives.
  2. Added the empty-shape cases to test-files/test_gap_6301_event_target_subclass.ts — empty ctor, dynamic new, Reflect.construct, class expression, function-local subclass, deleted-own-prop, frozen instance. All byte-identical to node --experimental-strip-types, also under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1.

Thanks for the push on this — the invariant was load-bearing and undocumented, which is exactly what made the finding plausible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
fd -t f alloc.rs -p crates/perry-runtime/src/object
echo "---"
rg -n "fn js_object_alloc_class_inline_keys|fn js_object_alloc_class_with_keys|fn js_object_alloc_class_dynamic_parent|fn js_build_class_keys_array" crates/perry-runtime/src/object/alloc.rs
echo "---"
rg -n -A15 "fn js_build_class_keys_array" crates/perry-runtime/src/object/alloc.rs
echo "---"
rg -n "set_object_keys_array" crates/perry-runtime/src/object/alloc.rs

Length of output: 5176


Confirms the claim: every class-instance allocator path (js_object_alloc_class_inline_keys, js_object_alloc_class_with_keys, js_object_alloc_class_dynamic_parent, and the two others found at lines 523/609/681/747) calls set_object_keys_array unconditionally, and js_build_class_keys_array returns an empty (non-null) array rather than null when field_count == 0. So the keyless branch is genuinely unreachable for class instances, matching the empirical results.

@proggeramlug Thanks for the thorough empirical verification — I checked the allocator call sites directly and confirm your analysis holds: js_object_alloc_class_inline_keys, js_object_alloc_class_with_keys, and js_object_alloc_class_dynamic_parent all call set_object_keys_array unconditionally, and js_build_class_keys_array returns an empty (non-null) ArrayHeader — not NULL — when field_count == 0. So keys_array == NULL genuinely can't occur for a class instance (fieldless or not), only for the pre-first-store bump-allocated shapes you identified.

Withdrawing the original suggestion — the current placement after the keys.is_null() early return is correct, and the ordering constraint (own-property/vtable-override resolution before the EventTarget fallback) is preserved. Appreciate the documentation and fixture coverage added for this invariant.

ヽ(・∀・)ノ


🧬 Code Graph Analysis Results

Relevant snippets

crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs

  • (around the tail’s keys_array null fast-path and EventTarget value read)
    • let keys = (*obj).keys_array;
    • if keys.is_null() { ... } block that handles keyless receivers (including prototype-chain resolution and prototype-method lookup/vtable getter walk) and returns JSValue::undefined() if nothing resolves.
    • Later (deliberately last in the tail), the EventTarget “method surface read as VALUE” hook:
      • if !key.is_null() { if let Some(bound) = crate::event_target::event_target_value_read(obj as *mut ObjectHeader, key_bytes) { ... } }
    • Includes the in-code ordering/invariant rationale: the EventTarget arm is after the keys_array.is_null() early return so subclass overrides and own-property precedence remain correct.

crates/perry-runtime/src/event_target.rs

  • [lines 1155-1160]
pub(crate) fn event_target_value_read(target: *mut ObjectHeader, name: &[u8]) -> Option<f64> {
    if !is_event_target_method_name(name) {
        return None;
    }
    event_target_method_bind(target, name)
}

crates/perry-runtime/src/object/class_registry/construct.rs

  • [lines 1827-1847] lookup_prototype_method(...) (used by the keyless branch for JS-classic prototype method reads)
pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option<f64> {
    let guard = CLASS_PROTOTYPE_METHODS.read().ok()?;
    let map = guard.as_ref()?;
    let mut cid = class_id;
    let mut depth = 0usize;
    while depth < 32 {
        if let Some(per_class) = map.get(&cid) {
            if let Some(&bits) = per_class.get(name) {
                return Some(f64::from_bits(bits));
            }
        }
        match get_parent_class_id(cid) {
            Some(p) if p != 0 && p != cid => {
                cid = p;
                depth += 1;
            }
            _ => break,
        }
    }
    None
}

crates/perry-runtime/src/object/class_registry/parent_static.rs

  • [lines 1346-1349] get_parent_class_id(...) (used during class/vtable/prototype chain walking)
pub(crate) fn get_parent_class_id(class_id: u32) -> Option<u32> {
    let registry = CLASS_REGISTRY.read().unwrap();
    registry.as_ref().and_then(|r| r.get(&class_id).copied())
}

crates/perry-runtime/src/object/class_registry/parent_static.rs

  • [lines 1356-1377] lookup_class_method_in_chain(...) (method lookup across class parent chain; relevant to the keyless branch’s “fallback to class method in chain” logic)
pub fn lookup_class_method_in_chain(class_id: u32, name: &str) -> Option<(usize, u32, bool, bool)> {
    let registry = CLASS_VTABLE_REGISTRY.read().unwrap();
    let reg = registry.as_ref()?;
    let mut cur = class_id;
    for _ in 0..32 {
        if let Some(vt) = reg.get(&cur) {
            if let Some(entry) = vt.methods.get(name) {
                return Some((
                    entry.func_ptr,
                    entry.param_count,
                    entry.has_synthetic_arguments,
                    entry.has_rest,
                ));
            }
        }
        match get_parent_class_id(cur) {
            Some(pid) if pid != 0 => cur = pid,
            _ => return None,
        }
    }
    None
}


// Key not found
JSValue::undefined()
}
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/object/instanceof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,13 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 {
"Event" => crate::event_target::CLASS_ID_EVENT,
"CustomEvent" => crate::event_target::CLASS_ID_CUSTOM_EVENT,
"DOMException" => crate::event_target::CLASS_ID_DOM_EXCEPTION,
// #6301: the `X → EventTarget` edge is what makes a user
// `class Bus extends EventTarget {}` instance resolve the inherited
// `addEventListener`/`removeEventListener`/`dispatchEvent` surface
// (see `event_target::class_chain_is_event_target`). Without an id
// here `js_register_class_parent_dynamic` left the subclass
// parentless and it inherited nothing.
"EventTarget" => crate::event_target::CLASS_ID_EVENT_TARGET,
// TypedArray constructors used as runtime *values* (a dynamic
// `x instanceof TA` where `TA` is a variable — e.g. test262's
// `testWithTypedArrayConstructors`). Mirrors the per-kind synthetic
Expand Down
27 changes: 27 additions & 0 deletions crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1720,6 +1720,33 @@ pub unsafe extern "C" fn js_native_call_method(
}
}

// #6301: `class Bus extends EventTarget {}` — a fused
// `bus.dispatchEvent(ev)` / `this.addEventListener(...)` call. The static
// lowering in `lower_call/event_target.rs` only fires for a receiver whose
// class name is literally `EventTarget`, so a subclass call landed here and
// threw "<m> is not a function" (cac v7's `class CAC extends EventTarget`
// → #5931). Runs LAST, after every own-field / vtable / prototype-chain
// lookup above, so a subclass that OVERRIDES one of these names keeps its
// own method; only a genuine miss on a real event target reaches this.
if jsval.is_pointer()
&& crate::event_target::is_event_target_method_name(method_name.as_bytes())
{
// Re-read the receiver from its root handle instead of reusing the
// `object` snapshot taken at entry: the dispatch arms above allocate, so
// a moving collection may have relocated it (the same reason the args go
// through `refreshed_args()`).
let receiver = object_handle.get_nanbox_f64();
let recv = (receiver.to_bits() & crate::value::POINTER_MASK) as *mut ObjectHeader;
if !recv.is_null() && !crate::value::addr_class::is_small_handle(recv as usize) {
if let Some(bound) =
crate::event_target::event_target_method_bind(recv, method_name.as_bytes())
{
let args = refreshed_args();
return crate::closure::js_native_call_value(bound, args.as_ptr(), args.len());
}
}
}

crate::object::class_registry::report_dispatch_miss(
"call-method (no method/field/proto match)",
object,
Expand Down
Loading
Loading