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
42 changes: 42 additions & 0 deletions crates/perry-ext-events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ extern "C" {
fn js_get_global_this() -> f64;
fn js_array_is_array(value: f64) -> f64;
fn js_abort_signal_add_listener(signal: *mut u8, event: f64, listener: f64);
// AbortSignal recognition for the module-level events helpers — Node
// treats a signal as an EventTarget. `resolve_ptr` returns null for
// non-signals.
fn js_abort_signal_resolve_ptr(value: f64) -> *mut u8;
fn js_abort_signal_listener_count(signal: *mut u8) -> f64;
fn js_abort_signal_listeners_copy(signal: *mut u8) -> *mut ArrayHeader;
fn js_event_target_is_event_target(target: *const u8) -> i32;
fn js_event_target_add_event_listener(
target: *mut u8,
Expand Down Expand Up @@ -1846,6 +1852,16 @@ pub unsafe extern "C" fn js_events_get_event_listeners(
target_value: f64,
event_name_ptr: *const StringHeader,
) -> *mut ArrayHeader {
// AbortSignal is an EventTarget in Node, but Perry represents it as its
// own native object that `event_helper_target` doesn't recognize. A
// signal only ever tracks "abort" listeners.
let signal_ptr = js_abort_signal_resolve_ptr(target_value);
if !signal_ptr.is_null() {
if string_from_header(event_name_ptr).as_deref() == Some("abort") {
return js_abort_signal_listeners_copy(signal_ptr);
}
return js_array_alloc(0);
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand Down Expand Up @@ -1876,6 +1892,14 @@ pub unsafe extern "C" fn js_events_listener_count(
target_value: f64,
event_name_ptr: *const StringHeader,
) -> f64 {
// AbortSignal: see `js_events_get_event_listeners`.
let signal_ptr = js_abort_signal_resolve_ptr(target_value);
if !signal_ptr.is_null() {
if string_from_header(event_name_ptr).as_deref() == Some("abort") {
return js_abort_signal_listener_count(signal_ptr);
}
return 0.0;
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand All @@ -1898,6 +1922,12 @@ pub unsafe extern "C" fn js_events_listener_count(
/// `events.getMaxListeners(emitter)` — alias.
#[no_mangle]
pub unsafe extern "C" fn js_events_get_max_listeners(target_value: f64) -> f64 {
// AbortSignal: Node's default EventTarget listener cap. Perry stores no
// per-signal override (`setMaxListeners` below is an accepted no-op), so
// the default is always reported.
if !js_abort_signal_resolve_ptr(target_value).is_null() {
return 10.0;
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand All @@ -1923,6 +1953,18 @@ pub unsafe extern "C" fn js_events_set_max_listeners(
let len = (*handles_ptr).length;
for i in 0..len {
let value = f64::from_bits(js_array_get(handles_ptr, i).bits());
// AbortSignal is an EventTarget in Node — SDKs routinely call
// `events.setMaxListeners(n, controller.signal)` to raise the
// MaxListenersExceededWarning threshold on a shared signal. Perry
// represents signals as their own native object that
// `event_helper_target` doesn't recognize, so this threw
// ERR_INVALID_ARG_TYPE and rejected the caller's whole request
// path. Accept the signal; the warning threshold is the call's
// only Node-observable effect and Perry never emits that warning
// for signals, so accepting is a faithful no-op.
if !js_abort_signal_resolve_ptr(value).is_null() {
continue;
}
match event_helper_target(value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"eventTargets",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,23 @@ pub(crate) fn get_field_by_name_object_tail(
}
}

// AbortSignal method read through a DYNAMICALLY-typed receiver
// (`const s: any = c.signal; s.addEventListener` / `typeof
// s.addEventListener`). The static receiver form lowers to the native
// call, but this generic walk found no method property and returned
// undefined (the #5964 URLSearchParams dynamic-dispatch class).
// Returns a bound-method closure for the known signal methods.
if (*obj).class_id == crate::url::abort::ABORT_SIGNAL_CLASS_ID && !key.is_null() {
let key_ptr = (key as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let key_len = (*key).byte_len as usize;
let key_bytes = std::slice::from_raw_parts(key_ptr, key_len);
if let Some(bound) =
crate::url::abort::abort_signal_method_bind(obj as *mut ObjectHeader, key_bytes)
{
return JSValue::from_bits(bound.to_bits());
}
}

// Refs #420 / #618 followup: `instance.constructor` returns the
// class ref. Pre-fix this fell through to the keys_array lookup
// which never finds "constructor" (the class itself isn't stored
Expand Down
41 changes: 41 additions & 0 deletions crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,47 @@ pub unsafe extern "C" fn js_native_call_method(
}
}
}
// AbortSignal on a type-erased receiver — same wall class as the
// URLSearchParams block above (#5961/#5964): the statically-typed receiver
// form lowers to the native call, but a fused dynamic method call lands
// here, and the generic field-scan would miss and throw
// `addEventListener is not a function` (the shape minified SDK code takes
// when it stores a signal in an untyped local). `options` (arg 2) is
// accepted and ignored — a signal only ever fires "abort" once, so
// `{ once: true }` is behaviorally implied.
if matches!(
method_name,
"addEventListener" | "removeEventListener" | "throwIfAborted"
) && jsval.is_pointer()
{
let recv_ptr = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader;
// Skip native handles (nanbox-pointer-tagged small integer ids in the
// low handle band) — dereferencing one as an `ObjectHeader` to read
// `class_id` would fault.
if !recv_ptr.is_null()
&& !crate::value::addr_class::is_small_handle(recv_ptr as usize)
&& (*recv_ptr).class_id == crate::url::abort::ABORT_SIGNAL_CLASS_ID
{
let arg = |i: usize| {
if i < args_len && !args_ptr.is_null() {
*args_ptr.add(i)
} else {
f64::from_bits(JSValue::undefined().bits())
}
};
return match method_name {
"addEventListener" => {
crate::url::js_abort_signal_add_listener(recv_ptr, arg(0), arg(1));
f64::from_bits(JSValue::undefined().bits())
}
"removeEventListener" => {
crate::url::js_abort_signal_remove_listener(recv_ptr, arg(0), arg(1));
f64::from_bits(JSValue::undefined().bits())
}
_ => crate::url::js_abort_signal_throw_if_aborted(recv_ptr),
};
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Generic `Array.prototype` mutators borrowed onto a plain array-like
// object (`Array.prototype.splice.call(obj, …)` whose synthesized member
// call dispatches by name with no own method). The dense array arms further
Expand Down
115 changes: 115 additions & 0 deletions crates/perry-runtime/src/url/abort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,121 @@ pub extern "C" fn js_abort_signal_remove_listener(
}
}

/// Bound-method thunk: `signal.addEventListener(type, listener[, options])`
/// reached through DYNAMIC property dispatch (receiver of unknown static
/// type). `options` is accepted and ignored — a signal only ever fires
/// "abort" once, so Node's `{ once: true }` is behaviorally implied.
extern "C" fn abort_signal_add_event_listener_thunk(
closure: *const crate::closure::ClosureHeader,
event_type: f64,
listener: f64,
_options: f64,
) -> f64 {
let signal_bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64;
let signal =
crate::value::js_nanbox_get_pointer(f64::from_bits(signal_bits)) as *mut ObjectHeader;
js_abort_signal_add_listener(signal, event_type, listener);
f64::from_bits(TAG_UNDEFINED_AC)
}

/// Bound-method thunk: `signal.removeEventListener(type, listener[, options])`.
extern "C" fn abort_signal_remove_event_listener_thunk(
closure: *const crate::closure::ClosureHeader,
event_type: f64,
listener: f64,
_options: f64,
) -> f64 {
let signal_bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64;
let signal =
crate::value::js_nanbox_get_pointer(f64::from_bits(signal_bits)) as *mut ObjectHeader;
js_abort_signal_remove_listener(signal, event_type, listener);
f64::from_bits(TAG_UNDEFINED_AC)
}

/// Bound-method thunk: `signal.throwIfAborted()`.
extern "C" fn abort_signal_throw_if_aborted_thunk(
closure: *const crate::closure::ClosureHeader,
) -> f64 {
let signal_bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64;
let signal =
crate::value::js_nanbox_get_pointer(f64::from_bits(signal_bits)) as *mut ObjectHeader;
js_abort_signal_throw_if_aborted(signal)
}

/// Dynamic method dispatch for AbortSignal instances (issue class of #5964's
/// URLSearchParams wall): a DIRECT `signal.addEventListener(...)` on a
/// statically-known receiver lowers to the native call, but the same method
/// read through a dynamically-typed receiver (`const s: any = c.signal;
/// s.addEventListener(...)` — the shape minified SDK code takes) fell through
/// to the generic property-bag walk, returned `undefined`, and the call threw
/// `addEventListener is not a function`. Returns a bound-method closure for
/// the known method names, `None` for everything else. The signal is captured
/// as its NaN-boxed bits (the `aborted_resolve_listener` idiom) so the GC's
/// closure scan keeps it alive and relocates it.
pub(crate) fn abort_signal_method_bind(signal: *mut ObjectHeader, name: &[u8]) -> Option<f64> {
let (fp, arity): (*const u8, u32) = match name {
b"addEventListener" => (abort_signal_add_event_listener_thunk as *const u8, 3),
b"removeEventListener" => (abort_signal_remove_event_listener_thunk as *const u8, 3),
b"throwIfAborted" => (abort_signal_throw_if_aborted_thunk as *const u8, 0),
_ => return None,
};
crate::closure::js_register_closure_arity(fp, arity);
let closure = crate::closure::js_closure_alloc(fp, 1);
let signal_f64 = f64::from_bits(crate::value::js_nanbox_pointer(signal as i64).to_bits());
crate::closure::js_closure_set_capture_ptr(closure, 0, signal_f64.to_bits() as i64);
Some(f64::from_bits(
crate::value::js_nanbox_pointer(closure as i64).to_bits(),
))
}

/// The signal's lazily-allocated "abort"-listener array (field 2), or `None`
/// when no listener was ever registered.
fn abort_listeners_array(signal: *mut ObjectHeader) -> Option<*mut crate::array::ArrayHeader> {
if signal.is_null() {
return None;
}
let bits = crate::object::js_object_get_field_f64(signal, 2).to_bits();
if (bits & 0xFFFF_0000_0000_0000) != POINTER_TAG_AC {
return None;
}
let arr = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut crate::array::ArrayHeader;
(!arr.is_null()).then_some(arr)
}

/// Number of registered "abort" listeners on `signal` (`0` when none,
/// including the lazily-unallocated state). `events.listenerCount(signal,
/// "abort")` parity — a signal only ever tracks "abort" listeners.
#[no_mangle]
pub extern "C" fn js_abort_signal_listener_count(signal: *mut ObjectHeader) -> f64 {
abort_listeners_array(signal).map_or(0.0, |arr| crate::array::js_array_length(arr) as f64)
}

/// Fresh array holding `signal`'s registered "abort" listeners (empty when
/// none). `events.getEventListeners(signal, "abort")` parity — a copy, so the
/// caller can't mutate the internal listener list through the return value.
#[no_mangle]
pub extern "C" fn js_abort_signal_listeners_copy(
signal: *mut ObjectHeader,
) -> *mut crate::array::ArrayHeader {
let Some(src) = abort_listeners_array(signal) else {
let empty = crate::array::js_array_alloc(0);
unsafe {
(*empty).length = 0;
}
return empty;
};
let len = crate::array::js_array_length(src);
let dst = crate::array::js_array_alloc(len);
unsafe {
(*dst).length = len;
}
for i in 0..len {
let v = crate::array::js_array_get_f64(src, i);
crate::array::js_array_set_f64_unchecked(dst, i, v);
}
dst
}

/// Build the `TimeoutError` DOMException that `AbortSignal.timeout(ms)` aborts
/// with when its deadline elapses (Node names it `TimeoutError`, distinct from
/// the `AbortError` used by `controller.abort()`).
Expand Down
36 changes: 36 additions & 0 deletions crates/perry-stdlib/src/events/module_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ pub unsafe extern "C" fn js_events_get_event_listeners(
target_value: f64,
event_name_ptr: *const StringHeader,
) -> *mut ArrayHeader {
// AbortSignal is an EventTarget in Node, but Perry represents it as its
// own native object (url/abort.rs) that `event_helper_target` doesn't
// recognize. A signal only ever tracks "abort" listeners.
let signal_ptr = perry_runtime::url::abort::js_abort_signal_resolve_ptr(target_value);
if !signal_ptr.is_null() {
if string_from_header(event_name_ptr).as_deref() == Some("abort") {
return perry_runtime::url::abort::js_abort_signal_listeners_copy(signal_ptr);
}
return js_array_alloc(0);
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand Down Expand Up @@ -110,6 +120,14 @@ pub unsafe extern "C" fn js_events_listener_count(
target_value: f64,
event_name_ptr: *const StringHeader,
) -> f64 {
// AbortSignal: see `js_events_get_event_listeners`.
let signal_ptr = perry_runtime::url::abort::js_abort_signal_resolve_ptr(target_value);
if !signal_ptr.is_null() {
if string_from_header(event_name_ptr).as_deref() == Some("abort") {
return perry_runtime::url::abort::js_abort_signal_listener_count(signal_ptr);
}
return 0.0;
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand All @@ -133,6 +151,12 @@ pub unsafe extern "C" fn js_events_listener_count(
/// `events.getMaxListeners(emitter)` — alias.
#[no_mangle]
pub unsafe extern "C" fn js_events_get_max_listeners(target_value: f64) -> f64 {
// AbortSignal: Node's default EventTarget listener cap. Perry stores no
// per-signal override (`setMaxListeners` below is an accepted no-op), so
// the default is always reported.
if !perry_runtime::url::abort::js_abort_signal_resolve_ptr(target_value).is_null() {
return 10.0;
}
match event_helper_target(target_value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"emitter",
Expand Down Expand Up @@ -163,6 +187,18 @@ pub unsafe extern "C" fn js_events_set_max_listeners(
let len = js_array_length(handles_ptr);
for i in 0..len {
let value = perry_runtime::array::js_array_get_f64(handles_ptr, i);
// AbortSignal is an EventTarget in Node — SDKs routinely call
// `events.setMaxListeners(n, controller.signal)` to raise the
// MaxListenersExceededWarning threshold on a shared signal. Perry
// represents signals as their own native object that
// `event_helper_target` doesn't recognize, so this threw
// ERR_INVALID_ARG_TYPE and rejected the caller's whole request
// path. Accept the signal; the warning threshold is the call's
// only Node-observable effect and Perry never emits that warning
// for signals, so accepting is a faithful no-op.
if !perry_runtime::url::abort::js_abort_signal_resolve_ptr(value).is_null() {
continue;
}
match event_helper_target(value).unwrap_or_else(|| {
throw_invalid_arg_type(&invalid_instance_arg_message(
"eventTargets",
Expand Down
Loading
Loading