From 906c5212ead890b427eb795756ec1746d0bc03bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 21:54:29 +0000 Subject: [PATCH 1/6] fix(runtime): store event state in symbol properties --- changelog.d/honest-eventtarget.md | 1 + crates/perry-codegen/src/expr/index_get.rs | 81 +- .../src/expr/index_get/symbol_ic.rs | 98 ++ .../perry-codegen/src/expr/instance_misc1.rs | 8 + .../src/expr/property_get/composed_ics.rs | 22 +- .../perry-codegen/src/expr/this_super_call.rs | 1 - .../src/lower_call/event_target.rs | 79 -- crates/perry-codegen/src/lower_call/mod.rs | 1 - .../src/lower_call/new_helpers.rs | 20 + .../src/lower_call/options/abort.rs | 151 --- .../src/lower_call/options/mod.rs | 3 - .../lower_call/property_get/fetch_chain.rs | 22 +- .../src/runtime_decls/objects.rs | 1 + .../perry-codegen/src/wasm32/runtime_abi.tsv | 1 + .../src/lower/expr_member/member_tail.rs | 14 +- crates/perry-runtime/src/array/subclass.rs | 3 + crates/perry-runtime/src/error.rs | 11 +- crates/perry-runtime/src/event_target.rs | 978 +++++++----------- .../src/event_target/honest_tests.rs | 132 +++ .../src/event_target/prototype.rs | 465 +++++++++ .../perry-runtime/src/event_target/state.rs | 220 ++++ .../src/gc/tests/copying_side_tables.rs | 3 +- .../src/gc/tests/dead_owner_side_tables.rs | 28 +- .../src/gc/tests/event_internal_slots.rs | 108 ++ crates/perry-runtime/src/gc/tests/mod.rs | 2 + .../runtime_roots/side_table_scanners.rs | 13 +- .../src/hot_diag/receiver_repr.rs | 15 +- crates/perry-runtime/src/json/replacer.rs | 12 +- .../src/json/stringify_object.rs | 2 +- .../src/json/stringify_primitive_object.rs | 2 +- crates/perry-runtime/src/native_class_ids.rs | 20 +- .../src/node_submodules/diagnostics_gc.rs | 4 - .../src/object/class_constructors.rs | 49 + .../class_registry/prototype_objects.rs | 11 + .../src/object/class_registry/state.rs | 14 +- .../perry-runtime/src/object/descriptors.rs | 6 +- .../src/object/field_get_set/accessors.rs | 25 +- .../src/object/field_get_set/enumeration.rs | 3 +- .../field_get_set/get_field_by_name_tail.rs | 43 +- .../src/object/global_this/fetch_globals.rs | 40 +- .../src/object/global_this/populate.rs | 14 +- .../src/object/global_this/proto_methods.rs | 86 +- .../src/object/instanceof/static_dispatch.rs | 44 +- crates/perry-runtime/src/object/mod.rs | 2 + .../src/object/native_call_method.rs | 68 -- .../src/object/shaped_symbols.rs | 273 +++++ .../perry-runtime/src/object/to_string_tag.rs | 29 +- crates/perry-runtime/src/symbol.rs | 8 + crates/perry-runtime/src/symbol/accessors.rs | 37 + crates/perry-runtime/src/symbol/get.rs | 54 +- crates/perry-runtime/src/symbol/iterator.rs | 5 + crates/perry-runtime/src/symbol/properties.rs | 40 + crates/perry-runtime/src/thread.rs | 10 + crates/perry-runtime/src/url/abort.rs | 509 +++------ .../symbols_tests.rs | 4 +- scripts/addr_class_allowlist.txt | 1 - scripts/addr_class_ratchet_baseline.txt | 3 +- scripts/local_binding_type_allowlist.json | 16 - scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 2 +- scripts/thread_exit_address_globals.json | 9 - test-files/test_gap_honest_eventtarget.ts | 207 ++++ .../test_gap_honest_eventtarget_integrity.ts | 30 + .../test_gap_honest_symbol_consumers.ts | 58 ++ test-files/test_gap_honest_symbol_shapes.ts | 28 + 65 files changed, 2607 insertions(+), 1644 deletions(-) create mode 100644 changelog.d/honest-eventtarget.md create mode 100644 crates/perry-codegen/src/expr/index_get/symbol_ic.rs delete mode 100644 crates/perry-codegen/src/lower_call/event_target.rs delete mode 100644 crates/perry-codegen/src/lower_call/options/abort.rs create mode 100644 crates/perry-runtime/src/event_target/honest_tests.rs create mode 100644 crates/perry-runtime/src/event_target/prototype.rs create mode 100644 crates/perry-runtime/src/event_target/state.rs create mode 100644 crates/perry-runtime/src/gc/tests/event_internal_slots.rs create mode 100644 crates/perry-runtime/src/object/shaped_symbols.rs create mode 100644 test-files/test_gap_honest_eventtarget.ts create mode 100644 test-files/test_gap_honest_eventtarget_integrity.ts create mode 100644 test-files/test_gap_honest_symbol_consumers.ts create mode 100644 test-files/test_gap_honest_symbol_shapes.ts diff --git a/changelog.d/honest-eventtarget.md b/changelog.d/honest-eventtarget.md new file mode 100644 index 00000000000..392051c0b42 --- /dev/null +++ b/changelog.d/honest-eventtarget.md @@ -0,0 +1 @@ +EventTarget, Event, CustomEvent, AbortController and AbortSignal use shared prototype methods and accessors, with state in ordinary symbol-keyed shape properties. Reflect exposed symbols like Node and keep private state hidden. Preserve DOMException Error semantics, subclass prototype inheritance and named worker-transfer rejection. diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 0a33a6e28b0..9596756fe67 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -28,7 +28,7 @@ use crate::native_value::{ }; use crate::rooting; use crate::type_analysis::{is_array_expr, is_numeric_expr, is_string_expr, receiver_class_name}; -use crate::types::{DOUBLE, I1, I16, I32, I64, I8, PTR}; +use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; use super::{ array_kind_fact, attach_buffer_view_pointer_state_for_expr, @@ -58,81 +58,8 @@ use guarded_array::{ }; use inline_dyn_typed_array::lower_inline_dyn_typed_array_get; -/// Emit a weak monomorphic IC for an exact own Symbol-keyed data property. -/// -/// The cache stores raw bits, not roots. Its epoch is advanced by every -/// Symbol-property mutation and completed GC, so a moved/reclaimed receiver or -/// value cannot hit and the cache cannot keep otherwise-dead objects alive. -pub(crate) fn lower_symbol_property_get_ic( - ctx: &mut FnCtx<'_>, - obj_box: &str, - sym_box: &str, -) -> String { - let site_id = ctx.ic_site_counter; - ctx.ic_site_counter += 1; - let cache_name = super::inline_cache_global_name(ctx, site_id); - ctx.ic_globals.push(cache_name.clone()); - - let probe_idx = ctx.new_block("symic.probe"); - let hit_idx = ctx.new_block("symic.hit"); - let miss_idx = ctx.new_block("symic.miss"); - let merge_idx = ctx.new_block("symic.merge"); - let probe_label = ctx.block_label(probe_idx); - let hit_label = ctx.block_label(hit_idx); - let miss_label = ctx.block_label(miss_idx); - let merge_label = ctx.block_label(merge_idx); - - // #9708: the cache sits behind a pointer slot that the miss handler fills - // on the first prime. The probe's three loads go through the pointer, so - // an absent cache branches straight to the miss — the edge a fresh - // (all-zero) global took anyway, since a zero epoch never matches. - let ic_slot = super::emit_inline_cache_slot(ctx, &cache_name); - let cache_ref = ic_slot.cache.clone(); - let cache_slot_ref = ic_slot.slot_ref.clone(); - ctx.block() - .cond_br(&ic_slot.present, &probe_label, &miss_label); - - ctx.current_block = probe_idx; - let epoch = ctx - .block() - .load_atomic_acquire(I64, "@PERRY_SYMBOL_PROPERTY_IC_EPOCH", 8); - let cached_epoch_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); - let cached_epoch = ctx.block().load_atomic_acquire(I64, &cached_epoch_ptr, 8); - let epoch_matches = ctx.block().icmp_eq(I64, &epoch, &cached_epoch); - let obj_bits = ctx.block().bitcast_double_to_i64(obj_box); - let cached_obj_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "1")]); - let cached_obj = ctx.block().load(I64, &cached_obj_ptr); - let obj_matches = ctx.block().icmp_eq(I64, &obj_bits, &cached_obj); - let sym_bits = ctx.block().bitcast_double_to_i64(sym_box); - let cached_sym_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "2")]); - let cached_sym = ctx.block().load(I64, &cached_sym_ptr); - let sym_matches = ctx.block().icmp_eq(I64, &sym_bits, &cached_sym); - let identity_matches = ctx.block().and(I1, &obj_matches, &sym_matches); - let hit = ctx.block().and(I1, &epoch_matches, &identity_matches); - ctx.block().cond_br(&hit, &hit_label, &miss_label); - - ctx.current_block = hit_idx; - let cached_value_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "3")]); - let cached_value_bits = ctx.block().load(I64, &cached_value_ptr); - let cached_value = ctx.block().bitcast_i64_to_double(&cached_value_bits); - let hit_end = ctx.block().label.clone(); - ctx.block().br(&merge_label); - - ctx.current_block = miss_idx; - let miss_value = ctx.block().call( - DOUBLE, - "js_object_get_symbol_property_ic_miss", - &[(DOUBLE, obj_box), (DOUBLE, sym_box), (PTR, &cache_slot_ref)], - ); - let miss_end = ctx.block().label.clone(); - ctx.block().br(&merge_label); - - ctx.current_block = merge_idx; - ctx.block().phi( - DOUBLE, - &[(&cached_value, &hit_end), (&miss_value, &miss_end)], - ) -} +mod symbol_ic; +pub(crate) use symbol_ic::lower_symbol_property_get_ic; /// #7494: deliberately `static_type_of`, not `receiver_class_name`. /// @@ -1878,7 +1805,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let num_lbl = ctx.block_label(num_idx); let merge_lbl = ctx.block_label(merge_idx); ctx.block().cond_br(&is_sym_bit, &sym_lbl, &nonsym_lbl); - // Symbol key → side-table get. + // Symbol key → own-shape IC or the canonical property resolver. ctx.current_block = sym_idx; let v_sym = lower_symbol_property_get_ic(ctx, &obj_box, &idx_box); let sym_end_lbl = ctx.block().label.clone(); diff --git a/crates/perry-codegen/src/expr/index_get/symbol_ic.rs b/crates/perry-codegen/src/expr/index_get/symbol_ic.rs new file mode 100644 index 00000000000..a3965acd2e4 --- /dev/null +++ b/crates/perry-codegen/src/expr/index_get/symbol_ic.rs @@ -0,0 +1,98 @@ +//! Shape-guarded own Symbol property loads. +use crate::expr::FnCtx; +use crate::nanbox::POINTER_MASK_I64; +use crate::types::{DOUBLE, I1, I32, I64, PTR}; + +/// Emit a weak monomorphic IC for an exact own Symbol-keyed data property. +/// +/// The cache stores raw bits, not roots. Its epoch is advanced by every +/// Symbol-property mutation and completed GC, so a moved/reclaimed receiver or +/// value cannot hit and the cache cannot keep otherwise-dead objects alive. +pub(crate) fn lower_symbol_property_get_ic( + ctx: &mut FnCtx<'_>, + obj_box: &str, + sym_box: &str, +) -> String { + let site_id = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let cache_name = crate::expr::inline_cache_global_name(ctx, site_id); + ctx.ic_globals.push(cache_name.clone()); + + let probe_idx = ctx.new_block("symic.probe"); + let shape_idx = ctx.new_block("symic.shape"); + let shape_label = ctx.block_label(shape_idx); + let hit_idx = ctx.new_block("symic.hit"); + let miss_idx = ctx.new_block("symic.miss"); + let merge_idx = ctx.new_block("symic.merge"); + let probe_label = ctx.block_label(probe_idx); + let hit_label = ctx.block_label(hit_idx); + let miss_label = ctx.block_label(miss_idx); + let merge_label = ctx.block_label(merge_idx); + + // #9708: the cache sits behind a pointer slot that the miss handler fills + // on the first prime. The probe's three loads go through the pointer, so + // an absent cache branches straight to the miss — the edge a fresh + // (all-zero) global took anyway, since a zero epoch never matches. + let ic_slot = crate::expr::emit_inline_cache_slot(ctx, &cache_name); + let cache_ref = ic_slot.cache.clone(); + let cache_slot_ref = ic_slot.slot_ref.clone(); + ctx.block() + .cond_br(&ic_slot.present, &probe_label, &miss_label); + + ctx.current_block = probe_idx; + let epoch = ctx + .block() + .load_atomic_acquire(I64, "@PERRY_SYMBOL_PROPERTY_IC_EPOCH", 8); + let cached_epoch_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); + let cached_epoch = ctx.block().load_atomic_acquire(I64, &cached_epoch_ptr, 8); + let epoch_matches = ctx.block().icmp_eq(I64, &epoch, &cached_epoch); + let obj_bits = ctx.block().bitcast_double_to_i64(obj_box); + let cached_obj_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "1")]); + let cached_obj = ctx.block().load(I64, &cached_obj_ptr); + let obj_matches = ctx.block().icmp_eq(I64, &obj_bits, &cached_obj); + let sym_bits = ctx.block().bitcast_double_to_i64(sym_box); + let cached_sym_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "2")]); + let cached_sym = ctx.block().load(I64, &cached_sym_ptr); + let sym_matches = ctx.block().icmp_eq(I64, &sym_bits, &cached_sym); + let identity_matches = ctx.block().and(I1, &obj_matches, &sym_matches); + let hit = ctx.block().and(I1, &epoch_matches, &identity_matches); + ctx.block().cond_br(&hit, &shape_label, &miss_label); + + ctx.current_block = shape_idx; + let raw = ctx.block().and(I64, &obj_bits, POINTER_MASK_I64); + let shape_addr = ctx.block().add(I64, &raw, "4"); + let shape_ptr = ctx.block().inttoptr(I64, &shape_addr); + let shape = ctx.block().load(I32, &shape_ptr); + let shape = ctx.block().zext(I32, &shape, I64); + let entry_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "3")]); + let entry = ctx.block().load(I64, &entry_ptr); + let cached_shape = ctx.block().lshr(I64, &entry, "32"); + let shape_matches = ctx.block().icmp_eq(I64, &shape, &cached_shape); + ctx.block().cond_br(&shape_matches, &hit_label, &miss_label); + + ctx.current_block = hit_idx; + let slot = ctx.block().and(I64, &entry, "4294967295"); + let offset = ctx.block().shl(I64, &slot, "3"); + let fields = ctx.block().add(I64, &raw, "16"); + let addr = ctx.block().add(I64, &fields, &offset); + let cached_value_ptr = ctx.block().inttoptr(I64, &addr); + let cached_value_bits = ctx.block().load(I64, &cached_value_ptr); + let cached_value = ctx.block().bitcast_i64_to_double(&cached_value_bits); + let hit_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = miss_idx; + let miss_value = ctx.block().call( + DOUBLE, + "js_object_get_symbol_property_ic_miss", + &[(DOUBLE, obj_box), (DOUBLE, sym_box), (PTR, &cache_slot_ref)], + ); + let miss_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block().phi( + DOUBLE, + &[(&cached_value, &hit_end), (&miss_value, &miss_end)], + ) +} diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index eb411d6e0c0..cc9f7bc5433 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -123,6 +123,12 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { "BigUint64Array" => 0xFFFF003A, "Function" => 0xFFFF00F0, "URL" => 0xFFFF0063, + "EventTarget" => 0xFFFF2406, + "Event" => 0xFFFF2403, + "CustomEvent" => 0xFFFF2404, + "AbortController" => 0xFFFF2401, + "AbortSignal" => 0xFFFF2402, + "DOMException" => 0xFFFF2405, // #10556: `class Sub extends EventEmitter {}` — same shape as the // Array/Map/Set/Error builtins above. Without this edge, // `new Sub() instanceof EventEmitter` never reaches the class-chain @@ -588,6 +594,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Temporal.PlainYearMonth" => 0xFFFF0205u32, "Temporal.PlainMonthDay" => 0xFFFF0206u32, "Temporal.ZonedDateTime" => 0xFFFF0207u32, + "AbortController" | "globalThis.AbortController" => 0xFFFF2401u32, + "AbortSignal" | "globalThis.AbortSignal" => 0xFFFF2402u32, "Event" | "globalThis.Event" => 0xFFFF2403u32, "CustomEvent" | "globalThis.CustomEvent" => 0xFFFF2404u32, "DOMException" | "globalThis.DOMException" => 0xFFFF2405u32, diff --git a/crates/perry-codegen/src/expr/property_get/composed_ics.rs b/crates/perry-codegen/src/expr/property_get/composed_ics.rs index 38ecc9ecdb0..d59112af7e4 100644 --- a/crates/perry-codegen/src/expr/property_get/composed_ics.rs +++ b/crates/perry-codegen/src/expr/property_get/composed_ics.rs @@ -9,7 +9,7 @@ use super::*; /// Fuse `base[provenSymbol].field` into one weak identity/epoch guard followed /// by one exact ShapeId guard and direct slot load. /// -/// The ordinary Symbol IC already proves that its cached intermediate value is +/// The ordinary Symbol IC already proves that its indexed intermediate value is /// the current own Symbol data property and invalidates on every Symbol write /// or completed GC. A second ordinary property PIC currently throws that fact /// away and repeats receiver tag, GC-header, descriptor, ShapeId, and dispatch @@ -52,6 +52,8 @@ pub(super) fn lower_symbol_then_named_property_ic( let probe_idx = ctx.new_block("symfield.probe"); let identity_idx = ctx.new_block("symfield.identity"); + let slot_idx = ctx.new_block("symfield.slot"); + let slot_label = ctx.block_label(slot_idx); let hit_idx = ctx.new_block("symfield.hit"); let live_idx = ctx.new_block("symfield.live"); let miss_idx = ctx.new_block("symfield.miss"); @@ -102,7 +104,23 @@ pub(super) fn lower_symbol_then_named_property_ic( // first. Named-property mutations do not, so independently validate // the intermediate object's live ShapeId and descriptor latch. ctx.current_block = identity_idx; - let intermediate_ptr = ctx.block().gep(I64, &symbol_cache, &[(I64, "3")]); + let base_raw = ctx.block().and(I64, &base_bits, POINTER_MASK_I64); + let base_shape_addr = ctx.block().add(I64, &base_raw, "4"); + let base_shape_ptr = ctx.block().inttoptr(I64, &base_shape_addr); + let base_shape = ctx.block().load(I32, &base_shape_ptr); + let base_shape = ctx.block().zext(I32, &base_shape, I64); + let entry_ptr = ctx.block().gep(I64, &symbol_cache, &[(I64, "3")]); + let entry = ctx.block().load(I64, &entry_ptr); + let cached_shape = ctx.block().lshr(I64, &entry, "32"); + let shape_matches = ctx.block().icmp_eq(I64, &base_shape, &cached_shape); + ctx.block() + .cond_br(&shape_matches, &slot_label, &miss_label); + ctx.current_block = slot_idx; + let slot = ctx.block().and(I64, &entry, "4294967295"); + let offset = ctx.block().shl(I64, &slot, "3"); + let fields = ctx.block().add(I64, &base_raw, "16"); + let addr = ctx.block().add(I64, &fields, &offset); + let intermediate_ptr = ctx.block().inttoptr(I64, &addr); let intermediate_bits = ctx.block().load(I64, &intermediate_ptr); let intermediate_handle = ctx.block().and(I64, &intermediate_bits, POINTER_MASK_I64); let descriptor_addr = ctx.block().sub(I64, &intermediate_handle, "6"); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 4e144085f20..a6acda88380 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -141,7 +141,6 @@ pub(crate) fn is_other_builtin_constructor_name(name: &str) -> bool { | "Set" | "WeakMap" | "WeakSet" - | "EventTarget" | "Array" | "ArrayBuffer" | "SharedArrayBuffer" diff --git a/crates/perry-codegen/src/lower_call/event_target.rs b/crates/perry-codegen/src/lower_call/event_target.rs deleted file mode 100644 index d7a026f1c8d..00000000000 --- a/crates/perry-codegen/src/lower_call/event_target.rs +++ /dev/null @@ -1,79 +0,0 @@ -use anyhow::Result; -use perry_hir::Expr; - -use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; -use crate::nanbox::double_literal; -use crate::type_analysis::receiver_class_name; -use crate::types::{DOUBLE, I64}; - -fn is_event_target_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { - matches!(receiver_class_name(ctx, e).as_deref(), Some("EventTarget")) -} - -pub(super) fn lower_event_target_call( - ctx: &mut FnCtx<'_>, - object: &Expr, - property: &str, - args: &[Expr], -) -> Result> { - if !is_event_target_expr(ctx, object) { - return Ok(None); - } - if (property == "addEventListener" || property == "removeEventListener") && args.len() >= 2 { - let target_box = lower_expr(ctx, object)?; - let event_box = lower_expr(ctx, &args[0])?; - let listener_box = lower_expr(ctx, &args[1])?; - let options_box = if let Some(options) = args.get(2) { - lower_expr(ctx, options)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(3) { - let _ = lower_expr(ctx, a)?; - } - let blk = ctx.block(); - let target = unbox_to_i64(blk, &target_box); - let event = blk.call( - I64, - "js_get_string_pointer_unified", - &[(DOUBLE, &event_box)], - ); - let listener = unbox_to_i64(blk, &listener_box); - let runtime = if property == "addEventListener" { - "js_event_target_add_event_listener_with_options" - } else { - "js_event_target_remove_event_listener_with_options" - }; - blk.call_void( - runtime, - &[ - (I64, &target), - (I64, &event), - (I64, &listener), - (DOUBLE, &options_box), - ], - ); - return Ok(Some(double_literal(f64::from_bits( - crate::nanbox::TAG_UNDEFINED, - )))); - } - if property == "dispatchEvent" { - let target_box = lower_expr(ctx, object)?; - let event_box = if let Some(event) = args.first() { - lower_expr(ctx, event)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } - let blk = ctx.block(); - let target = unbox_to_i64(blk, &target_box); - return Ok(Some(blk.call( - DOUBLE, - "js_event_target_dispatch_event", - &[(I64, &target), (DOUBLE, &event_box)], - ))); - } - Ok(None) -} diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index d3e3513408a..bf31b981786 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -62,7 +62,6 @@ mod ctor_prologue_stores; mod ctor_return_publish_tests; mod dataview_intrinsic; mod early_branches; -mod event_target; mod extern_func; mod extern_timers; mod field_init; diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 68e515bed6c..25aef536ca5 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -83,6 +83,9 @@ pub(crate) enum NativeInstanceBase { Set, WeakMap, WeakSet, + EventTarget, + AbortController, + AbortSignal, Event, CustomEvent, DomException, @@ -108,6 +111,9 @@ pub(crate) fn native_instance_base(name: &str) -> Option { "Set" => Some(NativeInstanceBase::Set), "WeakMap" => Some(NativeInstanceBase::WeakMap), "WeakSet" => Some(NativeInstanceBase::WeakSet), + "EventTarget" => Some(NativeInstanceBase::EventTarget), + "AbortController" => Some(NativeInstanceBase::AbortController), + "AbortSignal" => Some(NativeInstanceBase::AbortSignal), "Event" => Some(NativeInstanceBase::Event), "CustomEvent" => Some(NativeInstanceBase::CustomEvent), "DOMException" => Some(NativeInstanceBase::DomException), @@ -320,6 +326,20 @@ pub(crate) fn emit_native_instance_base_init( ], ); } + NativeInstanceBase::EventTarget + | NativeInstanceBase::AbortController + | NativeInstanceBase::AbortSignal => { + let kind = match base { + NativeInstanceBase::EventTarget => "0", + NativeInstanceBase::AbortController => "1", + _ => "2", + }; + ctx.block().call( + DOUBLE, + "js_event_target_subclass_init", + &[(DOUBLE, this_box), (I32, kind)], + ); + } NativeInstanceBase::Event | NativeInstanceBase::CustomEvent => { let arg0 = lowered_args .first() diff --git a/crates/perry-codegen/src/lower_call/options/abort.rs b/crates/perry-codegen/src/lower_call/options/abort.rs deleted file mode 100644 index 831ea3f65f7..00000000000 --- a/crates/perry-codegen/src/lower_call/options/abort.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! `AbortController` / `AbortSignal` method-call lowering. -//! -//! Extracted from `lower_call.rs` (#1099, part of #1097) — pure move, -//! no behavior change. `is_abort_controller_expr` is the static-type -//! probe; `lower_abort_controller_call` handles `controller.abort(...)`, -//! `controller.signal.addEventListener("abort", cb)`, and -//! `AbortSignal.timeout(ms)`. - -use anyhow::Result; -use perry_hir::types::Type as HirType; -use perry_hir::Expr; - -use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; -use crate::nanbox::double_literal; -use crate::types::{DOUBLE, I64}; - -/// Returns `true` if the expression statically resolves to an -/// `AbortController`-typed value (either a local whose declared type -/// is `Named("AbortController")` or a `new AbortController()` call). -pub(in crate::lower_call) fn is_abort_controller_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { - match e { - Expr::New { class_name, .. } => class_name == "AbortController", - Expr::LocalGet(id) => matches!( - ctx.stable_local_type_proof(id), - Some(HirType::Named(n)) if n == "AbortController" - ), - _ => false, - } -} - -/// Returns `true` if the expression statically resolves to an -/// `AbortSignal`-typed value: a local declared `Named("AbortSignal")`. -pub(in crate::lower_call) fn is_abort_signal_typed_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { - matches!( - e, - Expr::LocalGet(id) - if matches!(ctx.stable_local_type_proof(id), Some(HirType::Named(n)) if n == "AbortSignal") - ) -} - -/// Lower AbortController / AbortSignal method calls: -/// - `controller.abort(reason?)` -/// - `controller.signal.addEventListener("abort", cb)` -/// - `controller.signal.throwIfAborted()` / `signal.throwIfAborted()` -/// - `AbortSignal.timeout(ms)` (static) -/// -/// Returns `None` if the call shape doesn't match one of the handled -/// patterns — caller falls through to the generic dispatch. -pub(in crate::lower_call) fn lower_abort_controller_call( - ctx: &mut FnCtx<'_>, - object: &Expr, - property: &str, - args: &[Expr], -) -> Result> { - // ── controller.signal.throwIfAborted() / signal.throwIfAborted() ── - if property == "throwIfAborted" { - // Case 1: `.signal.throwIfAborted()` — derive the signal - // pointer from the controller. - if let Expr::PropertyGet { - object: inner_obj, - property: inner_prop, - .. - } = object - { - if inner_prop == "signal" && is_abort_controller_expr(ctx, inner_obj) { - let ctrl_box = lower_expr(ctx, inner_obj)?; - let blk = ctx.block(); - let ctrl_handle = unbox_to_i64(blk, &ctrl_box); - let signal_handle = - blk.call(I64, "js_abort_controller_signal", &[(I64, &ctrl_handle)]); - blk.call_void("js_abort_signal_throw_if_aborted", &[(I64, &signal_handle)]); - return Ok(Some(double_literal(f64::from_bits( - crate::nanbox::TAG_UNDEFINED, - )))); - } - } - // Case 2: receiver is itself an AbortSignal value (a local typed - // `AbortSignal`, or the result of `AbortSignal.abort/timeout/any`). - if is_abort_signal_typed_expr(ctx, object) { - let sig_box = lower_expr(ctx, object)?; - let blk = ctx.block(); - let sig_handle = unbox_to_i64(blk, &sig_box); - blk.call_void("js_abort_signal_throw_if_aborted", &[(I64, &sig_handle)]); - return Ok(Some(double_literal(f64::from_bits( - crate::nanbox::TAG_UNDEFINED, - )))); - } - } - // ── AbortSignal.timeout(ms) static ── - if property == "timeout" { - if let Expr::GlobalGet(_) = object { - // Can't distinguish AbortSignal.timeout from other globals - // without more context — skip. - } - } - // Static `AbortSignal.timeout(ms)` — matched via a PropertyGet on a - // GlobalGet-shaped object isn't quite right because GlobalGet has - // no name; best we can do is detect by property name "timeout" and - // the local-isn't-a-known-thing. Skip for now. - - // ── controller.abort(reason?) ── - if property == "abort" && is_abort_controller_expr(ctx, object) { - let recv_box = lower_expr(ctx, object)?; - let blk = ctx.block(); - let ctrl_handle = unbox_to_i64(blk, &recv_box); - if args.is_empty() { - blk.call_void("js_abort_controller_abort", &[(I64, &ctrl_handle)]); - } else { - let reason = lower_expr(ctx, &args[0])?; - let blk = ctx.block(); - blk.call_void( - "js_abort_controller_abort_reason", - &[(I64, &ctrl_handle), (DOUBLE, &reason)], - ); - } - return Ok(Some(double_literal(f64::from_bits( - crate::nanbox::TAG_UNDEFINED, - )))); - } - - // ── controller.signal.addEventListener("abort", cb) ── - if property == "addEventListener" && args.len() >= 2 { - if let Expr::PropertyGet { - object: inner_obj, - property: inner_prop, - .. - } = object - { - if inner_prop == "signal" && is_abort_controller_expr(ctx, inner_obj) { - let ctrl_box = lower_expr(ctx, inner_obj)?; - let blk = ctx.block(); - let ctrl_handle = unbox_to_i64(blk, &ctrl_box); - // Get the signal pointer. - let signal_handle = - blk.call(I64, "js_abort_controller_signal", &[(I64, &ctrl_handle)]); - let evt = lower_expr(ctx, &args[0])?; - let listener = lower_expr(ctx, &args[1])?; - let blk = ctx.block(); - blk.call_void( - "js_abort_signal_add_listener", - &[(I64, &signal_handle), (DOUBLE, &evt), (DOUBLE, &listener)], - ); - return Ok(Some(double_literal(f64::from_bits( - crate::nanbox::TAG_UNDEFINED, - )))); - } - } - } - - Ok(None) -} diff --git a/crates/perry-codegen/src/lower_call/options/mod.rs b/crates/perry-codegen/src/lower_call/options/mod.rs index 281d1d9fcbc..be94a04a432 100644 --- a/crates/perry-codegen/src/lower_call/options/mod.rs +++ b/crates/perry-codegen/src/lower_call/options/mod.rs @@ -9,7 +9,6 @@ //! sub-modules, split by API at clean function boundaries: //! //! - `notification.rs` — perry/system `notificationSchedule({...})` -//! - `abort.rs` — `AbortController` / `AbortSignal` //! - `fetch.rs` — Web Fetch family (fetch / axios / Headers / //! Request / Response / blob / readable_stream) //! @@ -24,10 +23,8 @@ use crate::expr::{lower_expr, FnCtx}; use crate::rooting::{with_rooted_group, Repr}; use crate::types::{DOUBLE, I64}; -mod abort; mod fetch; mod notification; -pub(in crate::lower_call) use abort::lower_abort_controller_call; pub(in crate::lower_call) use fetch::lower_fetch_native_method; pub(in crate::lower_call) use notification::lower_notification_schedule; diff --git a/crates/perry-codegen/src/lower_call/property_get/fetch_chain.rs b/crates/perry-codegen/src/lower_call/property_get/fetch_chain.rs index de1cd204f44..a6e6a1e21e1 100644 --- a/crates/perry-codegen/src/lower_call/property_get/fetch_chain.rs +++ b/crates/perry-codegen/src/lower_call/property_get/fetch_chain.rs @@ -1,6 +1,5 @@ -//! AbortController / AbortSignal / EventTarget dispatch + chained Web Fetch +//! Chained Web Fetch //! (`r.headers.get(k)`, `r.clone().status`, `new Response(...).text()`). -//! Pure code move from `property_get.rs` — no behavior change. use anyhow::Result; use perry_hir::Expr; @@ -10,32 +9,15 @@ use crate::expr::FnCtx; // Reach the dispatch helpers (`pub(in crate::lower_call)` / `pub(super)`) by // their canonical crate-relative paths — they live in sibling modules of the // `lower_call` parent. -use crate::lower_call::event_target::lower_event_target_call; -use crate::lower_call::options::lower_abort_controller_call; use crate::lower_call::options::lower_fetch_native_method; -/// AbortController / AbortSignal / EventTarget method calls, then chained Web -/// Fetch dispatch. Returns `Ok(Some(_))` when any of these claims the call. +/// Chained Web Fetch dispatch. Returns `Ok(Some(_))` when it claims the call. pub(crate) fn try_lower_fetch_chain( ctx: &mut FnCtx<'_>, object: &Expr, property: &str, args: &[Expr], ) -> Result> { - // ── AbortController / AbortSignal dispatch ── - // `new AbortController()` returns a NaN-boxed pointer - // (refined to `Named("AbortController")`). The runtime's - // ObjectHeader carries `signal` / `aborted` fields that the - // generic property-get path reads. Method calls need explicit - // interception because the class isn't in `ctx.classes`. - if let Some(val) = lower_abort_controller_call(ctx, object, property, args)? { - return Ok(Some(val)); - } - - if let Some(val) = lower_event_target_call(ctx, object, property, args)? { - return Ok(Some(val)); - } - // ── Chained Web Fetch dispatch ── // `r.headers.get(k)` — the inner `r.headers` lowered to a // NativeMethodCall that returns an f64 Headers handle; route diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 5228f32a4e9..60d4132f36b 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -99,6 +99,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.add_external_global("PERRY_TA_VIEW_GUARD", I64); module.add_external_global("PERRY_TA_OWN_PROPS_PRESENT", I8); module.declare_function("js_object_alloc", I64, &[I32, I32]); + module.declare_function("js_event_target_subclass_init", DOUBLE, &[DOUBLE, I32]); // #3149: `Object(value)` plain-call coercion. Takes & returns a NaN-boxed // JSValue (DOUBLE): nullish/primitive -> fresh {}, object passes through. module.declare_function("js_object_coerce", DOUBLE, &[DOUBLE]); diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 4a061bf5833..3025e46eb6b 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -1162,6 +1162,7 @@ js_event_target_new ptr js_event_target_remove_event_listener void ptr,ptr,i64 js_event_target_remove_event_listener_with_options void ptr,ptr,i64,f64 js_event_target_set_max_listeners i32s ptr,f64 +js_event_target_subclass_init f64 f64,i32u js_events_add_abort_listener i64 f64,f64 js_events_get_event_listeners ptr f64,ptr js_events_get_max_listeners f64 f64 diff --git a/crates/perry-hir/src/lower/expr_member/member_tail.rs b/crates/perry-hir/src/lower/expr_member/member_tail.rs index 3f6727b5996..65a8b87a6b4 100644 --- a/crates/perry-hir/src/lower/expr_member/member_tail.rs +++ b/crates/perry-hir/src/lower/expr_member/member_tail.rs @@ -481,7 +481,19 @@ pub(crate) fn lower_member_tail( ast::MemberProp::Computed(c) if !matches!(c.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(_))) ); - if !outer_is_prototype_or_proto + // Event-family constructors expose ordinary static properties. + // Keep their actual receiver for constants and inherited members. + let receiver_is_event_ctor = matches!( + property.as_str(), + "EventTarget" + | "Event" + | "CustomEvent" + | "AbortController" + | "AbortSignal" + | "DOMException" + ); + if !receiver_is_event_ctor + && !outer_is_prototype_or_proto && !outer_is_constructor_property && !receiver_is_namespace_value && !receiver_is_regexp_ctor diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 93bd6629a30..d0126760905 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -35,6 +35,9 @@ pub(super) use loop_guard::{js_packed_arraylike_loop_guard, js_packed_ecs_u32_lo // bit 1 payload valid // bit 2 compact nonnegative-int entity proof (mode 2) // bit 3 user-origin prototype signal +// bit 4 class-evaluation prototype +// bit 5 object is a prototype +// bit 6 exotic read receiver // bits 8..31 verified prefix bound (24 bits, max 16,000,000) // bits 32..63 exact semantic ShapeId const PACKED_NUMERIC_META_VALID: u64 = 1 << 1; diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 17158a0c164..96d5de11dbd 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -45,6 +45,12 @@ pub const ERROR_KIND_SYNTAX_ERROR: u32 = 4; pub const ERROR_KIND_AGGREGATE_ERROR: u32 = 5; pub const ERROR_KIND_EVAL_ERROR: u32 = 6; pub const ERROR_KIND_URI_ERROR: u32 = 7; +pub(crate) const ERROR_KIND_DOM_EXCEPTION: u32 = 8; + +pub(crate) unsafe fn mark_dom_exception(error: *mut ErrorHeader) { + (*error).error_kind = ERROR_KIND_DOM_EXCEPTION; + (*error).flags &= !ERROR_FLAG_HAS_MESSAGE; +} const ERROR_FLAG_HAS_MESSAGE: u32 = 1 << 0; const ERROR_FLAG_HAS_CAUSE: u32 = 1 << 1; @@ -75,6 +81,7 @@ pub(crate) fn error_kind_constructor_name(kind: u32) -> &'static str { ERROR_KIND_AGGREGATE_ERROR => "AggregateError", ERROR_KIND_EVAL_ERROR => "EvalError", ERROR_KIND_URI_ERROR => "URIError", + ERROR_KIND_DOM_EXCEPTION => "DOMException", _ => "Error", } } @@ -883,7 +890,9 @@ pub extern "C" fn js_error_is_error(value: f64) -> f64 { if ptr.is_null() || !crate::object::is_valid_obj_ptr(ptr) { return f64::from_bits(crate::value::TAG_FALSE); } - if ptr_is_native_error(ptr as usize) { + if ptr_is_native_error(ptr as usize) + || crate::event_target::is_dom_exception_object(ptr.cast()) + { return f64::from_bits(crate::value::TAG_TRUE); } } diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index b23257d5466..f7223f6c660 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -1,18 +1,13 @@ -//! Minimal WHATWG `EventTarget` storage used by Node's `events` helpers. -//! -//! Perry models `EventTarget` as a regular runtime object with hidden fields: -//! a marker, a listener-bag object keyed by event type, and a max-listener -//! number. Keeping the listener arrays in object fields lets the normal GC -//! trace callbacks without a separate native handle registry. +//! WHATWG event classes with shared prototypes and symbol-keyed own state. +//! Public and private state uses the ordinary property shape and GC barriers. use crate::{ js_array_alloc, js_array_get, js_array_length, js_array_push_f64, js_nanbox_pointer, - js_object_alloc, js_object_get_field_by_name, js_object_get_field_by_name_f64, - js_object_set_field_by_name, js_string_from_bytes, ArrayHeader, JSValue, ObjectHeader, - StringHeader, + js_object_alloc, js_object_get_field_by_name_f64, js_object_set_field_by_name, + js_string_from_bytes, ArrayHeader, JSValue, ObjectHeader, StringHeader, }; -use std::collections::HashSet; -use std::sync::{Mutex, OnceLock}; +pub(crate) mod prototype; +pub(crate) mod state; pub const CLASS_ID_EVENT: u32 = crate::native_class_ids::EVENT; pub const CLASS_ID_CUSTOM_EVENT: u32 = crate::native_class_ids::CUSTOM_EVENT; @@ -20,9 +15,9 @@ pub const CLASS_ID_DOM_EXCEPTION: u32 = crate::native_class_ids::DOM_EXCEPTION; /// `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. +/// The class chain selects native-base initialization for subclasses (#6301). +/// Instance branding follows the prototype constructor's EventTarget marker. +/// Keep in sync with perry-codegen/src/expr/instance_misc1.rs. pub const CLASS_ID_EVENT_TARGET: u32 = crate::native_class_ids::EVENT_TARGET; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; @@ -146,24 +141,15 @@ unsafe fn listener_signal(options: f64) -> Option<*mut ObjectHeader> { crate::url::abort::abort_signal_ptr_from_value(own_option_value(options, b"signal")) } -fn set_event_field(event: *mut ObjectHeader, name: &[u8], value: f64) { - let scope = crate::gc::RuntimeHandleScope::new(); - let event = scope.root_raw_mut_ptr(event); - let value = scope.root_nanbox_f64(value); - let field_key = key(name); - js_object_set_field_by_name(event.get_raw_mut_ptr(), field_key, value.get_nanbox_f64()); - crate::object::set_builtin_property_attrs( - event.get_raw_mut_ptr::() as usize, - String::from_utf8_lossy(name).into_owned(), - crate::object::PropertyAttrs::new(true, false, true), - ); +fn set_event_field(event: *mut ObjectHeader, name: u32, value: f64) { + state::set(event, name, value); } -fn event_bool_field(event: *mut ObjectHeader, name: &[u8]) -> bool { +fn event_bool_field(event: *mut ObjectHeader, name: u32) -> bool { if event.is_null() { return false; } - let value = js_object_get_field_by_name_f64(event, key(name)); + let value = state::get(event, name); crate::value::js_is_truthy(value) != 0 } @@ -172,8 +158,8 @@ extern "C" fn event_prevent_default_thunk(_closure: *const crate::closure::Closu let Some(event) = value_as_ptr::(this_value) else { return undefined_value(); }; - if event_bool_field(event, b"cancelable") { - set_event_field(event, b"defaultPrevented", bool_value(true)); + if event_bool_field(event, state::CANCELABLE) && !event_bool_field(event, state::PASSIVE) { + set_event_field(event, state::DEFAULT_PREVENTED, bool_value(true)); } undefined_value() } @@ -181,7 +167,8 @@ extern "C" fn event_prevent_default_thunk(_closure: *const crate::closure::Closu extern "C" fn event_stop_propagation_thunk(_closure: *const crate::closure::ClosureHeader) -> f64 { let this_value = crate::object::js_implicit_this_get(); if let Some(event) = value_as_ptr::(this_value) { - set_event_field(event, b"_stopped", bool_value(true)); + state::require_private(event, state::STOPPED); + set_event_field(event, state::STOPPED, bool_value(true)); } undefined_value() } @@ -191,32 +178,13 @@ extern "C" fn event_stop_immediate_propagation_thunk( ) -> f64 { let this_value = crate::object::js_implicit_this_get(); if let Some(event) = value_as_ptr::(this_value) { - set_event_field(event, b"_stopped", bool_value(true)); - set_event_field(event, b"_immediateStopped", bool_value(true)); + state::require_private(event, state::STOPPED); + set_event_field(event, state::STOPPED, bool_value(true)); + set_event_field(event, state::IMMEDIATE_STOPPED, bool_value(true)); } undefined_value() } -fn install_event_method( - event: *mut ObjectHeader, - name: &str, - func: extern "C" fn(*const crate::closure::ClosureHeader) -> f64, -) { - let func_ptr = func as *const u8; - let closure = crate::closure::js_closure_alloc(func_ptr, 0); - if closure.is_null() { - return; - } - crate::closure::js_register_closure_arity(func_ptr, 0); - let name_ptr = js_string_from_bytes(name.as_ptr(), name.len() as u32); - crate::closure::closure_set_dynamic_prop( - closure as usize, - "name", - crate::value::js_nanbox_string(name_ptr as i64), - ); - set_event_field(event, name.as_bytes(), boxed_ptr(closure)); -} - fn construct_event( type_value: f64, options: f64, @@ -224,12 +192,25 @@ fn construct_event( constructor_name: &[u8], detail: Option, ) -> *mut ObjectHeader { - let event = js_object_alloc(class_id, 0); - if event.is_null() { - return std::ptr::null_mut(); - } - init_event_fields(event, type_value, options, constructor_name, detail); - event + let scope = crate::gc::RuntimeHandleScope::new(); + let type_value = scope.root_nanbox_f64(type_value); + let options = scope.root_nanbox_f64(options); + let detail = detail.map(|value| scope.root_nanbox_f64(value)); + note_constructed(); + let event = scope.root_raw_mut_ptr(js_object_alloc(class_id, 0)); + let (_, result) = event.across_mut(|| { + event.with_mut_ptr(|ptr| state::link(ptr, std::str::from_utf8(constructor_name).unwrap())); + event.with_mut_ptr(|ptr| { + init_event_fields( + ptr, + type_value.get_nanbox_f64(), + options.get_nanbox_f64(), + constructor_name, + detail.map(|v| v.get_nanbox_f64()), + ); + }); + }); + result } /// Shared Event field/method initialization, applied either to a freshly @@ -240,55 +221,43 @@ fn init_event_fields( event: *mut ObjectHeader, type_value: f64, options: f64, - constructor_name: &[u8], + _constructor_name: &[u8], detail: Option, ) { + let scope = crate::gc::RuntimeHandleScope::new(); + let event = scope.root_raw_mut_ptr(event); + let options = scope.root_nanbox_f64(options); + let detail = detail.map(|value| scope.root_nanbox_f64(value)); let type_ptr = string_from_value(type_value); - set_event_field( - event, - b"type", - crate::value::js_nanbox_string(type_ptr as i64), - ); - unsafe { + event.with_mut_ptr(|ptr| { set_event_field( - event, - b"bubbles", - bool_value(option_bool(options, b"bubbles")), - ); - set_event_field( - event, - b"cancelable", - bool_value(option_bool(options, b"cancelable")), - ); - set_event_field( - event, - b"composed", - bool_value(option_bool(options, b"composed")), - ); + ptr, + state::TYPE, + crate::value::js_nanbox_string(type_ptr as i64), + ) + }); + for (name, slot) in [ + (b"bubbles".as_slice(), state::BUBBLES), + (b"cancelable", state::CANCELABLE), + (b"composed", state::COMPOSED), + ] { + let value = unsafe { option_bool(options.get_nanbox_f64(), name) }; + event.with_mut_ptr(|ptr| set_event_field(ptr, slot, bool_value(value))); + } + for (name, value) in [ + (state::DEFAULT_PREVENTED, bool_value(false)), + (state::TARGET, null_value()), + (state::TRUSTED, bool_value(false)), + (state::TIMESTAMP, number_value(0.0)), + (state::STOPPED, bool_value(false)), + (state::DISPATCHED, bool_value(false)), + (state::PASSIVE, bool_value(false)), + ] { + event.with_mut_ptr(|ptr| set_event_field(ptr, name, value)); } - set_event_field(event, b"defaultPrevented", bool_value(false)); - set_event_field(event, b"target", null_value()); - set_event_field(event, b"currentTarget", null_value()); - set_event_field(event, b"eventPhase", number_value(0.0)); - set_event_field(event, b"isTrusted", bool_value(false)); - set_event_field(event, b"timeStamp", number_value(0.0)); - set_event_field(event, b"_stopped", bool_value(false)); - set_event_field(event, b"_immediateStopped", bool_value(false)); if let Some(detail) = detail { - set_event_field(event, b"detail", detail); + event.with_mut_ptr(|ptr| set_event_field(ptr, state::DETAIL, detail.get_nanbox_f64())); } - let ctor = crate::object::js_get_global_this_builtin_value( - constructor_name.as_ptr(), - constructor_name.len(), - ); - set_event_field(event, b"constructor", ctor); - install_event_method(event, "preventDefault", event_prevent_default_thunk); - install_event_method(event, "stopPropagation", event_stop_propagation_thunk); - install_event_method( - event, - "stopImmediatePropagation", - event_stop_immediate_propagation_thunk, - ); } /// `super(type, options)` from a user `class X extends Event` / @@ -311,15 +280,31 @@ pub extern "C" fn js_event_subclass_init( if argc == 0 { throw_missing_arg("type"); } - // `class X extends CustomEvent` must initialize as a CustomEvent: the - // `constructor` field resolves to the CustomEvent global and `detail` is - // read off the options bag (mirroring the direct `new CustomEvent(...)` - // path). Plain `extends Event` keeps `b"Event"` and no `detail`. + let scope = crate::gc::RuntimeHandleScope::new(); + let event = scope.root_raw_mut_ptr(event); + let type_value = scope.root_nanbox_f64(type_value); + let options = scope.root_nanbox_f64(options); if is_custom != 0 { - let detail = unsafe { option_detail(options) }; - init_event_fields(event, type_value, options, b"CustomEvent", Some(detail)); + let detail = unsafe { option_detail(options.get_nanbox_f64()) }; + event.with_mut_ptr(|ptr| { + init_event_fields( + ptr, + type_value.get_nanbox_f64(), + options.get_nanbox_f64(), + b"CustomEvent", + Some(detail), + ) + }); } else { - init_event_fields(event, type_value, options, b"Event", None); + event.with_mut_ptr(|ptr| { + init_event_fields( + ptr, + type_value.get_nanbox_f64(), + options.get_nanbox_f64(), + b"Event", + None, + ) + }); } undefined_value() } @@ -351,26 +336,36 @@ pub extern "C" fn js_dom_exception_subclass_init(this_value: f64, message: f64, let message_ptr = scope.root_string_ptr(message_ptr); let name_ptr = optional_string_from_value(name.get_nanbox_f64(), b"Error"); let name_ptr = scope.root_string_ptr(name_ptr); - let name_string = unsafe { - let name_ptr = name_ptr.get_raw_const_ptr::(); - let len = (*name_ptr).byte_len as usize; - let data = (name_ptr as *const u8).add(std::mem::size_of::()); - String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned() - }; set_event_field( value_as_ptr::(exception.get_nanbox_f64()).unwrap(), - b"message", - crate::value::js_nanbox_string(message_ptr.get_raw_const_ptr::() as i64), + state::DOM_MESSAGE, + message_ptr + .with_const_ptr::(|ptr| crate::value::js_nanbox_string(ptr as i64)), ); set_event_field( value_as_ptr::(exception.get_nanbox_f64()).unwrap(), - b"name", - crate::value::js_nanbox_string(name_ptr.get_raw_const_ptr::() as i64), + state::DOM_NAME, + name_ptr + .with_const_ptr::(|ptr| crate::value::js_nanbox_string(ptr as i64)), ); - set_event_field( - value_as_ptr::(exception.get_nanbox_f64()).unwrap(), - b"code", - dom_exception_code(&name_string), + let error = js_dom_exception_new( + message_ptr + .with_const_ptr::(|ptr| crate::value::js_nanbox_string(ptr as i64)), + name_ptr + .with_const_ptr::(|ptr| crate::value::js_nanbox_string(ptr as i64)), + ); + let stack = crate::error::js_error_get_stack(error); + let _gc = crate::gc::GcSuppressScope::new(); + let obj = value_as_ptr::(exception.get_nanbox_f64()).unwrap(); + js_object_set_field_by_name( + obj, + key(b"stack"), + crate::value::js_nanbox_string(stack as i64), + ); + crate::object::set_builtin_property_attrs( + obj as usize, + "stack".to_owned(), + crate::object::PropertyAttrs::new(true, false, true), ); undefined_value() } @@ -382,29 +377,12 @@ static KEEP_JS_DOM_EXCEPTION_SUBCLASS_INIT: extern "C" fn(f64, f64, f64) -> f64 js_dom_exception_subclass_init; fn is_event_instance(event: *const ObjectHeader) -> bool { - if event.is_null() { - return false; - } - let class_id = unsafe { (*event).class_id }; - if class_id == CLASS_ID_EVENT || class_id == CLASS_ID_CUSTOM_EVENT { - return true; - } - // A user subclass (`class CloseEvent extends Event`, e.g. the `ws` - // package's WebSocket events) carries its own class id; walk the - // registered parent chain looking for the Event base. - 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 || parent == CLASS_ID_CUSTOM_EVENT { - return true; - } - cur = parent; - } - _ => return false, - } - } - false + let valid = unsafe { crate::object::shaped_symbols::owner(event as usize).is_some() }; + valid + && crate::value::JSValue::from_bits( + state::get(event as *mut ObjectHeader, state::TYPE).to_bits(), + ) + .is_string() } /// `new Event(type, options?)`. @@ -426,81 +404,19 @@ pub extern "C" fn js_custom_event_new( if argc == 0 { throw_missing_arg("type"); } - let detail = unsafe { option_detail(options) }; + let scope = crate::gc::RuntimeHandleScope::new(); + let type_value = scope.root_nanbox_f64(type_value); + let options = scope.root_nanbox_f64(options); + let detail = unsafe { option_detail(options.get_nanbox_f64()) }; construct_event( - type_value, - options, + type_value.get_nanbox_f64(), + options.get_nanbox_f64(), CLASS_ID_CUSTOM_EVENT, b"CustomEvent", Some(detail), ) } -fn dom_exception_errors() -> &'static Mutex> { - static DOM_EXCEPTION_ERRORS: OnceLock>> = OnceLock::new(); - crate::once_init::get_or_init(&DOM_EXCEPTION_ERRORS, || Mutex::new(HashSet::new())) -} - -/// Latched true by the first `DOMException` construction, so the per-dead- -/// error GC cleanup below skips the mutex entirely in the (overwhelmingly -/// common) processes that never create one. -static DOM_EXCEPTIONS_CREATED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// Death cleanup (2026-07-09 GC audit wave 2): `DOM_EXCEPTION_ERRORS` is a -/// raw-address HashSet with zero removals, so it grew per DOMException and -/// misidentified recycled addresses as DOMExceptions. Errors already run the -/// `ErrorSideTables` finalize hook on death — this is called from -/// `error_side_tables_clear_dead` for every dead error. -pub(crate) fn dom_exception_error_clear_dead(err_addr: usize) { - if !DOM_EXCEPTIONS_CREATED.load(std::sync::atomic::Ordering::Relaxed) { - return; - } - if let Ok(mut set) = dom_exception_errors().lock() { - set.remove(&err_addr); - } -} - -/// #11471: thread-exit release for `DOM_EXCEPTION_ERRORS`. Its only removal -/// is the error finalize hook above, which `Arena::drop` never runs, so an -/// exiting thread's DOMExceptions would leave addresses behind that make an -/// unrelated Error at a reused address `instanceof DOMException`. Runs from a -/// TLS destructor: one process-global lock, no thread-locals, no GC. -pub(crate) fn release_dom_exception_errors_in_freed_ranges( - freed: &crate::arena::thread_exit::FreedRanges, -) { - if !DOM_EXCEPTIONS_CREATED.load(std::sync::atomic::Ordering::Relaxed) { - return; - } - dom_exception_errors() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .retain(|&addr| !freed.contains(addr)); -} - -/// Test probe (#11471): is `err_addr` recorded as a DOMException? -#[doc(hidden)] -pub fn dom_exception_error_registered_for_test(err_addr: usize) -> bool { - dom_exception_errors() - .lock() - .is_ok_and(|set| set.contains(&err_addr)) -} - -#[cfg(test)] -pub(crate) fn test_seed_dom_exception_error(err_addr: usize) { - DOM_EXCEPTIONS_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); - if let Ok(mut set) = dom_exception_errors().lock() { - set.insert(err_addr); - } -} - -#[cfg(test)] -pub(crate) fn test_dom_exception_error_registered(err_addr: usize) -> bool { - dom_exception_errors() - .lock() - .is_ok_and(|set| set.contains(&err_addr)) -} - fn dom_exception_code(name: &str) -> f64 { let code = match name { "IndexSizeError" => 1, @@ -536,48 +452,53 @@ fn dom_exception_code(name: &str) -> f64 { /// `new DOMException(message?, name?)`. #[no_mangle] pub extern "C" fn js_dom_exception_new(message: f64, name: f64) -> *mut crate::error::ErrorHeader { - let message_ptr = optional_string_from_value(message, b""); - let name_ptr = optional_string_from_value(name, b"Error"); - let name_string = unsafe { - let len = (*name_ptr).byte_len as usize; - let data = (name_ptr as *const u8).add(std::mem::size_of::()); - String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned() + let scope = crate::gc::RuntimeHandleScope::new(); + let name = scope.root_nanbox_f64(name); + let message_ptr = scope.root_string_ptr(optional_string_from_value(message, b"")); + let name_ptr = optional_string_from_value(name.get_nanbox_f64(), b"Error"); + let name_bytes = unsafe { + std::slice::from_raw_parts( + (name_ptr as *const u8).add(std::mem::size_of::()), + (*name_ptr).byte_len as usize, + ) + .to_vec() }; - let err = - crate::error::js_error_new_with_name_message_bytes(name_string.as_bytes(), message_ptr); - if !err.is_null() { - // #11541: registered before the first insert rather than named in - // `arena::thread_exit`'s dispatcher (which linked it into every - // binary). This is the table's only production insert. - static REGISTER: std::sync::Once = std::sync::Once::new(); - REGISTER.call_once(|| { - crate::arena::thread_exit::register_thread_exit_range_hook( - release_dom_exception_errors_in_freed_ranges, - ); - }); - DOM_EXCEPTIONS_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); - if let Ok(mut set) = dom_exception_errors().lock() { - set.insert(err as usize); - } - let ctor = crate::object::js_get_global_this_builtin_value(b"DOMException".as_ptr(), 12); - crate::node_submodules::set_error_user_prop(err as usize, "constructor", ctor); - crate::node_submodules::set_error_user_prop( - err as usize, - "code", - dom_exception_code(&name_string), - ); + let err = message_ptr + .with_mut_ptr(|ptr| crate::error::js_error_new_with_name_message_bytes(&name_bytes, ptr)); + note_constructed(); + unsafe { + crate::error::mark_dom_exception(err); } - err + state::link(err.cast(), "DOMException").cast() } -/// Runtime predicate for `value instanceof DOMException`. pub(crate) fn is_dom_exception_error(err: *const crate::error::ErrorHeader) -> bool { - if err.is_null() { - return false; + unsafe { + crate::value::addr_class::try_read_tracked_gc_header(err as usize) + .is_some_and(|h| h.as_ref().obj_type == crate::gc::GC_TYPE_ERROR) + && (*err).error_kind == crate::error::ERROR_KIND_DOM_EXCEPTION + } +} + +/// Test-only observation of a cell's own brand, with allocator ownership +/// checked first; no address registry survives the owning heap. +#[doc(hidden)] +pub fn dom_exception_has_error_brand_for_test(addr: usize) -> bool { + is_dom_exception_error(addr as *const crate::error::ErrorHeader) +} + +pub(crate) fn is_dom_exception_object(obj: *const ObjectHeader) -> bool { + unsafe { + if !crate::value::addr_class::try_read_tracked_gc_header(obj as usize) + .is_some_and(|h| h.as_ref().obj_type == crate::gc::GC_TYPE_OBJECT) + { + return false; + } + if !state::has(obj as *mut ObjectHeader, state::DOM_NAME) { + return false; + } + true } - dom_exception_errors() - .lock() - .is_ok_and(|set| set.contains(&(err as usize))) } pub(crate) fn abort_dom_exception_value() -> f64 { @@ -587,112 +508,34 @@ pub(crate) fn abort_dom_exception_value() -> f64 { crate::value::js_nanbox_pointer(err as i64) } -/// 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; - } - // Handle-based receivers (EventEmitter ids live at 0x38000..0x40000, - // widget/stream handles lower) are small integers, not heap pointers. - // Probing the GcHeader at handle-8 read unmapped memory and SIGSEGV'd - // when events.on(emitter, ...) validated its target (#4633). - if crate::value::addr_class::is_handle_band(target as usize) { - return false; - } - let gc_header = - (target as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT { + if crate::object::shaped_symbols::owner(target as usize).is_none() { 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::(), - marker_key.get_raw_mut_ptr::(), - bool_value(true), - ); - js_object_set_field_by_name( - target_handle.get_raw_mut_ptr::(), - listeners_key.get_raw_mut_ptr::(), - boxed_ptr(bag_handle.get_raw_mut_ptr::()), - ); - js_object_set_field_by_name( - target_handle.get_raw_mut_ptr::(), - max_listeners_key.get_raw_mut_ptr::(), - 10.0, - ); - - bag_handle.get_raw_mut_ptr::() + let target = scope.root_raw_mut_ptr(target as *mut ObjectHeader); + let name = key(b"constructor"); + let constructor = scope + .root_nanbox_f64(target.with_mut_ptr(|ptr| js_object_get_field_by_name_f64(ptr, name))); + let description = string_value(b"nodejs.event_target"); + let marker = crate::symbol::js_symbol_for(description); + crate::value::js_is_truthy(crate::symbol::js_object_get_symbol_property( + constructor.get_nanbox_f64(), + marker, + )) != 0 } 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")); - if let Some(bag) = value_as_ptr::(bag) { + let _gc = crate::gc::GcSuppressScope::new(); + if let Some(bag) = value_as_ptr::(state::get(target, state::LISTENERS)) { return Some(bag); } - Some(seed_event_target_state(target)) + let bag = crate::map::js_map_alloc(0).cast::(); + state::set(target, state::LISTENERS, boxed_ptr(bag)); + Some(bag) } unsafe fn event_array( @@ -703,7 +546,10 @@ unsafe fn event_array( if bag.is_null() || event_name_ptr.is_null() { return None; } - let existing = js_object_get_field_by_name_f64(bag, event_name_ptr); + let existing = crate::map::js_map_get( + bag.cast(), + crate::value::js_nanbox_string(event_name_ptr as i64), + ); if let Some(arr) = value_as_ptr::(existing) { return Some(arr); } @@ -711,7 +557,11 @@ unsafe fn event_array( return None; } let arr = js_array_alloc(0); - js_object_set_field_by_name(bag, event_name_ptr, boxed_ptr(arr)); + crate::map::js_map_set( + bag.cast(), + crate::value::js_nanbox_string(event_name_ptr as i64), + boxed_ptr(arr), + ); Some(arr) } @@ -794,7 +644,11 @@ unsafe fn remove_event_listener_value_with_capture( result = js_array_push_f64(result, current); } if changed { - js_object_set_field_by_name(bag, event_name_ptr, boxed_ptr(result)); + crate::map::js_map_set( + bag.cast(), + crate::value::js_nanbox_string(event_name_ptr as i64), + boxed_ptr(result), + ); } } @@ -835,21 +689,14 @@ extern "C" fn event_target_abort_remove_listener( /// 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. +/// parent edge. Internal listener state is traced through the instance's symbol properties. #[no_mangle] pub extern "C" fn js_event_target_new() -> *mut ObjectHeader { + let _gc = crate::gc::GcSuppressScope::new(); + note_constructed(); let target = js_object_alloc(CLASS_ID_EVENT_TARGET, 0); - let bag = js_object_alloc(0, 0); - js_object_set_field_by_name( - target, - key(b"_eventTarget"), - f64::from_bits(JSValue::bool(true).bits()), - ); - js_object_set_field_by_name(target, key(b"_eventTargetListeners"), boxed_ptr(bag)); - js_object_set_field_by_name(target, key(b"_eventTargetMaxListeners"), 10.0); - target + state::initialize_target(target, false); + state::link(target, "EventTarget") } /// `target.addEventListener(type, listener)`. @@ -878,13 +725,26 @@ pub unsafe extern "C" fn js_event_target_add_event_listener_with_options( if callback_ptr == 0 { return; } - let capture = listener_capture(options); - let once = listener_option_bool(options, b"once"); - if let Some(signal) = listener_signal(options) { - if crate::url::js_abort_signal_is_aborted(signal) != 0 { - return; - } + let scope = crate::gc::RuntimeHandleScope::new(); + let target_h = scope.root_raw_mut_ptr(target); + let name_h = scope.root_string_ptr(event_name_ptr); + let callback_h = scope.root_raw_mut_ptr(callback_ptr as *mut crate::closure::ClosureHeader); + let options_h = scope.root_nanbox_f64(options); + let capture = listener_capture(options_h.get_nanbox_f64()); + let once = listener_option_bool(options_h.get_nanbox_f64(), b"once"); + let signal = listener_signal(options_h.get_nanbox_f64()); + let signal_h = signal.map(|signal| scope.root_raw_mut_ptr(signal)); + if signal_h + .as_ref() + .is_some_and(|signal| crate::url::js_abort_signal_is_aborted(signal.get_raw_mut_ptr()) != 0) + { + return; } + // Only private storage operations follow; option getters have already run. + let _gc = crate::gc::GcSuppressScope::new(); + let target = target_h.get_raw_mut_ptr(); + let event_name_ptr = name_h.get_raw_const_ptr(); + let callback_ptr = callback_h.get_raw_mut_ptr::() as i64; let Some(bag) = listeners_bag(target) else { return; }; @@ -901,9 +761,14 @@ pub unsafe extern "C" fn js_event_target_add_event_listener_with_options( } let updated = js_array_push_f64(arr, make_listener_record(listener, capture, once)); if updated != arr { - js_object_set_field_by_name(bag, event_name_ptr, boxed_ptr(updated)); + crate::map::js_map_set( + bag.cast(), + crate::value::js_nanbox_string(event_name_ptr as i64), + boxed_ptr(updated), + ); } - if let Some(signal) = listener_signal(options) { + if let Some(signal) = signal_h { + let signal = signal.get_raw_mut_ptr(); let func = event_target_abort_remove_listener as *const u8; crate::closure::js_register_closure_arity(func, 0); let abort_listener = crate::closure::js_closure_alloc(func, 4); @@ -942,18 +807,23 @@ pub unsafe extern "C" fn js_event_target_remove_event_listener_with_options( callback_ptr: i64, options: f64, ) { + let scope = crate::gc::RuntimeHandleScope::new(); + let target = scope.root_raw_mut_ptr(target); + let name = scope.root_string_ptr(event_name_ptr); + let callback = scope.root_raw_mut_ptr(callback_ptr as *mut crate::closure::ClosureHeader); + let capture = listener_capture(options); + let _gc = crate::gc::GcSuppressScope::new(); remove_event_listener_with_capture( - target, - event_name_ptr, - callback_ptr, - listener_capture(options), + target.get_raw_mut_ptr(), + name.get_raw_const_ptr(), + callback.get_raw_mut_ptr::() as i64, + capture, ); } fn event_type_ptr(event: f64) -> Option<*const StringHeader> { let event_ptr = value_as_ptr::(event)?; - let type_value = js_object_get_field_by_name(event_ptr, key(b"type")); - let type_box = f64::from_bits(type_value.bits()); + let type_box = state::get(event_ptr, state::TYPE); let ptr = crate::value::js_get_string_pointer_unified(type_box) as *const StringHeader; (!ptr.is_null()).then_some(ptr) } @@ -987,55 +857,62 @@ pub unsafe extern "C" fn js_event_target_dispatch_event( if !is_event_instance(event_ptr) { throw_invalid_event(event); } - let Some(event_name_ptr) = event_type_ptr(event) else { + let scope = crate::gc::RuntimeHandleScope::new(); + let target_h = scope.root_raw_mut_ptr(target); + let event_h = scope.root_nanbox_f64(event); + let target = || target_h.get_raw_mut_ptr::(); + let event_ptr = + || crate::value::js_nanbox_get_pointer(event_h.get_nanbox_f64()) as *mut ObjectHeader; + let Some(event_name) = event_type_ptr(event_h.get_nanbox_f64()) else { return bool_value(true); }; - let target_value = boxed_ptr(target); - set_event_field(event_ptr, b"target", target_value); - set_event_field(event_ptr, b"currentTarget", target_value); - set_event_field(event_ptr, b"eventPhase", number_value(2.0)); - - let callbacks = listeners_bag(target) - .and_then(|bag| event_array(bag, event_name_ptr, false)) - .map(|arr| { - let len = js_array_length(arr); - let mut callbacks = Vec::with_capacity(len as usize); - for i in 0..len { + let name_h = scope.root_string_ptr(event_name); + set_event_field(event_ptr(), state::TARGET, boxed_ptr(target())); + set_event_field(event_ptr(), state::DISPATCHED, bool_value(true)); + let callbacks = { + let _gc = crate::gc::GcSuppressScope::new(); + let mut callbacks = Vec::new(); + if let Some(arr) = listeners_bag(target()) + .and_then(|bag| event_array(bag, name_h.get_raw_const_ptr(), false)) + { + for i in 0..js_array_length(arr) { let record = f64::from_bits(js_array_get(arr, i).bits()); callbacks.push(( - listener_record_callback(record), + scope.root_nanbox_f64(listener_record_callback(record)), listener_record_capture(record), listener_record_once(record), )); } - callbacks - }) - .unwrap_or_default(); - - let args = [event]; - let this_scope = crate::gc::RuntimeHandleScope::new(); - // #9445: the displaced receiver is rooted ONCE here, not once per callback. - let prev_this = this_scope.root_nanbox_f64(crate::object::js_implicit_this_get()); + } + callbacks + }; + let prev_this = scope.root_nanbox_f64(crate::object::js_implicit_this_get()); for (callback, capture, once) in callbacks { - let Some(callable) = closure_value_from_listener(callback) else { + if closure_value_from_listener(callback.get_nanbox_f64()).is_none() { continue; - }; + } if once { - remove_event_listener_value_with_capture(target, event_name_ptr, callback, capture); + let _gc = crate::gc::GcSuppressScope::new(); + remove_event_listener_value_with_capture( + target(), + name_h.get_raw_const_ptr(), + callback.get_nanbox_f64(), + capture, + ); } - crate::object::js_implicit_this_set(target_value); - let _ = crate::closure::js_native_call_value(callable, args.as_ptr(), args.len()); + crate::object::js_implicit_this_set(boxed_ptr(target())); + let args = [event_h.get_nanbox_f64()]; + crate::closure::js_native_call_value(callback.get_nanbox_f64(), args.as_ptr(), 1); crate::object::js_implicit_this_set(prev_this.get_nanbox_f64()); - if event_bool_field(event_ptr, b"_immediateStopped") { + if event_bool_field(event_ptr(), state::IMMEDIATE_STOPPED) { break; } } - - set_event_field(event_ptr, b"currentTarget", null_value()); - set_event_field(event_ptr, b"eventPhase", number_value(0.0)); - let canceled = event_bool_field(event_ptr, b"cancelable") - && event_bool_field(event_ptr, b"defaultPrevented"); - bool_value(!canceled) + set_event_field(event_ptr(), state::DISPATCHED, bool_value(false)); + bool_value( + !(event_bool_field(event_ptr(), state::CANCELABLE) + && event_bool_field(event_ptr(), state::DEFAULT_PREVENTED)), + ) } /// Runtime predicate used by the Node `events` module helpers. @@ -1054,6 +931,8 @@ pub unsafe extern "C" fn js_event_target_get_event_listeners( target: *mut ObjectHeader, event_name_ptr: *const StringHeader, ) -> *mut ArrayHeader { + // Only unreachable private records are read; no user callback can run. + let _gc = crate::gc::GcSuppressScope::new(); let out = js_array_alloc(0); let Some(bag) = listeners_bag(target) else { return out; @@ -1079,7 +958,7 @@ pub unsafe extern "C" fn js_event_target_get_max_listeners(target: *mut ObjectHe if !is_event_target(target) { return 10.0; } - let value = js_object_get_field_by_name_f64(target, key(b"_eventTargetMaxListeners")); + let value = state::get(target, state::MAX_LISTENERS); if JSValue::from_bits(value.to_bits()).is_number() { value } else { @@ -1096,71 +975,27 @@ pub unsafe extern "C" fn js_event_target_set_max_listeners( if !is_event_target(target) { return 0; } - js_object_set_field_by_name(target, key(b"_eventTargetMaxListeners"), n); + state::set(target, state::MAX_LISTENERS, 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; - if bits == 0 { - let receiver = crate::object::js_implicit_this_get(); - if let Some(target) = value_as_ptr::(receiver) { - let valid = crate::value::addr_class::try_read_gc_header(target as usize) - .is_some_and(|h| h.obj_type == crate::gc::GC_TYPE_OBJECT); - if valid { - let scope = crate::gc::RuntimeHandleScope::new(); - let target_handle = scope.root_raw_mut_ptr(target); - // `is_event_target` interns `_eventTarget` through `key()`, - // which allocates, so the receiver can move while the - // predicate runs. `across_mut` orders the re-read after that - // call and hands back the refreshed address, and the nested - // `with_const_ptr` scopes the pointer the predicate reads -- - // neither ever binds the pre-call address. - let (is_target, target) = target_handle.across_mut::(|| { - target_handle.with_const_ptr::(|p| is_event_target(p)) - }); - if is_target { - return target; - } - } +/// Brand-check the implicit receiver of a shared EventTarget method. +unsafe fn bound_event_target(_closure: *const crate::closure::ClosureHeader) -> *mut ObjectHeader { + let receiver = crate::object::js_implicit_this_get(); + if let Some(target) = value_as_ptr::(receiver) { + if is_event_target(target) { + return target; } - let msg = b"Value of \"this\" must be of type EventTarget"; - let text = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let error = crate::error::js_typeerror_new(text); - crate::exception::js_throw(crate::value::js_nanbox_pointer(error as i64)); } - crate::value::js_nanbox_get_pointer(f64::from_bits(bits)) as *mut ObjectHeader + prototype::throw_receiver("EventTarget") } fn event_proto_receiver() -> *mut ObjectHeader { let receiver = crate::object::js_implicit_this_get(); if let Some(event) = value_as_ptr::(receiver) { let valid = unsafe { - crate::value::addr_class::try_read_gc_header(event as usize) - .is_some_and(|h| h.obj_type == crate::gc::GC_TYPE_OBJECT) + crate::value::addr_class::try_read_tracked_gc_header(event as usize) + .is_some_and(|h| h.as_ref().obj_type == crate::gc::GC_TYPE_OBJECT) }; if valid && is_event_instance(event) { return event; @@ -1202,33 +1037,29 @@ extern "C" fn event_proto_init_event_thunk( let scope = crate::gc::RuntimeHandleScope::new(); let event = scope.root_raw_mut_ptr(event_proto_receiver()); let event_type = scope.root_nanbox_f64(event_type); - let phase_key = key(b"eventPhase"); - if event - .with_mut_ptr::(|event| js_object_get_field_by_name_f64(event, phase_key)) - != 0.0 - { + if event.with_mut_ptr(|ptr| event_bool_field(ptr, state::DISPATCHED)) { return undefined_value(); } let type_string = scope.root_string_ptr(string_from_value(event_type.get_nanbox_f64())); let type_value = type_string.with_const_ptr::(|p| crate::value::js_nanbox_string(p as i64)); - event.with_mut_ptr::(|event| set_event_field(event, b"type", type_value)); + event.with_mut_ptr::(|event| set_event_field(event, state::TYPE, type_value)); event.with_mut_ptr::(|event| { set_event_field( event, - b"bubbles", + state::BUBBLES, bool_value(crate::value::js_is_truthy(bubbles) != 0), ) }); event.with_mut_ptr::(|event| { set_event_field( event, - b"cancelable", + state::CANCELABLE, bool_value(crate::value::js_is_truthy(cancelable) != 0), ) }); event.with_mut_ptr::(|event| { - set_event_field(event, b"defaultPrevented", bool_value(false)) + set_event_field(event, state::DEFAULT_PREVENTED, bool_value(false)) }); undefined_value() } @@ -1238,9 +1069,12 @@ extern "C" fn event_proto_composed_path_thunk( ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let event = scope.root_raw_mut_ptr(event_proto_receiver()); - let current_key = key(b"currentTarget"); - let current = event.with_mut_ptr::(|event| { - js_object_get_field_by_name_f64(event, current_key) + let current = event.with_mut_ptr(|ptr| { + if event_bool_field(ptr, state::DISPATCHED) { + state::get(ptr, state::TARGET) + } else { + null_value() + } }); let current = scope.root_nanbox_f64(current); let result = js_array_alloc(0); @@ -1255,87 +1089,7 @@ extern "C" fn event_proto_composed_path_thunk( /// Install the WebIDL method values on EventTarget and Event prototypes. /// CustomEvent inherits Event's methods through its prototype link. pub(crate) fn install_web_event_proto_methods(name: &str, proto_obj: *mut ObjectHeader) { - use crate::object::install_proto_method; - // Each install allocates a closure, its name string and the key string, so - // the prototype can move between them: root it once and re-read the - // current address from the rooted slot on every use rather than closing - // over the incoming raw `proto_obj`. - let scope = crate::gc::RuntimeHandleScope::new(); - let proto_h = scope.root_nanbox_f64(boxed_ptr(proto_obj)); - let proto = - || crate::value::js_nanbox_get_pointer(proto_h.get_nanbox_f64()) as *mut ObjectHeader; - let install = |method: &str, func: *const u8, call_arity: u32, spec_length: u32| { - let value = install_proto_method(proto(), method, func, call_arity); - let closure = crate::value::js_nanbox_get_pointer(value) as usize; - if closure != 0 { - crate::object::native_module::set_builtin_closure_length(closure, spec_length); - } - // WebIDL operations are ENUMERABLE, unlike ECMAScript builtin methods - // (`Array.prototype.map` is enumerable=false). Measured on the pinned - // oracle (`.node-version`, v26.5.1): every one of the eight members - // installed here reports `enumerable=true`, and - // `Object.keys(EventTarget.prototype)` is - // `["addEventListener", "removeEventListener", "dispatchEvent"]`. - // `install_proto_method` defaults to the ECMAScript shape, so each - // needs this override. (`AbortSignal.prototype.throwIfAborted` is the - // one member of the #10808 group Node makes non-enumerable; it is - // installed in `proto_methods.rs` without this call, on purpose.) - crate::object::set_builtin_property_attrs( - proto() as usize, - method.to_string(), - crate::object::PropertyAttrs::new(true, true, true), - ); - }; - match name { - "EventTarget" => { - install( - "addEventListener", - event_target_add_event_listener_thunk as *const u8, - 3, - 2, - ); - install( - "removeEventListener", - event_target_remove_event_listener_thunk as *const u8, - 3, - 2, - ); - install( - "dispatchEvent", - event_target_dispatch_event_thunk as *const u8, - 1, - 1, - ); - } - "Event" => { - install("initEvent", event_proto_init_event_thunk as *const u8, 3, 1); - install( - "stopImmediatePropagation", - event_proto_stop_immediate_propagation_thunk as *const u8, - 0, - 0, - ); - install( - "preventDefault", - event_proto_prevent_default_thunk as *const u8, - 0, - 0, - ); - install( - "composedPath", - event_proto_composed_path_thunk as *const u8, - 0, - 0, - ); - install( - "stopPropagation", - event_proto_stop_propagation_thunk as *const u8, - 0, - 0, - ); - } - _ => {} - } + prototype::install(name, proto_obj); } /// Shared body of the add/remove listener thunks. `string_from_value` can @@ -1348,6 +1102,8 @@ unsafe fn bound_listener_call( options: f64, add: bool, ) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let target_handle = scope.root_raw_mut_ptr(bound_event_target(closure)); 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 @@ -1355,8 +1111,6 @@ unsafe fn bound_listener_call( // 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); @@ -1399,64 +1153,68 @@ extern "C" fn event_target_dispatch_event_thunk( 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" - ) +/// Class identity, including declared subclasses; never a property/name probe. +pub(crate) fn native_class_name(mut class: u32) -> Option<&'static str> { + for _ in 0..64 { + let name = match class { + crate::native_class_ids::ABORT_CONTROLLER => Some("AbortController"), + crate::native_class_ids::ABORT_SIGNAL => Some("AbortSignal"), + CLASS_ID_EVENT => Some("Event"), + CLASS_ID_CUSTOM_EVENT => Some("CustomEvent"), + CLASS_ID_DOM_EXCEPTION => Some("DOMException"), + CLASS_ID_EVENT_TARGET => Some("EventTarget"), + _ => None, + }; + if name.is_some() { + return name; + } + match crate::object::get_parent_class_id(class) { + Some(parent) if parent != 0 && parent != class => class = parent, + _ => return None, + } + } + None } -/// 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 { - 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; +pub(crate) fn note_constructed() { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::EventTarget, + ); } - 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::()).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 { - if !is_event_target_method_name(name) { - return None; +} + +#[cfg(test)] +pub(crate) mod honest_tests; + +/// Initialize the native base through super(), retaining the derived shape +/// and prototype. Merely creating a class host does not run this constructor. +#[no_mangle] +pub extern "C" fn js_event_target_subclass_init(this: f64, kind: u32) -> f64 { + if kind == 2 { + prototype::throw_receiver("AbortSignal"); } - event_target_method_bind(target, name) + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = + scope.root_raw_mut_ptr(value_as_ptr::(this).expect("native super receiver")); + let class = owner.with_const_ptr::(|ptr| unsafe { (*ptr).class_id }); + let proto = crate::object::class_decl_prototype_value(class); + owner.with_mut_ptr::(|ptr| { + crate::object::prototype_chain::object_link_class_default_prototype( + ptr as usize, + proto.to_bits(), + ) + }); + owner.with_mut_ptr(|owner| { + if kind == 0 { + state::initialize_target(owner, false); + } else { + state::set(owner, state::CONTROLLER_SIGNAL, undefined_value()); + } + }); + undefined_value() } + +#[used(compiler)] +static KEEP_JS_EVENT_TARGET_SUBCLASS_INIT: extern "C" fn(f64, u32) -> f64 = + js_event_target_subclass_init; diff --git a/crates/perry-runtime/src/event_target/honest_tests.rs b/crates/perry-runtime/src/event_target/honest_tests.rs new file mode 100644 index 00000000000..d566713ae36 --- /dev/null +++ b/crates/perry-runtime/src/event_target/honest_tests.rs @@ -0,0 +1,132 @@ +use super::*; + +pub(crate) fn constructors() -> [fn() -> f64; 6] { + [ + || boxed_ptr(js_event_target_new()), + || boxed_ptr(js_event_new(string_value(b"x"), undefined_value(), 1)), + || { + boxed_ptr(js_custom_event_new( + string_value(b"x"), + undefined_value(), + 1, + )) + }, + || boxed_ptr(crate::url::js_abort_controller_new()), + || boxed_ptr(crate::url::js_abort_signal_abort(undefined_value())), + || boxed_ptr(js_dom_exception_new(undefined_value(), undefined_value())), + ] +} + +pub(crate) fn assert_surface(value: f64) { + let _gc = crate::gc::GcSuppressScope::new(); + let ptr = crate::value::js_nanbox_get_pointer(value) as usize; + let names = crate::object::js_object_get_own_property_names(value); + let names = crate::value::js_nanbox_get_pointer(names) as *const ArrayHeader; + let expected = usize::from(is_dom_exception_error( + ptr as *const crate::error::ErrorHeader, + )); + assert_eq!( + crate::array::js_array_length(names) as usize, + expected, + "internal event state must not be an own string property" + ); + if expected == 1 { + let name = crate::array::js_array_get_f64(names, 0); + let name = crate::builtins::js_string_coerce(name); + unsafe { + let bytes = std::slice::from_raw_parts( + (name as *const u8).add(std::mem::size_of::()), + (*name).byte_len as usize, + ); + assert_eq!(bytes, b"stack"); + } + } + let keys = crate::object::js_object_keys_value(value); + assert_eq!(crate::array::js_array_length(keys), 0); +} + +#[test] +fn producers_return_headered_distinct_cells_with_only_node_own_names() { + let _gc = crate::gc::GcSuppressScope::new(); + for make in constructors() { + let first = make(); + let second = make(); + let ptr = crate::value::js_nanbox_get_pointer(first) as usize; + assert!( + !crate::value::addr_class::is_handle_band(ptr), + "gate B: producer returned a handle id" + ); + let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(ptr) } + .expect("gate B: producer must own its GC header"); + assert!(matches!( + unsafe { header.as_ref().obj_type }, + crate::gc::GC_TYPE_OBJECT | crate::gc::GC_TYPE_ERROR + )); + assert_ne!(first.to_bits(), second.to_bits()); + assert_surface(first); + } +} + +#[test] +fn event_resources_are_refused_by_name_at_the_worker_boundary() { + let _gc = crate::gc::GcSuppressScope::new(); + let names = [ + "EventTarget", + "Event", + "CustomEvent", + "AbortController", + "AbortSignal", + "DOMException", + ]; + for (make, name) in constructors().into_iter().zip(names) { + let value = make(); + let result = unsafe { crate::thread::serialize_nanbox_for_thread(value.to_bits()) }; + assert!( + matches!(result, crate::thread::SerializedValue::Unsupported(actual) if actual == name), + "{name} must not serialize as a plain object: {result:?}" + ); + } +} + +#[test] +fn event_target_methods_are_shared_inherited_values() { + let _gc = crate::gc::GcSuppressScope::new(); + let first = js_event_target_new(); + let second = js_event_target_new(); + let name = key(b"dispatchEvent"); + let method = js_object_get_field_by_name_f64(first, name); + assert_eq!( + method.to_bits(), + js_object_get_field_by_name_f64(second, name).to_bits() + ); + assert!(crate::closure::is_closure_ptr( + crate::value::js_nanbox_get_pointer(method) as usize + )); + let proto = crate::object::builtin_prototype_value("EventTarget"); + let proto = crate::value::js_nanbox_get_pointer(proto) as *const ObjectHeader; + assert_eq!( + method.to_bits(), + js_object_get_field_by_name_f64(proto, name).to_bits() + ); +} + +#[test] +fn class_hosts_do_not_acquire_instance_state() { + let _gc = crate::gc::GcSuppressScope::new(); + for class in [ + crate::native_class_ids::EVENT_TARGET, + crate::native_class_ids::EVENT, + crate::native_class_ids::CUSTOM_EVENT, + crate::native_class_ids::ABORT_CONTROLLER, + crate::native_class_ids::ABORT_SIGNAL, + crate::native_class_ids::DOM_EXCEPTION, + ] { + let host = crate::object::js_object_alloc(class, 1); + assert!( + unsafe { crate::object::shaped_symbols::entries(host as usize, true).is_empty() }, + "a class host must not gain an instance brand" + ); + let meta = unsafe { crate::object::object_meta_ensure(host) }; + assert_eq!(unsafe { (*meta).native_state }, 0); + } +} diff --git a/crates/perry-runtime/src/event_target/prototype.rs b/crates/perry-runtime/src/event_target/prototype.rs new file mode 100644 index 00000000000..9643300827f --- /dev/null +++ b/crates/perry-runtime/src/event_target/prototype.rs @@ -0,0 +1,465 @@ +//! The Web event surface lives on shared prototypes; state is object-owned. +use super::*; +use crate::closure::ClosureHeader; +use crate::native_class_ids as ids; + +pub(super) fn throw_receiver(name: &str) -> ! { + let message = format!("Value of \"this\" must be of type {name}"); + let text = key(message.as_bytes()); + let error = crate::error::js_typeerror_new(text); + crate::exception::js_throw(boxed_ptr(error)) +} + +fn receiver(class: u32, name: &str) -> *mut ObjectHeader { + let this = crate::object::js_implicit_this_get(); + if let Some(obj) = value_as_ptr::(this) { + let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(obj as usize) }; + if header + .as_ref() + .is_some_and(|h| unsafe { h.as_ref().obj_type } == crate::gc::GC_TYPE_OBJECT) + { + let branded = match class { + ids::EVENT => is_event_instance(obj), + ids::CUSTOM_EVENT => { + is_event_instance(obj) && !is_undefined(state::get(obj, state::DETAIL)) + } + ids::ABORT_CONTROLLER => state::has(obj, state::CONTROLLER_SIGNAL), + ids::ABORT_SIGNAL => !is_undefined(state::get(obj, state::SIGNAL_ABORTED)), + ids::EVENT_TARGET => unsafe { is_event_target(obj) }, + ids::DOM_EXCEPTION => is_dom_exception_object(obj), + _ => false, + }; + if branded { + return obj; + } + } + if class == ids::DOM_EXCEPTION + && header + .as_ref() + .is_some_and(|h| unsafe { h.as_ref().obj_type } == crate::gc::GC_TYPE_ERROR) + && is_dom_exception_error(obj.cast()) + { + return obj; + } + } + throw_receiver(name) +} + +macro_rules! event_getter { + ($function:ident, $key:expr) => { + extern "C" fn $function(_: *const ClosureHeader) -> f64 { + state::get(receiver(ids::EVENT, "Event"), $key) + } + }; +} +event_getter!(get_type, state::TYPE); +event_getter!(get_target, state::TARGET); +extern "C" fn get_current_target(_: *const ClosureHeader) -> f64 { + let owner = receiver(ids::EVENT, "Event"); + if event_bool_field(owner, state::DISPATCHED) { + state::get(owner, state::TARGET) + } else { + null_value() + } +} +event_getter!(get_bubbles, state::BUBBLES); +event_getter!(get_cancelable, state::CANCELABLE); +event_getter!(get_default_prevented, state::DEFAULT_PREVENTED); +event_getter!(get_timestamp, state::TIMESTAMP); +event_getter!(get_composed, state::COMPOSED); +extern "C" fn get_phase(_: *const ClosureHeader) -> f64 { + let owner = receiver(ids::EVENT, "Event"); + if event_bool_field(owner, state::DISPATCHED) { + 2.0 + } else { + 0.0 + } +} +extern "C" fn get_trusted(_: *const ClosureHeader) -> f64 { + let receiver = crate::object::js_implicit_this_get(); + let trusted = value_as_ptr::(receiver) + .filter(|&obj| state::has(obj, state::TRUSTED)) + .is_some_and(|obj| event_bool_field(obj, state::TRUSTED)); + bool_value(trusted) +} +event_getter!(get_cancel_bubble, state::STOPPED); +extern "C" fn get_return_value(_: *const ClosureHeader) -> f64 { + let obj = receiver(ids::EVENT, "Event"); + bool_value(!event_bool_field(obj, state::DEFAULT_PREVENTED)) +} +extern "C" fn set_return_value(_: *const ClosureHeader, value: f64) -> f64 { + let obj = receiver(ids::EVENT, "Event"); + if crate::value::js_is_truthy(value) == 0 + && event_bool_field(obj, state::CANCELABLE) + && !event_bool_field(obj, state::PASSIVE) + { + state::set(obj, state::DEFAULT_PREVENTED, bool_value(true)); + } + undefined_value() +} +extern "C" fn set_cancel_bubble(_: *const ClosureHeader, value: f64) -> f64 { + let obj = receiver(ids::EVENT, "Event"); + if crate::value::js_is_truthy(value) != 0 { + state::require_private(obj, state::STOPPED); + state::set(obj, state::STOPPED, bool_value(true)); + } + undefined_value() +} +extern "C" fn get_detail(_: *const ClosureHeader) -> f64 { + state::get(receiver(ids::CUSTOM_EVENT, "CustomEvent"), state::DETAIL) +} +extern "C" fn get_signal(_: *const ClosureHeader) -> f64 { + boxed_ptr(crate::url::js_abort_controller_signal(receiver( + ids::ABORT_CONTROLLER, + "AbortController", + ))) +} +extern "C" fn get_aborted(_: *const ClosureHeader) -> f64 { + state::get_slot( + receiver(ids::ABORT_SIGNAL, "AbortSignal"), + state::SIGNAL_ABORTED, + ) +} +extern "C" fn get_reason(_: *const ClosureHeader) -> f64 { + state::get_slot( + receiver(ids::ABORT_SIGNAL, "AbortSignal"), + state::SIGNAL_REASON, + ) +} +extern "C" fn get_onabort(_: *const ClosureHeader) -> f64 { + let obj = receiver(ids::ABORT_SIGNAL, "AbortSignal"); + let scope = crate::gc::RuntimeHandleScope::new(); + let handlers = scope.root_nanbox_f64(state::get(obj, state::HANDLERS)); + if is_undefined(handlers.get_nanbox_f64()) { + return null_value(); + } + let name = string_value(b"abort"); + let handler = crate::map::js_map_get( + value_as_ptr::(handlers.get_nanbox_f64()).unwrap(), + name, + ); + if is_undefined(handler) { + return null_value(); + } + let handler = scope.root_nanbox_f64(handler); + let name = key(b"handler"); + let value = js_object_get_field_by_name_f64( + value_as_ptr::(handler.get_nanbox_f64()).unwrap(), + name, + ); + if is_undefined(value) { + null_value() + } else { + value + } +} +extern "C" fn onabort_wrapper(closure: *const ClosureHeader, event: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure as *mut ClosureHeader); + let event = scope.root_nanbox_f64(event); + let name = key(b"handler"); + let handler = closure + .with_mut_ptr::(|ptr| js_object_get_field_by_name_f64(ptr.cast(), name)); + let bits = handler.to_bits(); + if bits & crate::value::TAG_MASK == crate::value::POINTER_TAG + && crate::closure::is_closure_ptr((bits & crate::value::POINTER_MASK) as usize) + { + let args = [event.get_nanbox_f64()]; + unsafe { crate::closure::js_native_call_value(handler, args.as_ptr(), 1) } + } else { + undefined_value() + } +} +extern "C" fn set_onabort(_: *const ClosureHeader, value: f64) -> f64 { + let _gc = crate::gc::GcSuppressScope::new(); + let obj = receiver(ids::ABORT_SIGNAL, "AbortSignal"); + let handlers = state::get(obj, state::HANDLERS); + let handlers = if is_undefined(handlers) { + let map = crate::map::js_map_alloc(0); + state::set(obj, state::HANDLERS, boxed_ptr(map)); + map + } else { + value_as_ptr::(handlers).unwrap() + }; + let event_name = string_value(b"abort"); + let mut wrapped = crate::map::js_map_get(handlers, event_name); + if is_undefined(wrapped) { + let function = onabort_wrapper as *const u8; + crate::closure::js_register_closure_arity(function, 1); + let closure = crate::closure::js_closure_alloc(function, 0); + wrapped = boxed_ptr(closure); + crate::object::set_bound_native_closure_name(closure, "eventHandler"); + crate::map::js_map_set(handlers, event_name, wrapped); + unsafe { + js_event_target_add_event_listener(obj, key(b"abort"), closure as i64); + } + } + js_object_set_field_by_name( + value_as_ptr::(wrapped).unwrap(), + key(b"handler"), + value, + ); + undefined_value() +} +extern "C" fn abort(_: *const ClosureHeader, reason: f64) -> f64 { + crate::url::js_abort_controller_abort_reason( + receiver(ids::ABORT_CONTROLLER, "AbortController"), + reason, + ); + undefined_value() +} +extern "C" fn throw_if_aborted(_: *const ClosureHeader) -> f64 { + crate::url::js_abort_signal_throw_if_aborted(receiver(ids::ABORT_SIGNAL, "AbortSignal")) +} +extern "C" fn get_dom_name(_: *const ClosureHeader) -> f64 { + let obj = receiver(ids::DOM_EXCEPTION, "DOMException"); + if unsafe { crate::error::ptr_is_native_error(obj as usize) } { + crate::value::js_nanbox_string(crate::error::js_error_get_name(obj.cast()) as i64) + } else { + state::get(obj, state::DOM_NAME) + } +} +extern "C" fn get_dom_message(_: *const ClosureHeader) -> f64 { + let obj = receiver(ids::DOM_EXCEPTION, "DOMException"); + if unsafe { crate::error::ptr_is_native_error(obj as usize) } { + crate::value::js_nanbox_string(crate::error::js_error_get_message(obj.cast()) as i64) + } else { + state::get(obj, state::DOM_MESSAGE) + } +} +extern "C" fn get_dom_code(closure: *const ClosureHeader) -> f64 { + let name = get_dom_name(closure); + let name = crate::builtins::js_string_coerce(name); + let bytes = unsafe { + std::slice::from_raw_parts( + (name as *const u8).add(std::mem::size_of::()), + (*name).byte_len as usize, + ) + }; + dom_exception_code(&String::from_utf8_lossy(bytes)) +} + +fn accessor( + proto: *mut ObjectHeader, + name: &str, + getter: *const u8, + setter: Option<*const u8>, + enumerable: bool, +) { + // All callers hold the short prototype-construction suppression scope. + let closure = |func: *const u8, prefix: &str, arity: u32| { + crate::closure::js_register_closure_arity(func, arity); + let value = crate::closure::js_closure_alloc(func, 0); + crate::object::set_bound_native_closure_name(value, &format!("{prefix} {name}")); + crate::object::native_module::set_builtin_closure_length(value as usize, arity); + crate::object::native_module::set_builtin_closure_non_constructable(value as usize); + boxed_ptr(value).to_bits() + }; + let get = closure(getter, "get", 0); + let set = setter.map_or(0, |setter| closure(setter, "set", 1)); + unsafe { + crate::object::install_builtin_getter(proto, name, get); + } + crate::object::set_builtin_accessor_descriptor( + proto as usize, + name.to_owned(), + crate::object::AccessorDescriptor { get, set }, + crate::object::PropertyAttrs::new(true, enumerable, true), + ); +} + +pub(super) fn install(name: &str, proto: *mut ObjectHeader) { + let _gc = crate::gc::GcSuppressScope::new(); + let method = |name: &str, func: *const u8, arity: u32, length: u32, enumerable: bool| { + let value = crate::object::install_proto_method(proto, name, func, arity); + crate::object::native_module::set_builtin_closure_length( + crate::value::js_nanbox_get_pointer(value) as usize, + length, + ); + crate::object::set_builtin_property_attrs( + proto as usize, + name.to_owned(), + crate::object::PropertyAttrs::new(true, enumerable, true), + ); + }; + let get = |name: &str, func: *const u8| accessor(proto, name, func, None, true); + match name { + "EventTarget" => { + method( + "addEventListener", + event_target_add_event_listener_thunk as *const u8, + 3, + 2, + true, + ); + method( + "removeEventListener", + event_target_remove_event_listener_thunk as *const u8, + 3, + 2, + true, + ); + method( + "dispatchEvent", + event_target_dispatch_event_thunk as *const u8, + 1, + 1, + true, + ); + } + "Event" => { + method( + "initEvent", + event_proto_init_event_thunk as *const u8, + 3, + 1, + true, + ); + method( + "stopImmediatePropagation", + event_proto_stop_immediate_propagation_thunk as *const u8, + 0, + 0, + true, + ); + method( + "preventDefault", + event_proto_prevent_default_thunk as *const u8, + 0, + 0, + true, + ); + get("target", get_target as *const u8); + get("currentTarget", get_current_target as *const u8); + get("srcElement", get_target as *const u8); + get("type", get_type as *const u8); + get("cancelable", get_cancelable as *const u8); + get("defaultPrevented", get_default_prevented as *const u8); + get("timeStamp", get_timestamp as *const u8); + method( + "composedPath", + event_proto_composed_path_thunk as *const u8, + 0, + 0, + true, + ); + accessor( + proto, + "returnValue", + get_return_value as *const u8, + Some(set_return_value as *const u8), + true, + ); + get("bubbles", get_bubbles as *const u8); + get("composed", get_composed as *const u8); + get("eventPhase", get_phase as *const u8); + accessor( + proto, + "cancelBubble", + get_cancel_bubble as *const u8, + Some(set_cancel_bubble as *const u8), + true, + ); + method( + "stopPropagation", + event_proto_stop_propagation_thunk as *const u8, + 0, + 0, + true, + ); + get("isTrusted", get_trusted as *const u8); + } + "CustomEvent" => get("detail", get_detail as *const u8), + "AbortController" => { + get("signal", get_signal as *const u8); + method("abort", abort as *const u8, 1, 0, true); + } + "AbortSignal" => { + get("aborted", get_aborted as *const u8); + accessor(proto, "reason", get_reason as *const u8, None, false); + method("throwIfAborted", throw_if_aborted as *const u8, 0, 0, false); + accessor( + proto, + "onabort", + get_onabort as *const u8, + Some(set_onabort as *const u8), + true, + ); + } + "DOMException" => { + get("name", get_dom_name as *const u8); + get("message", get_dom_message as *const u8); + get("code", get_dom_code as *const u8); + for (index, name) in DOM_CODES.iter().enumerate() { + js_object_set_field_by_name(proto, key(name.as_bytes()), (index + 1) as f64); + crate::object::set_builtin_property_attrs( + proto as usize, + (*name).to_owned(), + crate::object::PropertyAttrs::new(false, true, false), + ); + } + } + _ => unreachable!(), + } +} + +const DOM_CODES: [&str; 25] = [ + "INDEX_SIZE_ERR", + "DOMSTRING_SIZE_ERR", + "HIERARCHY_REQUEST_ERR", + "WRONG_DOCUMENT_ERR", + "INVALID_CHARACTER_ERR", + "NO_DATA_ALLOWED_ERR", + "NO_MODIFICATION_ALLOWED_ERR", + "NOT_FOUND_ERR", + "NOT_SUPPORTED_ERR", + "INUSE_ATTRIBUTE_ERR", + "INVALID_STATE_ERR", + "SYNTAX_ERR", + "INVALID_MODIFICATION_ERR", + "NAMESPACE_ERR", + "INVALID_ACCESS_ERR", + "VALIDATION_ERR", + "TYPE_MISMATCH_ERR", + "SECURITY_ERR", + "NETWORK_ERR", + "ABORT_ERR", + "URL_MISMATCH_ERR", + "QUOTA_EXCEEDED_ERR", + "TIMEOUT_ERR", + "INVALID_NODE_TYPE_ERR", + "DATA_CLONE_ERR", +]; + +pub(crate) fn install_constructor_constants(name: &str, constructor: *mut ObjectHeader) { + if name == "EventTarget" { + let _gc = crate::gc::GcSuppressScope::new(); + let marker = unsafe { crate::symbol::js_symbol_for(string_value(b"nodejs.event_target")) }; + unsafe { + crate::symbol::js_object_set_symbol_property( + boxed_ptr(constructor), + marker, + bool_value(true), + ); + } + return; + } + let (names, start): (&[&str], usize) = match name { + "Event" => ( + &["NONE", "CAPTURING_PHASE", "AT_TARGET", "BUBBLING_PHASE"], + 0, + ), + "DOMException" => (&DOM_CODES, 1), + _ => return, + }; + let _gc = crate::gc::GcSuppressScope::new(); + for (index, name) in names.iter().enumerate() { + crate::object::define_builtin_data_property( + constructor, + key(name.as_bytes()), + (index + start) as f64, + (*name).to_owned(), + crate::object::PropertyAttrs::new(false, true, false), + ); + } +} diff --git a/crates/perry-runtime/src/event_target/state.rs b/crates/perry-runtime/src/event_target/state.rs new file mode 100644 index 00000000000..173d6cbf2c7 --- /dev/null +++ b/crates/perry-runtime/src/event_target/state.rs @@ -0,0 +1,220 @@ +//! Object-owned symbol properties; public symbols mirror Node, private keys +//! are excluded from reflection. No native-state record or backing object. +use crate::object::ObjectHeader; +use std::cell::RefCell; +pub(crate) const TYPE: u32 = 0; +pub(crate) const TARGET: u32 = 1; +pub(crate) const BUBBLES: u32 = 3; +pub(crate) const CANCELABLE: u32 = 4; +pub(crate) const DEFAULT_PREVENTED: u32 = 5; +pub(crate) const TIMESTAMP: u32 = 6; +pub(crate) const COMPOSED: u32 = 7; +pub(crate) const TRUSTED: u32 = 9; +pub(crate) const STOPPED: u32 = 10; +pub(crate) const IMMEDIATE_STOPPED: u32 = 11; +pub(crate) const DETAIL: u32 = 12; +pub(crate) const LISTENERS: u32 = 13; +pub(crate) const MAX_LISTENERS: u32 = 14; +pub(crate) const WARNED: u32 = 15; +pub(crate) const HANDLERS: u32 = 16; +pub(crate) const SIGNAL_ABORTED: u32 = 17; +pub(crate) const SIGNAL_REASON: u32 = 18; +pub(crate) const COMPOSITE: u32 = 20; +pub(crate) const CONTROLLER_SIGNAL: u32 = 21; +pub(crate) const DOM_NAME: u32 = 22; +pub(crate) const DOM_MESSAGE: u32 = 23; +pub(crate) const DISPATCHED: u32 = 24; +pub(crate) const PASSIVE: u32 = 25; +const KEYS: &[(&str, bool)] = &[ + ("type", false), + ("kTarget", false), + ("currentTarget", true), + ("bubbles", true), + ("cancelable", true), + ("defaultPrevented", true), + ("timestamp", true), + ("composed", true), + ("eventPhase", true), + ("isTrusted", true), + ("propagationStopped", true), + ("kStop", false), + ("detail", false), + ("kEvents", false), + ("events.maxEventTargetListeners", false), + ("events.maxEventTargetListenersWarned", false), + ("kHandlers", false), + ("kAborted", false), + ("kReason", false), + ("onabort", true), + ("kComposite", false), + ("signal", true), + ("name", true), + ("message", true), + ("kIsBeingDispatched", false), + ("kInPassiveListener", false), +]; +crate::perry_thread_local! { + static SYMBOLS: RefCell<[u64; KEYS.len()]> = const { RefCell::new([0; KEYS.len()]) }; +} + +pub(crate) fn scan_roots(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + SYMBOLS.with(|symbols| { + for bits in symbols.borrow_mut().iter_mut() { + if *bits != 0 { + visitor.visit_nanbox_u64_slot(bits); + } + } + }); +} + +fn symbol(index: u32) -> u64 { + let cached = SYMBOLS.with(|symbols| symbols.borrow()[index as usize]); + if cached != 0 { + return cached; + } + let desc = KEYS[index as usize].0.as_bytes(); + let desc = crate::string::js_string_from_bytes(desc.as_ptr(), desc.len() as u32); + let value = + unsafe { crate::symbol::js_symbol_new(crate::value::js_nanbox_string(desc as i64)) }; + SYMBOLS.with(|symbols| { + let mut symbols = symbols.borrow_mut(); + symbols[index as usize] = value.to_bits(); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + }); + value.to_bits() +} + +pub(crate) fn get_slot(owner: *mut ObjectHeader, index: u32) -> f64 { + let key = SYMBOLS.with(|symbols| symbols.borrow()[index as usize]); + if key == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + if !KEYS[index as usize].1 { + return unsafe { + crate::symbol::js_object_get_symbol_property( + crate::value::js_nanbox_pointer(owner as i64), + f64::from_bits(key), + ) + }; + } + require_private(owner, index); + let bits = unsafe { + crate::object::shaped_symbols::get( + owner as usize, + (key & crate::value::POINTER_MASK) as usize, + ) + }; + f64::from_bits(bits.unwrap_or(crate::value::TAG_UNDEFINED)) +} + +pub(crate) fn set_slot(owner: *mut ObjectHeader, index: u32, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_mut_ptr(owner); + let value = scope.root_nanbox_f64(value); + let key = symbol(index); + let entry = if KEYS[index as usize].1 { + crate::object::shaped_symbols::PRIVATE_ENTRY + | crate::object::key_attrs::ENTRY_NON_ENUMERABLE + } else { + 0 + }; + unsafe { + if !KEYS[index as usize].1 { + let receiver = owner.with_const_ptr::(|ptr| { + crate::value::js_nanbox_pointer(ptr as i64) + }); + let result = crate::proxy::js_reflect_set( + receiver, + f64::from_bits(key), + value.get_nanbox_f64(), + receiver, + ); + if crate::value::js_is_truthy(result) == 0 { + let message = b"Cannot update event state on this receiver"; + let message = + crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_typeerror_new(message); + crate::exception::js_throw(crate::value::js_nanbox_pointer(error as i64)); + } + return; + } + assert!(owner.with_mut_ptr::( + |ptr| crate::object::shaped_symbols::define( + ptr as usize, + (key & crate::value::POINTER_MASK) as usize, + value.get_nanbox_u64(), + entry + ) + )); + } +} +pub(super) use get_slot as get; +pub(super) use set_slot as set; +pub(crate) fn link(owner: *mut ObjectHeader, name: &str) -> *mut ObjectHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_mut_ptr(owner); + let (_, result) = owner.across_mut(|| { + let proto = scope.root_nanbox_f64(crate::object::builtin_prototype_value(name)); + owner.with_mut_ptr::(|ptr| { + crate::object::prototype_chain::object_link_class_default_prototype( + ptr as usize, + proto.get_nanbox_f64().to_bits(), + ); + }); + }); + result +} + +pub(crate) fn has(owner: *mut ObjectHeader, index: u32) -> bool { + let key = SYMBOLS.with(|symbols| symbols.borrow()[index as usize]); + key != 0 + && unsafe { + crate::object::shaped_symbols::entry( + owner as usize, + (key & crate::value::POINTER_MASK) as usize, + ) + .is_some() + } +} + +pub(crate) fn initialize_target(owner: *mut ObjectHeader, signal: bool) { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_mut_ptr(owner); + for (index, value) in [ + (LISTENERS, crate::value::TAG_UNDEFINED), + ( + MAX_LISTENERS, + (if signal { 0.0f64 } else { 10.0f64 }).to_bits(), + ), + (WARNED, crate::value::TAG_FALSE), + (HANDLERS, crate::value::TAG_UNDEFINED), + ] { + owner.with_mut_ptr(|ptr| set_slot(ptr, index, f64::from_bits(value))); + } +} + +/// Borrowed EventTarget methods can initialize an ordinary object without a +/// family class id. Its own symbol shape still identifies the native state at +/// a worker boundary, before ordinary-object serialization drops symbol keys. +pub(crate) fn transfer_family(owner: *mut ObjectHeader) -> Option<&'static str> { + if has(owner, CONTROLLER_SIGNAL) { + Some("AbortController") + } else if has(owner, SIGNAL_ABORTED) { + Some("AbortSignal") + } else if has(owner, DETAIL) { + Some("CustomEvent") + } else if has(owner, CANCELABLE) { + Some("Event") + } else if has(owner, LISTENERS) { + Some("EventTarget") + } else { + None + } +} + +/// Private fields are not forgeable by copying an exposed public symbol. +pub(crate) fn require_private(owner: *mut ObjectHeader, index: u32) { + if !has(owner, index) { + super::prototype::throw_receiver("Event"); + } +} diff --git a/crates/perry-runtime/src/gc/tests/copying_side_tables.rs b/crates/perry-runtime/src/gc/tests/copying_side_tables.rs index ee0f409c9dd..74d7bf4a3db 100644 --- a/crates/perry-runtime/src/gc/tests/copying_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/copying_side_tables.rs @@ -86,7 +86,8 @@ fn test_copying_minor_rewrites_symbol_side_table_roots_and_lookups() { crate::symbol::test_clear_symbol_side_table_roots(); gc_register_mutable_root_scanner(crate::symbol::scan_symbol_side_table_roots_mut); - let owner = crate::object::js_object_alloc(0, 0) as usize; + // Ordinary-object symbols are shape slots; arrays still use this scanner. + let owner = crate::array::js_array_alloc(0) as usize; let sym_key = unsafe { alloc_nursery_test_symbol() }; let value = young_leaf(); let static_sym_key = unsafe { alloc_nursery_test_symbol() }; diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index 183daeef8b5..3700924ed15 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -355,7 +355,7 @@ fn test_dead_symbol_pointer_pruned_live_symbol_survives_full_gc() { #[test] fn test_dead_owner_symbol_property_entries_pruned_on_full_gc() { let _guard = GcTestIsolationGuard::new(); - let (obj, _) = unsafe { alloc_nursery_test_object(0) }; + let obj = crate::array::js_array_alloc(0); let owner = obj as usize; let sym = unsafe { alloc_old_test_symbol() }; crate::symbol::test_seed_symbol_property_root(owner, sym, crate::value::TAG_TRUE); @@ -566,24 +566,18 @@ fn test_dead_owner_prototype_vm_expando_and_filehandle_entries_pruned() { ); } -/// `DOM_EXCEPTION_ERRORS` had zero removals; it is now folded into the -/// `ErrorSideTables` death cleanup that every dead error already runs. +/// The DOMException brand is carried by the cell, so recycling an address +/// cannot inherit a stale registry entry. #[test] -fn test_dom_exception_set_cleared_with_error_side_tables() { +fn dom_exception_brand_is_owned_by_its_error_cell() { let _global = global_side_table_test_lock(); - let addr: usize = 0xD0E0_0000_0000_2026; - crate::event_target::test_seed_dom_exception_error(addr); - assert!(crate::event_target::test_dom_exception_error_registered( - addr - )); - - crate::node_submodules::diagnostics_gc::error_side_tables_clear_dead(addr); - - assert!( - !crate::event_target::test_dom_exception_error_registered(addr), - "dead error must be removed from DOM_EXCEPTION_ERRORS by the error \ - side-table death cleanup" - ); + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let err = crate::event_target::js_dom_exception_new(undef, undef); + assert!(crate::event_target::is_dom_exception_error(err)); + unsafe { + (*err).error_kind = crate::error::ERROR_KIND_ERROR; + } + assert!(!crate::event_target::is_dom_exception_error(err)); } mod meta_and_shape_records; diff --git a/crates/perry-runtime/src/gc/tests/event_internal_slots.rs b/crates/perry-runtime/src/gc/tests/event_internal_slots.rs new file mode 100644 index 00000000000..f6a9dbfcef5 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/event_internal_slots.rs @@ -0,0 +1,108 @@ +//! Symbol properties are ordinary traced shape slots, including private keys. +use super::super::*; +use super::support::*; + +#[test] +fn event_symbol_properties_survive_a_moving_collection() { + let _guard = CopyingNurseryTestGuard::new(1); + unsafe { + let owner = crate::event_target::js_event_target_new(); + js_shadow_slot_set(0, ptr_bits(owner as usize)); + let (child, _) = alloc_nursery_test_object(0); + let owner = (js_shadow_slot_get(0) & POINTER_MASK) as *mut crate::ObjectHeader; + crate::event_target::state::set_slot( + owner, + crate::event_target::state::LISTENERS, + f64::from_bits(ptr_bits(child as usize)), + ); + let keys = crate::object::object_keys(owner); + assert_eq!( + keys.count(), + 4, + "EventTarget state must be ordinary own symbol properties" + ); + assert_eq!( + crate::object::object_live_slot_count(owner), + crate::object::INLINE_SLOT_FLOOR as u32 + ); + // Accessing existing symbol state must not allocate managed strings + // or another object. Distinct numbers exercise both loads and stores. + let allocated = crate::arena::arena_live_allocated_bytes(); + for value in 0..128 { + crate::event_target::state::set_slot( + owner, + crate::event_target::state::MAX_LISTENERS, + value as f64, + ); + assert_eq!( + crate::event_target::state::get_slot( + owner, + crate::event_target::state::MAX_LISTENERS + ), + value as f64 + ); + } + assert_eq!(crate::arena::arena_live_allocated_bytes(), allocated); + let before = + crate::event_target::state::get_slot(owner, crate::event_target::state::LISTENERS) + .to_bits(); + let _ = gc_collect_minor(); + let after = (js_shadow_slot_get(0) & POINTER_MASK) as *mut crate::ObjectHeader; + assert_ne!(after, owner, "premise: the owner must move"); + let child_after = + crate::event_target::state::get_slot(after, crate::event_target::state::LISTENERS) + .to_bits(); + assert_ne!( + child_after, before, + "the symbol slot must mark and rewrite its child" + ); + assert!(crate::value::addr_class::try_read_tracked_gc_header( + (child_after & POINTER_MASK) as usize + ) + .is_some()); + js_shadow_slot_set(0, 0); + } +} + +#[test] +fn promoted_event_symbol_property_barrier_keeps_a_new_child() { + let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = tenuring::set_survivals_for_test(tenuring::GC_TENURING_SURVIVALS_MAX); + let mut owner = crate::event_target::js_event_target_new(); + js_shadow_slot_set(0, ptr_bits(owner as usize)); + for _ in 0..tenuring::GC_TENURING_SURVIVALS_MAX { + let _ = gc_collect_minor(); + owner = (js_shadow_slot_get(0) & POINTER_MASK) as *mut crate::ObjectHeader; + } + assert!(crate::arena::pointer_in_old_gen(owner as usize)); + unsafe { + let (child, _) = alloc_nursery_test_object(0); + let slots = (owner as *const u8).add(std::mem::size_of::()); + let page = crate::arena::generation_page_for_addr(slots as usize); + crate::arena::old_page_clear_dirty(page); + assert!( + !old_page_dirty_for(page), + "premise: no prior store may cover this slot" + ); + crate::event_target::state::set_slot( + owner, + crate::event_target::state::LISTENERS, + f64::from_bits(ptr_bits(child as usize)), + ); + assert!( + old_page_dirty_for(page), + "symbol-slot stores must dirty their page" + ); + let _ = gc_collect_minor(); + let after = + crate::event_target::state::get_slot(owner, crate::event_target::state::LISTENERS) + .to_bits() + & POINTER_MASK; + assert_ne!( + after as usize, child as usize, + "the child must move through the remembered edge" + ); + assert!(crate::value::addr_class::try_read_tracked_gc_header(after as usize).is_some()); + } + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index c0d1836c387..9ec4324d678 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -117,3 +117,5 @@ mod zero_slot_skip; mod dyn_eval_registry; mod private_evaluation_storage; + +mod event_internal_slots; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs index 1a1943d11f6..99110177688 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs @@ -207,7 +207,8 @@ fn test_symbol_side_table_scanner_marks_keys_and_values_without_marking_owner() clear_mark_seeds(); crate::symbol::test_clear_symbol_side_table_roots(); - let owner = crate::object::js_object_alloc(0, 0) as usize; + // Ordinary-object symbols are shape slots; arrays still use this scanner. + let owner = crate::array::js_array_alloc(0) as usize; let sym_key = unsafe { alloc_nursery_test_symbol() }; let value = young_leaf(); let static_sym_key = unsafe { alloc_nursery_test_symbol() }; @@ -244,14 +245,15 @@ fn test_symbol_side_table_registered_scanner_rewrites_roots_and_metadata() { crate::symbol::test_clear_symbol_side_table_roots(); gc_register_mutable_root_scanner(crate::symbol::scan_symbol_side_table_roots_mut); - let owner = crate::object::js_object_alloc(0, 0) as usize; + // Ordinary-object symbols are shape slots; arrays still use this scanner. + let owner = crate::array::js_array_alloc(0) as usize; let sym_key = unsafe { alloc_nursery_test_symbol() }; let value = young_leaf(); let static_sym_key = unsafe { alloc_nursery_test_symbol() }; let static_value = young_leaf(); let valid_ptrs = build_valid_pointer_set(); - let owner_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_OBJECT) as usize; + let owner_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_ARRAY) as usize; let sym_key_old = unsafe { alloc_old_test_symbol() }; let value_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_STRING) as usize; let static_sym_key_old = unsafe { alloc_old_test_symbol() }; @@ -326,11 +328,12 @@ fn test_symbol_side_table_budgeted_scanner_heals_entries_after_owner_rekey() { let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); crate::symbol::test_clear_symbol_side_table_roots(); - let owner = crate::object::js_object_alloc(0, 0) as usize; + // Ordinary-object symbols are shape slots; arrays still use this scanner. + let owner = crate::array::js_array_alloc(0) as usize; let sym_key = unsafe { alloc_nursery_test_symbol() }; let value = young_leaf(); let valid_ptrs = build_valid_pointer_set(); - let owner_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_OBJECT) as usize; + let owner_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_ARRAY) as usize; let sym_key_old = unsafe { alloc_old_test_symbol() }; let value_old = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_STRING) as usize; unsafe { diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index 9a22ac5f0e4..5b737505ef6 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -13,7 +13,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; -const FAMILY_COUNT: usize = 13; +const FAMILY_COUNT: usize = 14; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -34,6 +34,7 @@ pub enum ReceiverReprFamily { SymbolGlobal, ExternalBuffer, Sab, + EventTarget, NullStub, } @@ -52,6 +53,7 @@ impl ReceiverReprFamily { Self::SymbolGlobal, Self::ExternalBuffer, Self::Sab, + Self::EventTarget, Self::NullStub, ]; @@ -68,6 +70,7 @@ impl ReceiverReprFamily { "symbol_global", "external_buffer", "sab", + "event_target", "null_stub", ]; @@ -500,6 +503,15 @@ mod tests { assert_fixture(ReceiverReprFamily::Sab, || { (crate::shared_sab::alloc_shared_sab(1) as usize, false) }); + // These cells were already pointers: migration also requires that + // private state no longer appears among their own string properties. + for make in crate::event_target::honest_tests::constructors() { + assert_fixture_migrated(ReceiverReprFamily::EventTarget, || { + let value = make(); + crate::event_target::honest_tests::assert_surface(value); + (value.to_bits() & POINTER_MASK) as usize + }); + } // #340/#341: `null_stub` is migrated — gate A, inverted (see `text`). assert_fixture_migrated(ReceiverReprFamily::NullStub, || { (crate::object::js_unresolved_namespace_stub().to_bits() & crate::value::POINTER_MASK) @@ -525,6 +537,7 @@ mod tests { let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); let workspace = manifest.parent().and_then(Path::parent).unwrap(); let witnesses = [ + ("crates/perry-runtime/src/event_target.rs", "EventTarget", 1), // Both Common registration entrypoints now funnel through one // payload publisher and therefore share one diagnostic bump. ("crates/perry-stdlib/src/common/handle.rs", "Common", 1), diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index df6c11a8946..43effeba47b 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -456,7 +456,9 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let replacer = replacer_root.get_raw_const_ptr::(); // #9398: tombstoned slot from an O(1) delete — not a key, not // serialized. See `tombstoned_key_slot`. - if tombstoned_key_slot(*keys_elements.add(f as usize)) { + if tombstoned_key_slot(*keys_elements.add(f as usize)) + || super::stringify::is_symbol_value((*keys_elements.add(f as usize)).to_bits()) + { continue; } // #11232: physical class capture/private slots are not JS properties. @@ -1055,7 +1057,9 @@ pub(crate) unsafe fn stringify_object_pretty( for f in 0..actual_fields { // #9398: tombstoned slot from an O(1) delete — not a key, not // serialized. See `tombstoned_key_slot`. - if tombstoned_key_slot(*keys_elements.add(f as usize)) { + if tombstoned_key_slot(*keys_elements.add(f as usize)) + || super::stringify::is_symbol_value((*keys_elements.add(f as usize)).to_bits()) + { continue; } // #11232: physical class capture/private slots are not JS properties. @@ -1265,7 +1269,9 @@ pub(crate) unsafe fn stringify_object_with_array_replacer( for f in 0..actual_fields { // #9398: tombstoned slot from an O(1) delete — not a key, not // serialized. See `tombstoned_key_slot`. - if tombstoned_key_slot(*keys_elements.add(f as usize)) { + if tombstoned_key_slot(*keys_elements.add(f as usize)) + || super::stringify::is_symbol_value((*keys_elements.add(f as usize)).to_bits()) + { continue; } // #11232: physical class capture/private slots are not JS properties. diff --git a/crates/perry-runtime/src/json/stringify_object.rs b/crates/perry-runtime/src/json/stringify_object.rs index 33d4019179c..b3055fa285a 100644 --- a/crates/perry-runtime/src/json/stringify_object.rs +++ b/crates/perry-runtime/src/json/stringify_object.rs @@ -531,7 +531,7 @@ unsafe fn stringify_object_walk( } let mut key_bits = (*key_slots.add(f as usize)).to_bits(); // Tombstoned slot from an O(1) delete: not a key, not serialized. - if key_bits == crate::value::TAG_HOLE { + if key_bits == crate::value::TAG_HOLE || is_symbol_value(key_bits) { continue; } // Private elements (`#x`) live in a class instance's keys_array but are diff --git a/crates/perry-runtime/src/json/stringify_primitive_object.rs b/crates/perry-runtime/src/json/stringify_primitive_object.rs index 0855d0c7d94..1229e047982 100644 --- a/crates/perry-runtime/src/json/stringify_primitive_object.rs +++ b/crates/perry-runtime/src/json/stringify_primitive_object.rs @@ -83,7 +83,7 @@ unsafe fn emit( for j in 0..keys_view.count() as usize { let f = order.map_or(j, |indices| indices[j] as usize); let key_bits = *key_slots.add(f); - if key_bits == crate::value::TAG_HOLE { + if key_bits == crate::value::TAG_HOLE || is_symbol_value(key_bits) { continue; } let bits = *fields.add(f); diff --git a/crates/perry-runtime/src/native_class_ids.rs b/crates/perry-runtime/src/native_class_ids.rs index 15e00d65bb8..c0d27808c41 100644 --- a/crates/perry-runtime/src/native_class_ids.rs +++ b/crates/perry-runtime/src/native_class_ids.rs @@ -62,7 +62,7 @@ pub(crate) const ASYNC_RESOURCE_LEGACY: u32 = 0xFFFF_0079; /// The first id in the native-state range and the last one, inclusive. Every /// family between them carries a `native_state` word the far side of a /// `postMessage` could not reconstruct. -const NATIVE_BACKED_FIRST: u32 = TEXT_ENCODER; +const NATIVE_BACKED_FIRST: u32 = ABORT_CONTROLLER; const NATIVE_BACKED_LAST: u32 = ASYNC_HOOK; /// Class ids whose instances are ordinary objects carrying native state that @@ -138,6 +138,12 @@ mod tests { #[test] fn the_transfer_guard_covers_exactly_the_migrated_families() { for id in [ + ABORT_CONTROLLER, + ABORT_SIGNAL, + EVENT, + CUSTOM_EVENT, + DOM_EXCEPTION, + EVENT_TARGET, TEXT_ENCODER, TEXT_DECODER, TIMEOUT, @@ -156,17 +162,7 @@ mod tests { "{id:#x} carries native state but crosses a thread boundary" ); } - for id in [ - ABORT_CONTROLLER, - ABORT_SIGNAL, - EVENT, - CUSTOM_EVENT, - DOM_EXCEPTION, - EVENT_TARGET, - 0, - 1, - 0x8000_0000, - ] { + for id in [0, 1, 0x8000_0000] { assert!(!is_native_backed_class_id(id), "{id:#x} is not migrated"); } } diff --git a/crates/perry-runtime/src/node_submodules/diagnostics_gc.rs b/crates/perry-runtime/src/node_submodules/diagnostics_gc.rs index 511a1999cad..019ee221615 100644 --- a/crates/perry-runtime/src/node_submodules/diagnostics_gc.rs +++ b/crates/perry-runtime/src/node_submodules/diagnostics_gc.rs @@ -35,10 +35,6 @@ pub(crate) fn error_side_tables_clear_dead(user_ptr: usize) { ERROR_DIAGNOSTICS.with(|m| { m.borrow_mut().remove(&user_ptr); }); - // 2026-07-09 GC audit wave 2: the DOMException brand set is address- - // keyed with zero removals — clean it up with the rest of the error - // side tables (latch-gated no-op unless a DOMException was ever made). - crate::event_target::dom_exception_error_clear_dead(user_ptr); } /// Copied-minor counterpart of the finalize hook (the fast path sweeps diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 2aef916811f..5dd2d9f5cf7 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -729,6 +729,55 @@ pub unsafe extern "C" fn js_super_construct_apply( ); return undef; } + // A spread super call must initialize the same receiver as a direct + // call. Only the terminal native constructor owns this initialization; + // any registered user constructor above already ran and returned. + if matches!( + cur, + crate::native_class_ids::EVENT_TARGET + | crate::native_class_ids::ABORT_CONTROLLER + | crate::native_class_ids::ABORT_SIGNAL + | crate::native_class_ids::EVENT + | crate::native_class_ids::CUSTOM_EVENT + | crate::native_class_ids::DOM_EXCEPTION + ) { + let count = if arr.is_null() { + 0 + } else { + crate::array::js_array_length(arr) + }; + let arg = |i| { + if i < count { + crate::array::js_array_get_f64(arr, i) + } else { + undef + } + }; + match cur { + crate::native_class_ids::EVENT_TARGET => { + crate::event_target::js_event_target_subclass_init(this_value, 0); + } + crate::native_class_ids::ABORT_CONTROLLER => { + crate::event_target::js_event_target_subclass_init(this_value, 1); + } + crate::native_class_ids::ABORT_SIGNAL => { + crate::event_target::js_event_target_subclass_init(this_value, 2); + } + crate::native_class_ids::EVENT | crate::native_class_ids::CUSTOM_EVENT => { + crate::event_target::js_event_subclass_init( + this_value, + arg(0), + arg(1), + count, + u32::from(cur == crate::native_class_ids::CUSTOM_EVENT), + ); + } + _ => { + crate::event_target::js_dom_exception_subclass_init(this_value, arg(0), arg(1)); + } + } + return undef; + } let next = crate::object::get_parent_class_id(cur).unwrap_or(0); if next == cur { break; diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 680fd95ade2..8e408eb3ac3 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -892,6 +892,17 @@ unsafe fn resolve_proto_chain_field_inner( } } } + // A native parent is represented by its actual prototype rather than a + // synthetic class-registry entry. Finish the declared prototype's real + // chain using own descriptors; recursively entering the generic getter + // can revisit this same class walk and mistake that recursion for a miss. + if let Some(receiver) = receiver { + return evaluated_parent_instance_field( + class_decl_prototype_object(class_id), + key, + receiver, + ); + } None } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index da95bbd52f5..9143b7173df 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1075,6 +1075,18 @@ fn class_parent_prototype_bits(value: f64) -> Option { /// have their own dedicated instance/prototype modeling and don't reach this /// fallback the same way. fn reserved_native_parent_prototype_bits(parent_id: u32) -> Option { + let web = match parent_id { + crate::native_class_ids::EVENT_TARGET => Some("EventTarget"), + crate::native_class_ids::EVENT => Some("Event"), + crate::native_class_ids::CUSTOM_EVENT => Some("CustomEvent"), + crate::native_class_ids::ABORT_CONTROLLER => Some("AbortController"), + crate::native_class_ids::ABORT_SIGNAL => Some("AbortSignal"), + crate::native_class_ids::DOM_EXCEPTION => Some("DOMException"), + _ => None, + }; + if let Some(name) = web { + return class_parent_prototype_bits(super::super::builtin_prototype_value(name)); + } const CLASS_ID_EVENT_EMITTER: u32 = 0xFFFF0076; const CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE: u32 = 0xFFFF0077; let (module, symbol) = match parent_id { @@ -1124,7 +1136,7 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { // read of one is then served by the inherited-read cache (spilled slots // never prime there). let members = class_prototype_member_names(class_id).len() as u32; - let proto = js_object_alloc(class_id, members + 1); + let proto = crate::object::js_object_alloc(class_id, members + 1); if proto.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 68409e3918e..72c1d21e9cb 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1383,7 +1383,8 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { let mut sso_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; for i in 0..len { let key_val = crate::array::js_array_get(keys, pos(i)); - if key_val.bits() == crate::value::TAG_HOLE + if !key_val.is_any_string() + || key_val.bits() == crate::value::TAG_HOLE || key_val.bits() == crate::value::TAG_UNDEFINED { continue; @@ -1430,7 +1431,8 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { // Tombstoned slot from an O(1) delete: not a key. Same raw-push // hole hazard as `js_object_keys`' fast path — this loop emitted // the marker itself (visible as `null` in getOwnPropertyNames). - if key_val.bits() == crate::value::TAG_HOLE + if !key_val.is_any_string() + || key_val.bits() == crate::value::TAG_HOLE || key_val.bits() == crate::value::TAG_UNDEFINED { // Tombstoned slot from an O(1) delete. `js_array_get` translates diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 27d84f4df0a..41be78a646a 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -375,19 +375,24 @@ pub(crate) unsafe fn ordinary_object_prototype_property_value( // Object.prototype, so a user-selected parent on the declaration // prototype was skipped. Walk the materialized declaration prototype // on a vtable miss, preserving the instance as the accessor receiver. - let decl_proto = super::super::class_decl_prototype_object(class_id); - if !decl_proto.is_null() - && super::super::prototype_chain::object_has_user_prototype_override( - decl_proto as usize, - ) - { - let _guard = object_prototype_lookup_guard()?; - if let Some(value) = - prototype_property_value_with_guard(decl_proto as usize, obj as usize, key) - { + let _guard = object_prototype_lookup_guard()?; + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_raw_mut_ptr(obj as *mut ObjectHeader); + let key = scope.root_string_ptr(key); + let decl_proto = super::super::class_decl_prototype_value(class_id); + if crate::value::JSValue::from_bits(decl_proto.to_bits()).is_pointer() { + let addr = crate::value::js_nanbox_get_pointer(decl_proto) as usize; + if let Some(value) = receiver.with_mut_ptr::(|ptr| { + key.with_const_ptr(|key| { + prototype_property_value_with_guard(addr, ptr as usize, key) + }) + }) { return Some(value); } } + return receiver.with_mut_ptr::(|ptr| { + key.with_const_ptr(|key| default_object_prototype_property_value(ptr as usize, key)) + }); } default_object_prototype_property_value(obj as usize, key) } diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index 0b6bf6e98f0..273abbfbda3 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1551,7 +1551,8 @@ fn js_object_keys_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // path below skips holes for free (`js_string_key_bytes` // rejects them); this raw-push path must skip explicitly or // `Object.keys` would emit the hole marker itself. - if key_val.bits() == crate::value::TAG_HOLE + if !key_val.is_any_string() + || key_val.bits() == crate::value::TAG_HOLE || key_val.bits() == crate::value::TAG_UNDEFINED { // Tombstoned slot from an O(1) delete. `js_array_get` translates diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 7cd6c780b77..28f8e48291a 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -717,6 +717,17 @@ pub(crate) fn get_field_by_name_object_tail( return JSValue::from_bits(v.to_bits()); } } + if !crate::error::js_error_has_own_property( + err_ptr, + std::str::from_utf8(key_bytes).unwrap_or(""), + ) { + if let Some(value) = super::super::prototype_chain::resolve_inherited_field( + err_ptr as usize, + key, + ) { + return value; + } + } match key_bytes { b"message" => { let s = crate::error::js_error_get_message(err_ptr); @@ -1182,23 +1193,6 @@ 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::()); - 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 @@ -1895,21 +1889,6 @@ 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()); - } - } - // Key not found JSValue::undefined() } diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 7aaa9cfbb8c..084fee12e41 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -526,7 +526,6 @@ fn is_uncallable_builtin_super_parent(name: &str) -> bool { | "Set" | "WeakMap" | "WeakSet" - | "EventTarget" | "Array" | "ArrayBuffer" | "SharedArrayBuffer" @@ -565,7 +564,6 @@ fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { "Set", "WeakMap", "WeakSet", - "EventTarget", "Array", "ArrayBuffer", "DataView", @@ -950,6 +948,44 @@ pub unsafe extern "C" fn js_fetch_or_value_super( _ => None, } }); + // These bases initialize the existing derived receiver's own symbol + // properties; replacing it would lose derived fields and prototype identity. + match kind { + Some("EventTarget") => { + return crate::event_target::js_event_target_subclass_init(this_box, 0) + } + Some("AbortController") => { + return crate::event_target::js_event_target_subclass_init(this_box, 1) + } + Some("AbortSignal") => { + return crate::event_target::js_event_target_subclass_init(this_box, 2) + } + Some("Event") | Some("CustomEvent") => { + let arg0 = if args_len > 0 { *args_ptr } else { undef }; + let arg1 = if args_len > 1 { + *args_ptr.add(1) + } else { + undef + }; + return crate::event_target::js_event_subclass_init( + this_box, + arg0, + arg1, + args_len as u32, + u32::from(kind == Some("CustomEvent")), + ); + } + Some("DOMException") => { + let arg0 = if args_len > 0 { *args_ptr } else { undef }; + let arg1 = if args_len > 1 { + *args_ptr.add(1) + } else { + undef + }; + return crate::event_target::js_dom_exception_subclass_init(this_box, arg0, arg1); + } + _ => {} + } // A native builtin base that cannot be called as a function still has to // perform its [[Construct]] work for `super()`. Construct it with the // current subclass as newTarget so the result carries the builtin's real diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index c64501e7095..06658092e39 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -406,6 +406,7 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade // entry point — works in tandem with `.call`/`.apply` since // those arms (#970) rebind IMPLICIT_THIS before forwarding. populate_builtin_prototype_methods(name, proto_obj); + crate::event_target::prototype::install_constructor_constants(name, closure_ptr.cast()); if name == "Function" { // SAFETY: `proto_obj` is the live, just-populated prototype. unsafe { @@ -558,14 +559,19 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade super::super::PropertyAttrs::new(true, false, true), ); } - // WebIDL inheritance carries prototype method values across these two - // interface pairs. Both constructors have been installed by this point. - for (child, parent) in [("AbortSignal", "EventTarget"), ("CustomEvent", "Event")] { + // Interface inheritance carries prototype methods. DOMException has + // Error.prototype as its instance parent, but no Error constructor parent. + for (child, parent, constructor_inherits) in [ + ("AbortSignal", "EventTarget", true), + ("CustomEvent", "Event", true), + ("DOMException", "Error", false), + ] { let child_ctor = js_get_global_this_builtin_value(child.as_ptr(), child.len()); let parent_ctor = js_get_global_this_builtin_value(parent.as_ptr(), parent.len()); let child_proto = builtin_prototype_value(child); let parent_proto = builtin_prototype_value(parent); - if JSValue::from_bits(child_ctor.to_bits()).is_pointer() + if constructor_inherits + && JSValue::from_bits(child_ctor.to_bits()).is_pointer() && JSValue::from_bits(parent_ctor.to_bits()).is_pointer() { crate::closure::closure_set_static_prototype( diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 0d21242c22a..ec88353ab5d 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -19,20 +19,6 @@ extern "C" fn url_prototype_href_thunk(_closure: *const crate::closure::ClosureH crate::url::js_url_get_href(web_method_receiver("URL")) } -extern "C" fn abort_controller_prototype_abort_thunk( - _closure: *const crate::closure::ClosureHeader, - reason: f64, -) -> f64 { - crate::url::js_abort_controller_abort_reason(web_method_receiver("AbortController"), reason); - f64::from_bits(crate::value::TAG_UNDEFINED) -} - -extern "C" fn abort_signal_prototype_throw_if_aborted_thunk( - _closure: *const crate::closure::ClosureHeader, -) -> f64 { - crate::url::js_abort_signal_throw_if_aborted(web_method_receiver("AbortSignal")) -} - fn web_method_enumerable(proto_obj: *mut ObjectHeader, name: &str) { super::super::set_builtin_property_attrs( proto_obj as usize, @@ -1366,74 +1352,14 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: install_proto_method(proto(), "toJSON", url_prototype_href_thunk as *const u8, 0); web_method_enumerable(proto(), "toJSON"); } - "AbortController" => { - let scope = crate::gc::RuntimeHandleScope::new(); - let proto_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(proto_obj as i64)); - let proto = || { - crate::value::js_nanbox_get_pointer(proto_h.get_nanbox_f64()) as *mut ObjectHeader - }; - // The thunk takes the `reason` argument, but WebIDL's - // `abort(optional any reason)` is an optional argument, so the - // spec `.length` is 0 -- overwrite the arity-derived value. - let method = install_proto_method( - proto(), - "abort", - abort_controller_prototype_abort_thunk as *const u8, - 1, - ); - if JSValue::from_bits(method.to_bits()).is_pointer() { - let closure = crate::value::js_nanbox_get_pointer(method) as usize; - super::super::native_module::set_builtin_closure_length(closure, 0); - } - web_method_enumerable(proto(), "abort"); - unsafe { install_web_builtin_to_string_tag(proto(), "AbortController") }; - } - "AbortSignal" => { - let scope = crate::gc::RuntimeHandleScope::new(); - let proto_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(proto_obj as i64)); - let proto = || { - crate::value::js_nanbox_get_pointer(proto_h.get_nanbox_f64()) as *mut ObjectHeader - }; - // DELIBERATELY NOT `web_method_enumerable`. Node deviates from - // plain WebIDL for this one member: `throwIfAborted` and the - // `reason` accessor are both NON-enumerable on - // `AbortSignal.prototype`, while every other operation in this - // group is enumerable. Measured on the pinned oracle - // (`.node-version`, v26.5.1): - // - // AbortSignal.throwIfAborted method enumerable=false - // configurable=true - // writable=true length=0 - // AbortSignal.reason accessor enumerable=false - // AbortSignal.aborted accessor enumerable=true - // Object.keys(AbortSignal.prototype) === ["aborted", "onabort"] - // - // contrast `AbortController.abort` / `EventTarget.*` / `Event.*`, - // all enumerable=true. `install_proto_method`'s own default is - // `{ writable: true, enumerable: false, configurable: true }`, so - // the correct thing here is to add NO enumerability override. - // Do not "fix" this into the shared helper -- a uniform install - // across the five arms silently diverges from Node. - // `test_gap_11003_web_proto_descriptors.ts` pins the descriptor - // and the matching `Object.keys` read; the behavioural half is in - // `test_gap_10808_web_proto_methods.ts`. - install_proto_method( - proto(), - "throwIfAborted", - abort_signal_prototype_throw_if_aborted_thunk as *const u8, - 0, - ); - unsafe { install_web_builtin_to_string_tag(proto(), "AbortSignal") }; - } - "EventTarget" | "Event" => { - let scope = crate::gc::RuntimeHandleScope::new(); - let proto_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(proto_obj as i64)); + "AbortController" | "AbortSignal" | "EventTarget" | "Event" | "CustomEvent" + | "DOMException" => { + let _gc = crate::gc::GcSuppressScope::new(); crate::event_target::install_web_event_proto_methods(builtin_name, proto_obj); - let proto = - crate::value::js_nanbox_get_pointer(proto_h.get_nanbox_f64()) as *mut ObjectHeader; - unsafe { install_web_builtin_to_string_tag(proto, builtin_name) }; + unsafe { + install_web_builtin_to_string_tag(proto_obj, builtin_name); + } } - "CustomEvent" => unsafe { install_web_builtin_to_string_tag(proto_obj, "CustomEvent") }, _ => {} } } diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index 3f0ead8b1b8..b8323963534 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -9,6 +9,12 @@ use super::*; /// Heap-backed builtins whose reserved ids otherwise dispatch by native brand. fn heap_builtin_name(class_id: u32) -> Option<&'static str> { Some(match class_id { + crate::native_class_ids::EVENT_TARGET => "EventTarget", + crate::native_class_ids::EVENT => "Event", + crate::native_class_ids::CUSTOM_EVENT => "CustomEvent", + crate::native_class_ids::ABORT_CONTROLLER => "AbortController", + crate::native_class_ids::ABORT_SIGNAL => "AbortSignal", + crate::native_class_ids::DOM_EXCEPTION => "DOMException", crate::buffer::BUFFER_TYPE_ID => "Uint8Array", crate::buffer::NODE_BUFFER_CLASS_ID => "Buffer", 0xFFFF0020 => "Date", @@ -113,6 +119,25 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { if instanceof_lhs_is_primitive(value) { return false_val; } + // #11256: Object.create(Builtin.prototype) has no native brand, but is + // still an instance of Builtin. Ordinary objects may store their chain + // through a synthetic class id rather than per-object prototype metadata. + // Native cells retain their brand path unless their prototype is recorded. + // Only a hit is final: a miss falls through to the class-id chain, which + // still knows builtin subclasses whose prototype chain is not materialized. + if let Some(name) = heap_builtin_name(class_id) { + let ordinary = unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) } + .is_some_and(|header| header.obj_type == crate::gc::GC_TYPE_OBJECT); + let matches = if ordinary { + prototype_instanceof_builtin(value, name) + } else { + recorded_prototype_instanceof_builtin(value, name) + }; + if matches == Some(true) { + return true_val; + } + } + // Subclass-of-built-in: see `subclass_of_builtin_reaches`. if subclass_of_builtin_reaches(value, class_id) { return true_val; @@ -291,25 +316,6 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { let bits = value.to_bits(); let jsval = crate::JSValue::from_bits(bits); - // #11256: Object.create(Builtin.prototype) has no native brand, but is - // still an instance of Builtin. Ordinary objects may store their chain - // through a synthetic class id rather than per-object prototype metadata. - // Native cells retain their brand path unless their prototype is recorded. - // Only a hit is final: a miss falls through to the class-id chain, which - // still knows builtin subclasses whose prototype chain is not materialized. - if let Some(name) = heap_builtin_name(class_id) { - let ordinary = unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) } - .is_some_and(|header| header.obj_type == crate::gc::GC_TYPE_OBJECT); - let matches = if ordinary { - prototype_instanceof_builtin(value, name) - } else { - recorded_prototype_instanceof_builtin(value, name) - }; - if matches == Some(true) { - return true_val; - } - } - // Native/exotic subclass instances (typed arrays, ArrayBuffers, boxed // primitives, Dates, …) do not carry a Perry `ObjectHeader.class_id`. // Their constructor records the distinct newTarget prototype in the diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 30e4a1660ca..de743c80033 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -702,6 +702,7 @@ const KEYS_INDEX_THRESHOLD: u32 = 32; #[path = "keys_lookup.rs"] mod keys_lookup; mod object_keys; +pub(crate) mod shaped_symbols; pub(crate) use object_keys::ObjectKeys; pub(crate) mod read_stub; pub(crate) use keys_lookup::*; @@ -1478,6 +1479,7 @@ pub fn scan_object_cache_roots(mark: &mut dyn FnMut(f64)) { } pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + crate::event_target::state::scan_roots(visitor); // Object-owned weak layout caches: rewrite moves without retaining keys. canonical_keys::scan_canonical_keys_roots_mut(visitor); scan_class_keys_roots_mut(visitor); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 2bb9a538317..62e85e9013c 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1560,47 +1560,6 @@ pub unsafe extern "C-unwind" fn js_native_call_method( ) { return result; } - // 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), - }; - } - } // 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 @@ -2753,33 +2712,6 @@ pub unsafe extern "C-unwind" 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 " 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()); - } - } - } - // #10893: `c.g(args)` where `g` is an ACCESSOR rather than a method. Every // arm above probes for a callable VALUE — vtable methods, own fields, the // prototype chain — but none of them RUNS a getter, so a class exposing a diff --git a/crates/perry-runtime/src/object/shaped_symbols.rs b/crates/perry-runtime/src/object/shaped_symbols.rs new file mode 100644 index 00000000000..4a5ab80f606 --- /dev/null +++ b/crates/perry-runtime/src/object/shaped_symbols.rs @@ -0,0 +1,273 @@ +//! Symbol data properties use the ordinary object's shape and property slots. +//! A private entry is runtime-only and is excluded from reflection. +use super::*; + +pub(crate) const PRIVATE_ENTRY: u8 = 0x40; + +pub(crate) unsafe fn owner(addr: usize) -> Option<*mut ObjectHeader> { + let header = crate::value::addr_class::try_read_tracked_gc_header(addr)?; + (header.as_ref().obj_type == crate::gc::GC_TYPE_OBJECT).then_some(addr as *mut ObjectHeader) +} + +pub(crate) unsafe fn position(obj: *const ObjectHeader, symbol: usize) -> Option { + let keys = object_keys(obj); + let bits = crate::value::POINTER_TAG | symbol as u64; + let (slots, count) = keys.dense_slots(); + (0..count) + .find(|&i| (*slots.add(i)).to_bits() == bits) + .map(|i| i as u32) +} + +pub(crate) unsafe fn get(addr: usize, symbol: usize) -> Option { + let obj = owner(addr)?; + let index = position(obj, symbol)?; + Some(js_object_get_field(obj, index).bits()) +} + +/// Root all operands while publishing the shared layout and growing storage. +/// Existing symbol keys share exactly the same indexed store as string keys. +pub(crate) unsafe fn define(addr: usize, symbol: usize, bits: u64, entry: u8) -> bool { + let Some(obj) = owner(addr) else { + return false; + }; + let _no_move = crate::gc::GcSuppressScope::new(); + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let symbol = scope.root_nanbox_u64(crate::value::POINTER_TAG | symbol as u64); + let value = scope.root_nanbox_u64(bits); + obj.with_mut_ptr::(|current| { + let existing = position( + current, + (symbol.get_nanbox_u64() & crate::value::POINTER_MASK) as usize, + ); + let index = if let Some(index) = existing { + index + } else { + // Reserved native-layout fields precede dynamic keys just as they do + // on the string-property append path. + if object_keys(current).is_null() && reserved_slot_floor_for_object(current) != 0 { + ensure_reserved_floor_keys(current); + } + let keys = object_keys(current); + let index = keys.count(); + let key = JSValue::from_bits(symbol.get_nanbox_u64()); + let next = if let Some(proof) = canonical_keys::SharedLayout::of_receiver(current) { + let parent = canonical_keys::canonicalize(&proof, keys.arr(), keys.count()); + canonical_keys::extend_slot( + &proof, + parent, + canonical_keys::Appended::Slot(JSValue::from_bits(symbol.get_nanbox_u64())), + entry, + ) + .view() + } else { + let array = if keys.is_null() { + crate::array::js_array_alloc(0) + } else { + keys.arr() + }; + let grown = crate::array::js_array_push(array, key); + let grown = key_attrs::owned_note_append(grown, index, entry); + ObjectKeys::owned(grown) + }; + set_object_keys(current, next); + index + }; + let live = object_live_slot_count(current); + if index < live.max(INLINE_SLOT_FLOOR as u32) { + if index >= live { + set_object_live_slot_count(current, index + 1); + } + js_object_set_field(current, index, JSValue::from_bits(value.get_nanbox_u64())); + } else { + // The existing property spill is object-owned, traced storage. + overflow_set(current as usize, index as usize, value.get_nanbox_u64()); + } + set_entry( + current as usize, + (symbol.get_nanbox_u64() & crate::value::POINTER_MASK) as usize, + entry, + ); + crate::symbol::symbol_property_ic_epoch_bump(); + true + }) +} + +pub(crate) unsafe fn entries(addr: usize, include_private: bool) -> Vec<(usize, u64)> { + let Some(obj) = owner(addr) else { + return Vec::new(); + }; + let keys = object_keys(obj); + let (slots, count) = keys.dense_slots(); + let mut result = Vec::new(); + for i in 0..count { + let key = JSValue::from_bits((*slots.add(i)).to_bits()); + if !key.is_pointer() { + continue; + } + if !include_private && key_attrs::keys_entry(keys.arr(), i as u32) & PRIVATE_ENTRY != 0 { + continue; + } + let symbol = (key.bits() & crate::value::POINTER_MASK) as usize; + // A non-string key in this shape is an actual Symbol, never an encoded name. + result.push((symbol, js_object_get_field(obj, i as u32).bits())); + } + result +} + +pub(crate) unsafe fn entry(addr: usize, symbol: usize) -> Option { + let obj = owner(addr)?; + let pos = position(obj, symbol)?; + Some(key_attrs::keys_entry(object_keys(obj).arr(), pos)) +} + +pub(crate) unsafe fn set_entry(addr: usize, symbol: usize, entry: u8) { + let Some(obj) = owner(addr) else { + return; + }; + let Some(pos) = position(obj, symbol) else { + return; + }; + let _no_move = crate::gc::GcSuppressScope::new(); + let keys = object_keys(obj); + if key_attrs::keys_entry(keys.arr(), pos) == entry { + return; + } + if let Some(proof) = canonical_keys::SharedLayout::of_receiver(obj) { + let next = canonical_keys::rebuild_with_entries(&proof, keys, pos, |i, _, old| { + if i == pos { + entry + } else { + old + } + }); + set_object_keys(obj, next.view()); + } else { + let next = key_attrs::ensure_owned_attrs(keys.arr(), keys.count()); + key_attrs::attrs_set_owned(next, key_attrs::keys_attrs(next), pos, entry); + set_object_keys(obj, ObjectKeys::owned(next)); + shapes::transition_object_shape_semantics(obj); + } + crate::symbol::symbol_property_ic_epoch_bump(); +} + +pub(crate) unsafe fn accessor(addr: usize, symbol: usize) -> Option<(u64, u64)> { + if entry(addr, symbol)? & key_attrs::ENTRY_ACCESSOR == 0 { + return None; + } + let pair = + (get(addr, symbol)? & crate::value::POINTER_MASK) as *const crate::array::ArrayHeader; + let bits = |i| { + let bits = crate::array::js_array_get(pair, i).bits(); + if bits == crate::value::TAG_UNDEFINED { + 0 + } else { + bits + } + }; + Some((bits(0), bits(1))) +} + +pub(crate) unsafe fn define_accessor(addr: usize, symbol: usize, get: u64, set: u64) -> bool { + if owner(addr).is_none() { + return false; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(addr as *mut ObjectHeader); + let symbol = scope.root_nanbox_u64(crate::value::POINTER_TAG | symbol as u64); + let get = scope.root_nanbox_u64(if get == 0 { + crate::value::TAG_UNDEFINED + } else { + get + }); + let set = scope.root_nanbox_u64(if set == 0 { + crate::value::TAG_UNDEFINED + } else { + set + }); + let pair = scope.root_raw_mut_ptr(crate::array::js_array_alloc(2)); + let next = pair.with_mut_ptr(|ptr| { + crate::array::js_array_push(ptr, JSValue::from_bits(get.get_nanbox_u64())) + }); + pair.set_raw_mut_ptr(next); + let next = pair.with_mut_ptr(|ptr| { + crate::array::js_array_push(ptr, JSValue::from_bits(set.get_nanbox_u64())) + }); + pair.set_raw_mut_ptr(next); + obj.with_mut_ptr::(|ptr| { + let addr = ptr as usize; + let symbol = (symbol.get_nanbox_u64() & crate::value::POINTER_MASK) as usize; + let attrs = entry(addr, symbol).unwrap_or(0) & key_attrs::ENTRY_ATTR_MASK; + let flags = attrs + | key_attrs::ENTRY_ACCESSOR + | if get.get_nanbox_u64() != crate::value::TAG_UNDEFINED { + key_attrs::ENTRY_HAS_GET + } else { + 0 + } + | if set.get_nanbox_u64() != crate::value::TAG_UNDEFINED { + key_attrs::ENTRY_HAS_SET + } else { + 0 + }; + define( + addr, + symbol, + pair.with_const_ptr::(|ptr| { + JSValue::pointer(ptr.cast()).bits() + }), + flags, + ); + true + }) +} + +pub(crate) unsafe fn delete(addr: usize, symbol: usize) -> bool { + let Some(obj) = owner(addr) else { + return false; + }; + let Some(pos) = position(obj, symbol) else { + return true; + }; + let _no_move = crate::gc::GcSuppressScope::new(); + let keys = object_keys(obj); + let count = keys.count(); + let mut next = crate::array::js_array_alloc(count.saturating_sub(1)); + let (slots, _) = keys.dense_slots(); + for i in 0..count { + if i == pos { + continue; + } + let index = if i < pos { i } else { i - 1 }; + next = crate::array::js_array_push( + next, + JSValue::from_bits((*slots.add(i as usize)).to_bits()), + ); + next = key_attrs::owned_note_append(next, index, key_attrs::keys_entry(keys.arr(), i)); + if i > pos { + let value = js_object_get_field(obj, i); + store(obj, index, value.bits()); + } + } + store(obj, count - 1, crate::value::TAG_UNDEFINED); + let next = if let Some(proof) = canonical_keys::SharedLayout::of_receiver(obj) { + canonical_keys::canonicalize(&proof, next, count - 1).view() + } else { + ObjectKeys::owned(next) + }; + set_object_keys(obj, next); + crate::symbol::symbol_property_ic_epoch_bump(); + true +} + +unsafe fn store(obj: *mut ObjectHeader, index: u32, bits: u64) { + let live = object_live_slot_count(obj); + if index < live.max(INLINE_SLOT_FLOOR as u32) { + if index >= live { + set_object_live_slot_count(obj, index + 1); + } + js_object_set_field(obj, index, JSValue::from_bits(bits)); + } else { + overflow_set(obj as usize, index as usize, bits); + } +} diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index 16b18a1df93..c4e61982dbc 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -16,24 +16,8 @@ pub(crate) fn web_stream_to_string_tag(value: f64) -> Option<&'static str> { } } -/// `Symbol.toStringTag` for Perry's Web/runtime built-ins that carry no -/// registered class-id hook and (for the handle-backed ones) no real -/// `ObjectHeader` at all (#10555): `URL`/`URLSearchParams` (ordinary -/// class_id-0 objects, detected structurally — see `is_url_object_shape` / -/// `shape_is_url_search_params`), the Web Fetch family `Headers` / `Request` -/// / `Response` / `Blob` / `FormData` (small-int handles owned by -/// `perry-stdlib`, reached through `fetch_handle_kind_probe` — the same -/// probe `instanceof` already uses), `TextEncoder` / `TextDecoder` (small-int -/// handles owned by this crate's own `text` module), and the class-id-tagged -/// `AbortController` / `AbortSignal` / `EventTarget` / `Event` / `CustomEvent` -/// (real `ObjectHeader`s whose instances are never linked to their -/// `.prototype` object via `object_static_prototype`, so the generic -/// own/inherited-property walk in `object_to_string_tag_property` can never -/// reach a tag installed there). -/// -/// Shared by `js_object_to_string`'s brand string and -/// `js_object_get_symbol_property`'s `x[Symbol.toStringTag]` own-property -/// read (`crate::symbol::get`), so the two can never disagree. +/// Remaining Web built-ins without a recorded prototype chain. Migrated +/// event and text objects resolve their tags through their real prototypes. pub(crate) fn web_builtin_to_string_tag(value: f64) -> Option<&'static str> { let bits = value.to_bits(); if (bits >> 48) != 0x7FFD { @@ -77,14 +61,7 @@ pub(crate) fn web_builtin_to_string_tag(value: f64) -> Option<&'static str> { if crate::url::search_params::shape_is_url_search_params(obj) { return Some("URLSearchParams"); } - match unsafe { (*obj).class_id } { - crate::url::abort::ABORT_CONTROLLER_CLASS_ID => Some("AbortController"), - crate::url::abort::ABORT_SIGNAL_CLASS_ID => Some("AbortSignal"), - crate::event_target::CLASS_ID_EVENT_TARGET => Some("EventTarget"), - crate::event_target::CLASS_ID_EVENT => Some("Event"), - crate::event_target::CLASS_ID_CUSTOM_EVENT => Some("CustomEvent"), - _ => None, - } + None } unsafe fn string_value_to_owned(value: f64) -> Option { diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 3fb0f832bdf..22997d8fb36 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -1242,6 +1242,14 @@ pub(crate) fn store_object_symbol_property_root( ) -> bool { note_symbol_key_installed(sym_key); note_symbol_owner_installed(obj_key); + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + let existed = unsafe { crate::object::shaped_symbols::entry(obj_key, sym_key) }; + let entry = existed.unwrap_or(0) & !crate::object::key_attrs::ENTRY_ACCESSOR_MASK; + unsafe { + crate::object::shaped_symbols::define(obj_key, sym_key, value_bits, entry); + } + return existed.is_none(); + } crate::closure::shape::note_function_own_state_changed(obj_key); { let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); diff --git a/crates/perry-runtime/src/symbol/accessors.rs b/crates/perry-runtime/src/symbol/accessors.rs index 6fe61d16908..1c050df7eab 100644 --- a/crates/perry-runtime/src/symbol/accessors.rs +++ b/crates/perry-runtime/src/symbol/accessors.rs @@ -19,6 +19,21 @@ per_test_global! { } pub(super) fn clear_symbol_accessor_property(obj_key: usize, sym_key: usize) { + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + if let Some(entry) = unsafe { crate::object::shaped_symbols::entry(obj_key, sym_key) } { + if entry & crate::object::key_attrs::ENTRY_ACCESSOR != 0 { + unsafe { + crate::object::shaped_symbols::define( + obj_key, + sym_key, + TAG_UNDEFINED, + entry & !crate::object::key_attrs::ENTRY_ACCESSOR_MASK, + ); + } + } + } + return; + } let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); if let Some(map) = guard.as_mut() { if map.remove(&(obj_key, sym_key)).is_some() { @@ -194,6 +209,9 @@ pub(crate) unsafe fn set_symbol_accessor_property( crate::symbol::note_symbol_key_installed(sym_key); crate::symbol::note_symbol_owner_installed(obj_key); note_symbol_accessor_key(sym_key); + if crate::object::shaped_symbols::define_accessor(obj_key, sym_key, get_bits, set_bits) { + return; + } { // `SYMBOL_PROPERTIES` is the only insertion-ordered record of symbol // property CREATION order, which `[[OwnPropertyKeys]]` must report @@ -250,6 +268,10 @@ pub(super) unsafe fn symbol_accessor_property( if obj_key == 0 || sym_key == 0 { return None; } + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + return unsafe { crate::object::shaped_symbols::accessor(obj_key, sym_key) } + .map(|(get, set)| SymbolAccessorDescriptor { get, set }); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); guard .as_ref() @@ -260,6 +282,10 @@ pub(super) fn symbol_accessor_property_by_key( obj_key: usize, sym_key: usize, ) -> Option { + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + return unsafe { crate::object::shaped_symbols::accessor(obj_key, sym_key) } + .map(|(get, set)| SymbolAccessorDescriptor { get, set }); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); guard .as_ref() @@ -380,6 +406,9 @@ pub(super) fn scan_symbol_accessor_root_slot( } pub(super) fn has_own_symbol_accessor(obj_key: usize, sym_key: usize) -> bool { + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + return unsafe { crate::object::shaped_symbols::accessor(obj_key, sym_key).is_some() }; + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); guard .as_ref() @@ -390,6 +419,14 @@ pub(super) fn has_own_symbol_accessor(obj_key: usize, sym_key: usize) -> bool { /// installed on `obj_key`. Used by `getOwnPropertySymbols`, which must report /// symbol-keyed accessors even though they live outside `SYMBOL_PROPERTIES`. pub(super) fn owner_symbol_accessor_keys(obj_key: usize) -> Vec { + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + return unsafe { crate::object::shaped_symbols::entries(obj_key, false) } + .into_iter() + .filter_map(|(sym, _)| unsafe { + crate::object::shaped_symbols::accessor(obj_key, sym).map(|_| sym) + }) + .collect(); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_ACCESSOR_PROPERTIES); guard .as_ref() diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 629a384d867..3cb8beadbe0 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -82,6 +82,9 @@ pub(crate) unsafe fn has_own_symbol_property(obj_f64: f64, sym_f64: f64) -> bool if obj_key == 0 || sym_key == 0 { return false; } + if crate::object::shaped_symbols::owner(obj_key).is_some() { + return crate::object::shaped_symbols::get(obj_key, sym_key).is_some(); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); match guard.as_ref().and_then(|map| map.get(&obj_key)) { Some(entries) => entries.iter().any(|&(sk, _)| sk == sym_key), @@ -150,6 +153,9 @@ pub(crate) unsafe fn own_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option Option *mut u64 { /// Miss path for the generated weak own-Symbol-property IC. /// -/// Cache layout (all `u64`): epoch, receiver bits, symbol bits, value bits. -/// The receiver and value are intentionally *not* GC roots. The process-wide +/// Cache layout (all `u64`): epoch, receiver bits, symbol bits, packed shape/slot. +/// The receiver is intentionally *not* a GC root. The process-wide /// epoch changes after every collection, so a moved/reclaimed address can /// never hit; it also changes after every Symbol-property mutation, so a data /// write, deletion, or data/accessor conversion cannot return a stale value. @@ -212,17 +218,16 @@ pub unsafe extern "C" fn js_object_get_symbol_property_ic_miss( if sym_key != 0 && accessors::symbol_accessor_property_by_key(obj_key, sym_key).is_none() { - let value_bits = { - let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); - guard.as_ref().and_then(|map| { - map.get(&obj_key).and_then(|entries| { - entries - .iter() - .find(|(entry_sym, _)| *entry_sym == sym_key) - .map(|(_, value_bits)| *value_bits) - }) - }) - }; + let obj = obj_key as *mut crate::object::ObjectHeader; + let slot = crate::object::shaped_symbols::position(obj, sym_key); + let shape = crate::object::shapes::object_shape_stamp(obj); + // The generated hit reads the inline slot directly, so only + // a slot inside the published live inline bound may prime. + let slot = slot.filter(|&index| { + shape != 0 && index < crate::object::object_live_slot_count(obj) + }); + let value_bits = + slot.map(|index| crate::object::js_object_get_field(obj, index).bits()); if let Some(value_bits) = value_bits { let epoch = PERRY_SYMBOL_PROPERTY_IC_EPOCH .load(std::sync::atomic::Ordering::Acquire); @@ -230,11 +235,11 @@ pub unsafe extern "C" fn js_object_get_symbol_property_ic_miss( // successful prime, never for a miss that cannot // prime. let cache = crate::object::pic_slot_resolve(cache_slot) as *mut u64; - // Publish identity/value first and epoch last. The + // Publish identity/layout first and epoch last. The // generated hit path acquire-loads this first word. *cache.add(1) = obj_bits; *cache.add(2) = sym_f64.to_bits(); - *cache.add(3) = value_bits; + *cache.add(3) = ((shape as u64) << 32) | slot.unwrap() as u64; (&*(cache as *const std::sync::atomic::AtomicU64)) .store(epoch, std::sync::atomic::Ordering::Release); return f64::from_bits(value_bits); @@ -318,7 +323,8 @@ pub unsafe extern "C" fn js_object_get_symbol_then_field_ic_miss( let field_primed = !field_cache.is_null() && ((*field_cache)[0] as u64 & crate::object::shapes::PIC_ID_TOKEN_BIT) != 0; let symbol_primed = !symbol_cache.is_null() - && *symbol_cache.add(3) == intermediate_bits + && *symbol_cache.add(1) == obj_f64.to_bits() + && *symbol_cache.add(2) == sym_f64.to_bits() && (&*(symbol_cache as *const std::sync::atomic::AtomicU64)) .load(std::sync::atomic::Ordering::Acquire) == PERRY_SYMBOL_PROPERTY_IC_EPOCH.load(std::sync::atomic::Ordering::Acquire); @@ -411,8 +417,7 @@ unsafe fn object_header_ptr_from_value_bits(bits: u64) -> Option { unsafe fn receiver_ptr_from_value_bits(bits: u64) -> Option { let (raw, obj_type) = heap_ptr_and_type_from_value_bits(bits)?; if obj_type == crate::gc::GC_TYPE_OBJECT - || obj_type == crate::gc::GC_TYPE_ARRAY - || obj_type == crate::gc::GC_TYPE_LAZY_ARRAY + || crate::object::prototype_chain::residual_prototype_owner_type(obj_type) { Some(raw) } else { @@ -1794,7 +1799,10 @@ mod own_data_ic_tests { assert_eq!(got.to_bits(), first.to_bits()); assert_eq!(cache[1], obj.to_bits()); assert_eq!(cache[2], sym.to_bits()); - assert_eq!(cache[3], first.to_bits()); + assert_eq!( + cache[3], + (crate::object::shapes::object_shape_stamp(obj_ptr) as u64) << 32 + ); assert_eq!( cache[0], PERRY_SYMBOL_PROPERTY_IC_EPOCH.load(Ordering::Acquire) @@ -1810,7 +1818,11 @@ mod own_data_ic_tests { ); let got = js_object_get_symbol_property_ic_miss(obj, sym, &mut cache_slot); assert_eq!(got.to_bits(), second.to_bits()); - assert_eq!(cache[3], second.to_bits()); + assert_eq!(cache[3] as u32, 0); + assert_eq!( + crate::object::js_object_get_field(obj_ptr, cache[3] as u32).bits(), + second.to_bits() + ); } } diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 0ca5f2d9a1d..3841721a5ea 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -71,6 +71,11 @@ pub unsafe extern "C" fn js_object_get_own_property_symbols(obj_f64: f64) -> i64 .cloned() .unwrap_or_default(); drop(guard); + let stored_entries = if crate::object::shaped_symbols::owner(obj_key).is_some() { + crate::object::shaped_symbols::entries(obj_key, false) + } else { + stored_entries + }; for entry in stored_entries { if !entries.iter().any(|(sym_key, _)| *sym_key == entry.0) { entries.push(entry); diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 332e6edea4f..6539f156593 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -25,6 +25,14 @@ pub(crate) fn clone_symbol_entries_for_obj_ptr(src_obj_ptr: usize) -> Vec<(usize if src_obj_ptr == 0 { return Vec::new(); } + if unsafe { crate::object::shaped_symbols::owner(src_obj_ptr).is_some() } { + return unsafe { crate::object::shaped_symbols::entries(src_obj_ptr, false) } + .into_iter() + .filter(|(symbol, _)| unsafe { + crate::object::shaped_symbols::accessor(src_obj_ptr, *symbol).is_none() + }) + .collect(); + } let mut entries = { let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); guard @@ -49,6 +57,9 @@ pub(crate) fn clone_symbol_entries_for_obj_ptr(src_obj_ptr: usize) -> Vec<(usize } pub(crate) fn symbol_property_root_bits(owner: usize, sym_key: usize) -> Option { + if unsafe { crate::object::shaped_symbols::owner(owner).is_some() } { + return unsafe { crate::object::shaped_symbols::get(owner, sym_key) }; + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); guard.as_ref().and_then(|map| { map.get(&owner) @@ -75,6 +86,13 @@ pub(crate) fn get_symbol_property_attrs( owner: usize, sym_key: usize, ) -> Option { + if unsafe { crate::object::shaped_symbols::owner(owner).is_some() } { + return unsafe { crate::object::shaped_symbols::entry(owner, sym_key) }.map(|entry| { + crate::object::PropertyAttrs { + bits: crate::object::key_attrs::entry_to_attr_bits(entry), + } + }); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTY_ATTRS); guard .as_ref() @@ -89,6 +107,18 @@ pub(crate) fn set_symbol_property_attrs( if owner == 0 || sym_key == 0 { return; } + if unsafe { crate::object::shaped_symbols::owner(owner).is_some() } { + let old = unsafe { crate::object::shaped_symbols::entry(owner, sym_key) }.unwrap_or(0); + unsafe { + crate::object::shaped_symbols::set_entry( + owner, + sym_key, + (old & !crate::object::key_attrs::ENTRY_ATTR_MASK) + | crate::object::key_attrs::attr_bits_to_entry(attrs.bits), + ); + } + return; + } super::note_symbol_key_installed(sym_key); let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTY_ATTRS); if guard.is_none() { @@ -112,6 +142,9 @@ pub(crate) unsafe fn js_object_delete_symbol_property(obj_f64: f64, sym_f64: f64 crate::array::note_array_proto_iterator_write(obj_key, sym_key); crate::object::map_set_subclass::note_iterator_symbol_write(obj_key, sym_key); + if crate::object::shaped_symbols::delete(obj_key, sym_key) { + return 1; + } accessors::clear_symbol_accessor_property(obj_key, sym_key); { let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); @@ -479,6 +512,10 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 } fn object_symbol_data_property_exists(obj_key: usize, sym_key: usize) -> bool { + if unsafe { crate::object::shaped_symbols::owner(obj_key).is_some() } { + return unsafe { crate::object::shaped_symbols::entry(obj_key, sym_key) } + .is_some_and(|entry| entry & crate::object::key_attrs::ENTRY_ACCESSOR == 0); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); guard.as_ref().is_some_and(|map| { map.get(&obj_key) @@ -692,6 +729,9 @@ pub unsafe extern "C" fn js_object_has_own_symbol(obj_f64: f64, sym_f64: f64) -> if accessors::has_own_symbol_accessor(obj_key, sym_key) { return true; } + if crate::object::shaped_symbols::owner(obj_key).is_some() { + return crate::object::shaped_symbols::entry(obj_key, sym_key).is_some(); + } let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); if let Some(map) = guard.as_ref() { if let Some(entries) = map.get(&obj_key) { diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 152afaef320..44467f82ad4 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -461,11 +461,21 @@ pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { // is owned by `native_class_ids`, so a family joins this guard // by taking the next id rather than by editing this file. let class_id = (*(raw_ptr as *const crate::object::ObjectHeader)).class_id; + if let Some(name) = crate::event_target::native_class_name(class_id).or_else(|| { + crate::event_target::state::transfer_family( + raw_ptr as *mut crate::object::ObjectHeader, + ) + }) { + return SerializedValue::Unsupported(name); + } if crate::native_class_ids::is_native_backed_class_id(class_id) { return SerializedValue::Unsupported("native handle"); } return serialize_object(raw_ptr as *const crate::object::ObjectHeader); } + gc::GC_TYPE_ERROR if crate::event_target::is_dom_exception_error(raw_ptr.cast()) => { + return SerializedValue::Unsupported("DOMException"); + } gc::GC_TYPE_CLOSURE => { return serialize_closure(raw_ptr as *const ClosureHeader); } diff --git a/crates/perry-runtime/src/url/abort.rs b/crates/perry-runtime/src/url/abort.rs index e08bebf1879..18ac35af950 100644 --- a/crates/perry-runtime/src/url/abort.rs +++ b/crates/perry-runtime/src/url/abort.rs @@ -1,29 +1,18 @@ //! `AbortController` / `AbortSignal` runtime implementation. use super::*; +use crate::event_target::state::{ + get_slot as get_abort_slot, set_slot as set_abort_slot, SIGNAL_ABORTED, SIGNAL_REASON, +}; // ========================================================================= // AbortController implementation // ========================================================================= -/// AbortController object structure (matches ObjectHeader layout) -/// Field 0: signal (object-ptr NaN-boxed) -/// Field 1: aborted flag (NaN-boxed bool) -/// Field 2: abort method (closure) +// State is held by ordinary symbol-keyed own properties. pub(crate) const ABORT_CONTROLLER_CLASS_ID: u32 = crate::native_class_ids::ABORT_CONTROLLER; pub(crate) const ABORT_SIGNAL_CLASS_ID: u32 = crate::native_class_ids::ABORT_SIGNAL; -const ABORT_CONTROLLER_FIELD_COUNT: u32 = 3; -const ABORT_SIGNAL_FIELD: u32 = 0; -const ABORT_ABORTED_FIELD: u32 = 1; -const ABORT_METHOD_FIELD: u32 = 2; - -// AbortSignal object layout (all fields NaN-boxed): -// field 0: aborted (bool) -// field 1: reason (any) -// field 2: listeners (array of closure f64 values; may be null/undefined if empty) -// field 3: addEventListener method -// field 4: removeEventListener method -const ABORT_SIGNAL_FIELD_COUNT: u32 = 5; +const ABORT_SIGNAL_FIELD: u32 = crate::event_target::state::CONTROLLER_SIGNAL; const TAG_UNDEFINED_AC: u64 = 0x7FFC_0000_0000_0001; const TAG_TRUE_AC: u64 = 0x7FFC_0000_0000_0004; @@ -43,109 +32,39 @@ fn nanbox_pointer_ac(ptr: *mut ObjectHeader) -> f64 { fn unbox_pointer_ac(v: f64) -> *mut ObjectHeader { let bits = v.to_bits(); if (bits & 0xFFFF_0000_0000_0000) != POINTER_TAG_AC { - // Fallback: legacy raw bitcast path - return (v.to_bits() as usize) as *mut ObjectHeader; + return std::ptr::null_mut(); } (bits & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader } fn alloc_abort_signal() -> *mut ObjectHeader { - let signal = js_object_alloc(ABORT_SIGNAL_CLASS_ID, ABORT_SIGNAL_FIELD_COUNT); - let mut signal_keys = js_array_alloc(ABORT_SIGNAL_FIELD_COUNT); - signal_keys = js_array_push_f64(signal_keys, create_string_f64("aborted")); - signal_keys = js_array_push_f64(signal_keys, create_string_f64("reason")); - signal_keys = js_array_push_f64(signal_keys, create_string_f64("_listeners")); - signal_keys = js_array_push_f64(signal_keys, create_string_f64("addEventListener")); - signal_keys = js_array_push_f64(signal_keys, create_string_f64("removeEventListener")); - js_object_set_keys(signal, signal_keys); - js_object_set_field_f64(signal, 0, f64::from_bits(TAG_FALSE_AC)); - js_object_set_field_f64(signal, 1, f64::from_bits(TAG_UNDEFINED_AC)); - js_object_set_field_f64(signal, 2, f64::from_bits(TAG_UNDEFINED_AC)); - js_object_set_field_f64(signal, 3, abort_signal_listener_method_value(signal, true)); - js_object_set_field_f64(signal, 4, abort_signal_listener_method_value(signal, false)); + let _gc = crate::gc::GcSuppressScope::new(); + crate::event_target::note_constructed(); + let signal = js_object_alloc(ABORT_SIGNAL_CLASS_ID, 0); + crate::event_target::state::link(signal, "AbortSignal"); + crate::event_target::state::initialize_target(signal, true); + set_abort_slot(signal, SIGNAL_ABORTED, f64::from_bits(TAG_FALSE_AC)); + set_abort_slot(signal, SIGNAL_REASON, f64::from_bits(TAG_UNDEFINED_AC)); + set_abort_slot( + signal, + crate::event_target::state::COMPOSITE, + f64::from_bits(TAG_FALSE_AC), + ); signal } -extern "C" fn abort_signal_add_event_listener_method( - closure: *const crate::closure::ClosureHeader, - event_type: f64, - listener: f64, -) -> f64 { - let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; - js_abort_signal_add_listener(signal, event_type, listener); - f64::from_bits(crate::value::TAG_UNDEFINED) -} - -extern "C" fn abort_signal_remove_event_listener_method( - closure: *const crate::closure::ClosureHeader, - event_type: f64, - listener: f64, -) -> f64 { - let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; - js_abort_signal_remove_listener(signal, event_type, listener); - f64::from_bits(crate::value::TAG_UNDEFINED) -} - -fn abort_signal_listener_method_value(signal: *mut ObjectHeader, add: bool) -> f64 { - let func = if add { - abort_signal_add_event_listener_method as *const u8 - } else { - abort_signal_remove_event_listener_method as *const u8 - }; - crate::closure::js_register_closure_arity(func, 2); - let closure = crate::closure::js_closure_alloc(func, 1); - crate::closure::js_closure_set_capture_ptr(closure, 0, signal as i64); - crate::value::js_nanbox_pointer(closure as i64) -} - -extern "C" fn abort_controller_abort_method( - closure: *const crate::closure::ClosureHeader, - reason: f64, -) -> f64 { - let controller_bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64; - let controller_value = f64::from_bits(controller_bits); - let controller = crate::value::js_nanbox_get_pointer(controller_value) as *mut ObjectHeader; - js_abort_controller_abort_reason(controller, reason); - f64::from_bits(crate::value::TAG_UNDEFINED) -} - -fn abort_controller_abort_value(controller: *mut ObjectHeader) -> f64 { - let func = abort_controller_abort_method as *const u8; - crate::closure::js_register_closure_arity(func, 1); - let closure = crate::closure::js_closure_alloc(func, 1); - let controller_value = crate::value::js_nanbox_pointer(controller as i64); - crate::closure::js_closure_set_capture_ptr(closure, 0, controller_value.to_bits() as i64); - crate::value::js_nanbox_pointer(closure as i64) -} - /// Create a new AbortController #[no_mangle] pub extern "C" fn js_abort_controller_new() -> *mut ObjectHeader { - // Allocate the AbortController object - let controller = js_object_alloc(ABORT_CONTROLLER_CLASS_ID, ABORT_CONTROLLER_FIELD_COUNT); - - let signal = alloc_abort_signal(); - - // Set up controller keys - let mut keys = js_array_alloc(ABORT_CONTROLLER_FIELD_COUNT); - keys = js_array_push_f64(keys, create_string_f64("signal")); - keys = js_array_push_f64(keys, create_string_f64("aborted")); - keys = js_array_push_f64(keys, create_string_f64("abort")); - js_object_set_keys(controller, keys); - - // Store signal in controller (NaN-boxed with POINTER_TAG) - js_object_set_field_f64(controller, ABORT_SIGNAL_FIELD, nanbox_pointer_ac(signal)); - js_object_set_field_f64( - controller, - ABORT_ABORTED_FIELD, - f64::from_bits(TAG_FALSE_AC), - ); - js_object_set_field_f64( + let _gc = crate::gc::GcSuppressScope::new(); + crate::event_target::note_constructed(); + let controller = js_object_alloc(ABORT_CONTROLLER_CLASS_ID, 0); + crate::event_target::state::link(controller, "AbortController"); + crate::event_target::state::set_slot( controller, - ABORT_METHOD_FIELD, - abort_controller_abort_value(controller), + ABORT_SIGNAL_FIELD, + f64::from_bits(TAG_UNDEFINED_AC), ); - controller } @@ -155,52 +74,45 @@ pub extern "C" fn js_abort_controller_signal(controller: *mut ObjectHeader) -> * if controller.is_null() { return std::ptr::null_mut(); } - let signal_val = crate::object::js_object_get_field_f64(controller, ABORT_SIGNAL_FIELD); - unbox_pointer_ac(signal_val) + let signal_val = crate::event_target::state::get_slot(controller, ABORT_SIGNAL_FIELD); + if signal_val.to_bits() == TAG_UNDEFINED_AC { + let scope = crate::gc::RuntimeHandleScope::new(); + let controller = scope.root_raw_mut_ptr(controller); + let signal = scope.root_raw_mut_ptr(alloc_abort_signal()); + let ((), result) = signal.across_mut(|| { + controller.with_mut_ptr(|controller| { + signal.with_mut_ptr(|signal| { + crate::event_target::state::set_slot( + controller, + ABORT_SIGNAL_FIELD, + nanbox_pointer_ac(signal), + ) + }); + }); + }); + result + } else { + unbox_pointer_ac(signal_val) + } } fn fire_abort_listeners(signal: *mut ObjectHeader) { if signal.is_null() { return; } - // Wake any in-flight `fetch` bound to this signal so it rejects with an - // AbortError. Done here — before the JS-listener early return below — so the - // fetch is cancelled even when the signal carries no JS `abort` listener - // (the common `fetch(url, { signal })` case registers none). This replaces a - // per-fetch JS listener, so reused signals don't accumulate stale closures. - notify_fetch_abort(signal as i64); - let listeners_val = crate::object::js_object_get_field_f64(signal, 2); - let bits = listeners_val.to_bits(); - if bits == TAG_UNDEFINED_AC || bits == TAG_FALSE_AC { - return; - } - // Extract array pointer (NaN-boxed POINTER_TAG). - let arr_ptr = if (bits & 0xFFFF_0000_0000_0000) == POINTER_TAG_AC { - (bits & 0x0000_FFFF_FFFF_FFFF) as *mut crate::array::ArrayHeader - } else { - return; - }; - if arr_ptr.is_null() { - return; - } - let len = crate::array::js_array_length(arr_ptr) as usize; - let mut callbacks = Vec::with_capacity(len); - for i in 0..len { - callbacks.push(crate::array::js_array_get_f64(arr_ptr, i as u32)); - } - for cb_val in callbacks { - let cb_bits = cb_val.to_bits(); - // Try to extract closure pointer (may be POINTER_TAG or raw bitcast). - let cb_ptr = if (cb_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG_AC { - (cb_bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::closure::ClosureHeader - } else if cb_bits > 0x10000 && (cb_bits >> 48) == 0 { - cb_bits as *const crate::closure::ClosureHeader - } else { - continue; - }; - if !cb_ptr.is_null() { - crate::closure::js_closure_call0(cb_ptr); - } + let scope = crate::gc::RuntimeHandleScope::new(); + let signal = scope.root_raw_mut_ptr(signal); + signal.with_mut_ptr::(|ptr| notify_fetch_abort(ptr as i64)); + let event = crate::event_target::js_event_new( + create_string_f64("abort"), + f64::from_bits(TAG_UNDEFINED_AC), + 1, + ); + let event = scope.root_nanbox_f64(nanbox_pointer_ac(event)); + unsafe { + signal.with_mut_ptr(|ptr| { + crate::event_target::js_event_target_dispatch_event(ptr, event.get_nanbox_f64()) + }); } } @@ -208,7 +120,7 @@ fn abort_signal_is_aborted(signal: *mut ObjectHeader) -> bool { if signal.is_null() { return false; } - crate::object::js_object_get_field_f64(signal, 0).to_bits() == TAG_TRUE_AC + crate::value::js_is_truthy(get_abort_slot(signal, SIGNAL_ABORTED)) != 0 } /// Return true if the given AbortSignal has already been aborted. @@ -229,7 +141,11 @@ pub(crate) fn abort_signal_ptr_from_value(value: f64) -> Option<*mut ObjectHeade if crate::value::addr_class::is_handle_band(ptr as usize) { return None; } - let is_signal = unsafe { (*ptr).class_id == ABORT_SIGNAL_CLASS_ID }; + let is_signal = unsafe { + crate::value::addr_class::try_read_tracked_gc_header(ptr as usize) + .is_some_and(|h| h.as_ref().obj_type == crate::gc::GC_TYPE_OBJECT) + && (*ptr).class_id == ABORT_SIGNAL_CLASS_ID + }; is_signal.then_some(ptr) } @@ -312,221 +228,82 @@ pub extern "C" fn js_abort_controller_abort_reason(controller: *mut ObjectHeader if controller.is_null() { return; } - let signal_val = crate::object::js_object_get_field_f64(controller, ABORT_SIGNAL_FIELD); - let signal = unbox_pointer_ac(signal_val); + let scope = crate::gc::RuntimeHandleScope::new(); + let reason = scope.root_nanbox_f64(reason); + let signal = js_abort_controller_signal(controller); if !signal.is_null() { + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted_signal = scope.root_raw_mut_ptr(signal); if abort_signal_is_aborted(signal) { - js_object_set_field_f64(controller, ABORT_ABORTED_FIELD, f64::from_bits(TAG_TRUE_AC)); return; } // Set aborted = true on signal - js_object_set_field_f64(signal, 0, f64::from_bits(TAG_TRUE_AC)); + set_abort_slot(signal, SIGNAL_ABORTED, f64::from_bits(TAG_TRUE_AC)); // Node defaults omitted/undefined reasons to a DOMException AbortError. - let effective = if reason.to_bits() == TAG_UNDEFINED_AC { + let effective = if reason.get_nanbox_u64() == TAG_UNDEFINED_AC { crate::event_target::abort_dom_exception_value() } else { - reason + reason.get_nanbox_f64() }; - js_object_set_field_f64(signal, 1, effective); + rooted_signal.with_mut_ptr(|ptr| set_abort_slot(ptr, SIGNAL_REASON, effective)); // Fire listeners - fire_abort_listeners(signal); + rooted_signal.with_mut_ptr(fire_abort_listeners); } - - // Also set aborted on controller - js_object_set_field_f64(controller, ABORT_ABORTED_FIELD, f64::from_bits(TAG_TRUE_AC)); } -/// Register an "abort" event listener on a signal. `event_type` is the NaN-boxed -/// string name (we only act on "abort"); `listener` is a NaN-boxed closure f64. +/// Register a listener through the shared EventTarget state. #[no_mangle] pub extern "C" fn js_abort_signal_add_listener( signal: *mut ObjectHeader, event_type: f64, listener: f64, ) { - if signal.is_null() { - return; - } - let scope = crate::gc::RuntimeHandleScope::new(); - let signal = scope.root_raw_mut_ptr(signal); - let event_type = scope.root_nanbox_f64(event_type); - let listener = scope.root_nanbox_f64(listener); - // Only handle "abort" events — ignore everything else. - let type_str = get_string_content(event_type.get_nanbox_f64()); - if type_str != "abort" { - return; - } - let listeners_val = - signal.with_mut_ptr(|signal| crate::object::js_object_get_field_f64(signal, 2)); - let bits = listeners_val.to_bits(); - let arr_ptr: *mut crate::array::ArrayHeader = if (bits & 0xFFFF_0000_0000_0000) - == POINTER_TAG_AC - { - (bits & 0x0000_FFFF_FFFF_FFFF) as *mut crate::array::ArrayHeader - } else { - // Lazily allocate the listeners array. - let new_arr = js_array_alloc(0); - let new_bits = POINTER_TAG_AC | ((new_arr as u64) & 0x0000_FFFF_FFFF_FFFF); - signal.with_mut_ptr(|signal| js_object_set_field_f64(signal, 2, f64::from_bits(new_bits))); - new_arr - }; - if !arr_ptr.is_null() { - js_array_push_f64(arr_ptr, listener.get_nanbox_f64()); + let _gc = crate::gc::GcSuppressScope::new(); + let name = crate::builtins::js_string_coerce(event_type); + if crate::value::JSValue::from_bits(listener.to_bits()).is_pointer() { + unsafe { + crate::event_target::js_event_target_add_event_listener( + signal, + name, + crate::value::js_nanbox_get_pointer(listener), + ); + } } } -/// Remove one matching "abort" listener from a signal. #[no_mangle] pub extern "C" fn js_abort_signal_remove_listener( signal: *mut ObjectHeader, event_type: f64, listener: f64, ) { - if signal.is_null() { - return; - } - let type_str = get_string_content(event_type); - if type_str != "abort" { - return; - } - let listeners_val = crate::object::js_object_get_field_f64(signal, 2); - let bits = listeners_val.to_bits(); - if (bits & 0xFFFF_0000_0000_0000) != POINTER_TAG_AC { - return; - } - let arr_ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut crate::array::ArrayHeader; - if arr_ptr.is_null() { - return; - } - let len = crate::array::js_array_length(arr_ptr); - for i in 0..len { - let current = crate::array::js_array_get_f64(arr_ptr, i); - if current.to_bits() != listener.to_bits() { - continue; - } - for j in (i + 1)..len { - let next = crate::array::js_array_get_f64(arr_ptr, j); - crate::array::js_array_set_f64_unchecked(arr_ptr, j - 1, next); + let _gc = crate::gc::GcSuppressScope::new(); + let name = crate::builtins::js_string_coerce(event_type); + if crate::value::JSValue::from_bits(listener.to_bits()).is_pointer() { + unsafe { + crate::event_target::js_event_target_remove_event_listener( + signal, + name, + crate::value::js_nanbox_get_pointer(listener), + ); } - crate::array::js_array_set_length(arr_ptr, (len - 1) as f64); - break; - } -} - -/// 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 { - 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) + let _gc = crate::gc::GcSuppressScope::new(); + crate::array::js_array_length(js_abort_signal_listeners_copy(signal)) 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 + let _gc = crate::gc::GcSuppressScope::new(); + let name = js_string_from_bytes(b"abort".as_ptr(), 5); + unsafe { crate::event_target::js_event_target_get_event_listeners(signal, name) } } /// Build the `TimeoutError` DOMException that `AbortSignal.timeout(ms)` aborts @@ -550,7 +327,10 @@ extern "C" fn abort_signal_timeout_fire(closure: *const crate::closure::ClosureH let signal = crate::value::js_nanbox_get_pointer(f64::from_bits(signal_bits)) as *mut ObjectHeader; if !signal.is_null() { - abort_signal_set_aborted(signal, timeout_dom_exception_value()); + let scope = crate::gc::RuntimeHandleScope::new(); + let signal = scope.root_raw_mut_ptr(signal); + let reason = timeout_dom_exception_value(); + signal.with_mut_ptr(|ptr| abort_signal_set_aborted(ptr, reason)); } f64::from_bits(TAG_UNDEFINED_AC) } @@ -567,6 +347,7 @@ extern "C" fn abort_signal_timeout_fire(closure: *const crate::closure::ClosureH /// slow/held response instead of timing out. #[no_mangle] pub extern "C" fn js_abort_signal_timeout(ms: f64) -> *mut ObjectHeader { + let _gc = crate::gc::GcSuppressScope::new(); let signal = alloc_abort_signal(); let func = abort_signal_timeout_fire as *const u8; crate::closure::js_register_closure_arity(func, 0); @@ -585,8 +366,8 @@ fn abort_signal_set_aborted(signal: *mut ObjectHeader, reason: f64) { if signal.is_null() || abort_signal_is_aborted(signal) { return; } - js_object_set_field_f64(signal, 0, f64::from_bits(TAG_TRUE_AC)); - js_object_set_field_f64(signal, 1, reason); + set_abort_slot(signal, SIGNAL_ABORTED, f64::from_bits(TAG_TRUE_AC)); + set_abort_slot(signal, SIGNAL_REASON, reason); fire_abort_listeners(signal); } @@ -594,6 +375,7 @@ fn abort_signal_set_aborted(signal: *mut ObjectHeader, reason: f64) { /// `.reason` is `reason` (or an `AbortError` when omitted, matching Node). #[no_mangle] pub extern "C" fn js_abort_signal_abort(reason: f64) -> *mut ObjectHeader { + let _gc = crate::gc::GcSuppressScope::new(); let signal = alloc_abort_signal(); let reason_bits = reason.to_bits(); // Node defaults the reason to an AbortError when none is supplied. @@ -602,8 +384,8 @@ pub extern "C" fn js_abort_signal_abort(reason: f64) -> *mut ObjectHeader { } else { reason }; - js_object_set_field_f64(signal, 0, f64::from_bits(TAG_TRUE_AC)); - js_object_set_field_f64(signal, 1, effective); + set_abort_slot(signal, SIGNAL_ABORTED, f64::from_bits(TAG_TRUE_AC)); + set_abort_slot(signal, SIGNAL_REASON, effective); signal } @@ -612,7 +394,7 @@ pub extern "C" fn js_abort_signal_abort(reason: f64) -> *mut ObjectHeader { #[no_mangle] pub extern "C" fn js_abort_signal_throw_if_aborted(signal: *mut ObjectHeader) -> f64 { if abort_signal_is_aborted(signal) { - let reason = crate::object::js_object_get_field_f64(signal, 1); + let reason = get_abort_slot(signal, SIGNAL_REASON); // Node throws the stored reason verbatim (which is the AbortError // default when none was provided). crate::exception::js_throw(reason); @@ -634,7 +416,7 @@ extern "C" fn abort_any_propagate_thunk( let reason = if source.is_null() { f64::from_bits(TAG_UNDEFINED_AC) } else { - crate::object::js_object_get_field_f64(source, 1) + get_abort_slot(source, SIGNAL_REASON) }; abort_signal_set_aborted(combined, reason); f64::from_bits(TAG_UNDEFINED_AC) @@ -650,35 +432,52 @@ extern "C" fn abort_any_propagate_thunk( pub extern "C" fn js_abort_signal_any( signals_arr: *mut crate::array::ArrayHeader, ) -> *mut ObjectHeader { - let combined = alloc_abort_signal(); if signals_arr.is_null() { - return combined; + return alloc_abort_signal(); } - let len = crate::array::js_array_length(signals_arr); - let combined_box = crate::value::js_nanbox_pointer(combined as i64); - for i in 0..len { - let elem = crate::array::js_array_get_f64(signals_arr, i); - let Some(source) = abort_signal_ptr_from_value(elem) else { - continue; - }; - if abort_signal_is_aborted(source) { - // Adopt the first already-aborted source's reason immediately. - let reason = crate::object::js_object_get_field_f64(source, 1); - abort_signal_set_aborted(combined, reason); - return combined; + let scope = crate::gc::RuntimeHandleScope::new(); + let sources = scope.root_raw_mut_ptr(signals_arr); + let combined = scope.root_raw_mut_ptr(alloc_abort_signal()); + let (_, result) = combined.across_mut(|| { + let len = sources.with_const_ptr(|ptr| crate::array::js_array_length(ptr)); + for i in 0..len { + let elem = scope.root_nanbox_f64( + sources.with_const_ptr(|ptr| crate::array::js_array_get_f64(ptr, i)), + ); + let Some(source) = abort_signal_ptr_from_value(elem.get_nanbox_f64()) else { + continue; + }; + if abort_signal_is_aborted(source) { + let reason = get_abort_slot(source, SIGNAL_REASON); + combined.with_mut_ptr(|ptr| abort_signal_set_aborted(ptr, reason)); + break; + } + let func = abort_any_propagate_thunk as *const u8; + crate::closure::js_register_closure_arity(func, 1); + let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(func, 2)); + closure.with_mut_ptr(|closure| { + combined.with_mut_ptr(|ptr| { + crate::closure::js_closure_set_capture_ptr( + closure, + 0, + nanbox_pointer_ac(ptr).to_bits() as i64, + ); + }); + crate::closure::js_closure_set_capture_ptr( + closure, + 1, + elem.get_nanbox_f64().to_bits() as i64, + ); + }); + let abort_evt = create_string_f64("abort"); + let listener = closure.with_mut_ptr::(|ptr| { + crate::value::js_nanbox_pointer(ptr as i64) + }); + let source = abort_signal_ptr_from_value(elem.get_nanbox_f64()).unwrap(); + js_abort_signal_add_listener(source, abort_evt, listener); } - // Register a propagation listener that adopts this source's reason - // when it later aborts. - let func = abort_any_propagate_thunk as *const u8; - crate::closure::js_register_closure_arity(func, 1); - let closure = crate::closure::js_closure_alloc(func, 2); - crate::closure::js_closure_set_capture_ptr(closure, 0, combined_box.to_bits() as i64); - crate::closure::js_closure_set_capture_ptr(closure, 1, elem.to_bits() as i64); - let listener = crate::value::js_nanbox_pointer(closure as i64); - let abort_evt = create_string_f64("abort"); - js_abort_signal_add_listener(source, abort_evt, listener); - } - combined + }); + result } // #2582: keepalive anchors so the auto-optimize whole-program LLVM bitcode diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs index 22d0feb648c..abfd147c3ae 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -419,7 +419,7 @@ fn thread_exit_releases_the_threads_dom_exceptions() { perry_runtime::event_target::js_dom_exception_new(undefined(), undefined()) as usize; ( err, - perry_runtime::event_target::dom_exception_error_registered_for_test(err), + perry_runtime::event_target::dom_exception_has_error_brand_for_test(err), ) }) .join() @@ -429,7 +429,7 @@ fn thread_exit_releases_the_threads_dom_exceptions() { "new DOMException() must be recorded while its thread lives" ); assert!( - !perry_runtime::event_target::dom_exception_error_registered_for_test(err), + !perry_runtime::event_target::dom_exception_has_error_brand_for_test(err), "a dead thread's DOMException address outlived its heap" ); } diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 4c23097aee1..e5afdfc9a66 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -59,7 +59,6 @@ crates/perry-runtime/src/cluster.rs | * | pre-existing GcHeader probe predating crates/perry-runtime/src/collection_iter_object.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/dgram.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/dns.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up -crates/perry-runtime/src/event_target.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/fs/mod.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/fs/stream/write_file_input.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/fs/validate.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index b75f97452c0..d4e41dc8041 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -28,7 +28,7 @@ handle-floor | crates/perry-ext-http/src/lib.rs | 2 handle-floor | crates/perry-runtime/src/array/alloc.rs | 2 handle-floor | crates/perry-runtime/src/array/concat_reverse.rs | 1 handle-floor | crates/perry-runtime/src/array/flat_clone.rs | 3 -handle-floor | crates/perry-runtime/src/array/generic.rs | 4 +handle-floor | crates/perry-runtime/src/array/generic.rs | 3 handle-floor | crates/perry-runtime/src/array/header.rs | 3 handle-floor | crates/perry-runtime/src/array/indexing.rs | 2 handle-floor | crates/perry-runtime/src/array/indexing_keyed.rs | 1 @@ -180,7 +180,6 @@ handle-floor | crates/perry-runtime/src/typedarray/access.rs | 5 handle-floor | crates/perry-runtime/src/typedarray/construct.rs | 4 handle-floor | crates/perry-runtime/src/typedarray/mod.rs | 5 handle-floor | crates/perry-runtime/src/typedarray_props.rs | 2 -handle-floor | crates/perry-runtime/src/url/abort.rs | 1 handle-floor | crates/perry-runtime/src/url/search_params.rs | 1 handle-floor | crates/perry-runtime/src/util_call_sites.rs | 1 handle-floor | crates/perry-runtime/src/util_inherits.rs | 2 diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 1ec895ec04b..c8f0d812626 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -305,22 +305,6 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, - { - "path": "crates/perry-codegen/src/lower_call/options/abort.rs", - "function": "is_abort_controller_expr", - "access": "stable_local_type_proof", - "count": 1, - "classification": "representation-proven", - "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." - }, - { - "path": "crates/perry-codegen/src/lower_call/options/abort.rs", - "function": "is_abort_signal_typed_expr", - "access": "stable_local_type_proof", - "count": 1, - "classification": "representation-proven", - "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." - }, { "path": "crates/perry-codegen/src/lower_call/property_get/dispatch_receiver_class.rs", "function": "guarded_declared_receiver_class_candidate", diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index 4564b1f0a0a..daf96659492 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -896 +893 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index aa559be1456..814c9c66f9d 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -78,7 +78,7 @@ 6 crates/perry-runtime/src/embedded.rs 3 crates/perry-runtime/src/error.rs 4 crates/perry-runtime/src/error/global_ref.rs # moved-from: crates/perry-runtime/src/error.rs -16 crates/perry-runtime/src/event_target.rs +13 crates/perry-runtime/src/event_target.rs 3 crates/perry-runtime/src/frame.rs 3 crates/perry-runtime/src/fs/dir_glob_watch/watch.rs 3 crates/perry-runtime/src/gc/tests/copying.rs diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index ab0c291b1cf..05ecbf518b5 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -3930,15 +3930,6 @@ "why": "Buffer expando records keyed by buffer address; records whose buffer or any stored value is freed are dropped at thread exit.", "hook": "release_buffer_own_props_in_freed_ranges" }, - { - "file": "crates/perry-runtime/src/event_target.rs", - "names": [ - "DOM_EXCEPTION_ERRORS" - ], - "verdict": "thread_exit_invalidated", - "why": "Addresses of DOMException error objects, removed only by the error finalizer; freed addresses are dropped at thread exit.", - "hook": "release_dom_exception_errors_in_freed_ranges" - }, { "file": "crates/perry-runtime/src/ios_game_loop.rs", "names": [ diff --git a/test-files/test_gap_honest_eventtarget.ts b/test-files/test_gap_honest_eventtarget.ts new file mode 100644 index 00000000000..7246c54617d --- /dev/null +++ b/test-files/test_gap_honest_eventtarget.ts @@ -0,0 +1,207 @@ +// Instance identity, own surface, inherited operations, brands and subclasses. +function surface(label: string, a: any, b: any, ctor: any): void { + const map = new Map(); + map.set(a, "first"); map.set(b, "second"); + const weak = new WeakMap(); + weak.set(a, "first"); weak.set(b, "second"); + console.log(label, typeof a, a instanceof ctor, a !== b, + Object.prototype.toString.call(a), String(a)); + console.log("own", JSON.stringify(Object.keys(a)), JSON.stringify(Object.getOwnPropertyNames(a)), JSON.stringify(a)); + console.log("identity", map.size, map.get(a), map.get(b), new Set([a, b, a]).size, weak.get(a), weak.get(b)); + console.log("prototype", Object.getPrototypeOf(a) === ctor.prototype, + JSON.stringify(Object.getOwnPropertyNames(ctor.prototype))); +} +surface("target", new EventTarget(), new EventTarget(), EventTarget); +surface("event", new Event("x"), new Event("x"), Event); +surface("custom", new CustomEvent("x"), new CustomEvent("x"), CustomEvent); +const controller = new AbortController(); +surface("controller", controller, new AbortController(), AbortController); +surface("signal", controller.signal, new AbortController().signal, AbortSignal); +surface("exception", new DOMException("message", "AbortError"), new DOMException("message", "AbortError"), DOMException); +console.log("same signal", controller.signal === controller.signal); +console.log("error brand", Error.isError(new DOMException()), new DOMException() instanceof Error); +console.log("method identity", new EventTarget().dispatchEvent === new EventTarget().dispatchEvent, + controller.abort === AbortController.prototype.abort, + controller.signal.addEventListener === EventTarget.prototype.addEventListener); + +class Bus extends EventTarget {} +class ChildBus extends Bus {} +const bus = new ChildBus(); +let hits = 0; +bus.addEventListener("ping", () => { hits++; }); +bus.dispatchEvent(new Event("ping")); +console.log("subclass", hits, bus instanceof EventTarget, bus instanceof Bus, + Object.getPrototypeOf(Bus.prototype) === EventTarget.prototype, + JSON.stringify(Object.keys(bus)), JSON.stringify(Object.getOwnPropertyNames(bus)), JSON.stringify(bus)); +const detail = { token: 7 }; +const event = new CustomEvent("ping", { cancelable: true, detail }); +console.log("detail identity", event.detail === detail, event.detail === event.detail); +event.preventDefault(); +console.log("event state", event.type, event.cancelable, event.defaultPrevented, event.returnValue); +let aborted = 0; +controller.signal.addEventListener("abort", () => { aborted++; }); +controller.abort(detail); +console.log("abort state", aborted, controller.signal.aborted, controller.signal.reason === detail); +console.log("still private", JSON.stringify(Object.keys(event)), JSON.stringify(Object.getOwnPropertyNames(controller.signal))); +for (const [proto, name] of [[Event.prototype, "type"], [CustomEvent.prototype, "detail"], + [AbortController.prototype, "signal"], [AbortSignal.prototype, "reason"], [DOMException.prototype, "code"]] as const) { + const getter = Object.getOwnPropertyDescriptor(proto, name)!.get!; + try { getter.call({}); console.log("invalid brand accepted"); } + catch (error: any) { console.log("invalid brand", name, error.name); } +} +class SpecialEvent extends CustomEvent {} +const special = new SpecialEvent("special", { detail }); +console.log("custom subclass", special.detail === detail, special instanceof Event, + JSON.stringify(Object.getOwnPropertyNames(special))); +class SpecialException extends DOMException {} +const specialError = new SpecialException("m", "AbortError"); +console.log("exception subclass", specialError.name, specialError.message, specialError.code, + specialError instanceof DOMException, specialError instanceof Error, Error.isError(specialError), + JSON.stringify(Object.keys(specialError)), JSON.stringify(Object.getOwnPropertyNames(specialError))); + +const targetForTag = new EventTarget(); +const tagDescriptor = Object.getOwnPropertyDescriptor(EventTarget.prototype, Symbol.toStringTag)!; +delete (EventTarget.prototype as any)[Symbol.toStringTag]; +console.log("prototype controls tag", Object.prototype.toString.call(targetForTag), + (targetForTag as any)[Symbol.toStringTag]); +Object.defineProperty(EventTarget.prototype, Symbol.toStringTag, tagDescriptor); +console.log("restored tag", Object.prototype.toString.call(targetForTag)); + +class FieldBus extends Bus { + first = 10; + second = 20; + third = 30; +} +let fieldHits = 0; +for (let i = 0; i < 3; i++) { + const fields: any = new FieldBus(); + fields.addEventListener("field", () => { fieldHits++; }); + for (let j = 0; j < 12; j++) fields["extra" + j] = j; + fields.dispatchEvent(new Event("field")); + console.log("public fields", fields.first, fields.second, fields.third, fields.extra11, + Object.keys(fields).length, Object.getOwnPropertyNames(fields).length); +} +console.log("field listeners", fieldHits); + +const originalDispatch = EventTarget.prototype.dispatchEvent; +EventTarget.prototype.dispatchEvent = function (_event: Event): boolean { + console.log("replaced dispatch", this instanceof EventTarget); + return false; +}; +console.log("dispatch result", new EventTarget().dispatchEvent(new Event("x"))); +EventTarget.prototype.dispatchEvent = originalDispatch; +const originalAbort = AbortController.prototype.abort; +AbortController.prototype.abort = function (_reason?: any): void { + console.log("replaced abort", this instanceof AbortController); +}; +new AbortController().abort(); +AbortController.prototype.abort = originalAbort; +console.log("constructor chains", Object.getPrototypeOf(AbortSignal) === EventTarget, + Object.getPrototypeOf(CustomEvent) === Event, Object.getPrototypeOf(DOMException) === Function.prototype); +const DynamicBase: any = EventTarget; +class DynamicBus extends DynamicBase {} +class DynamicChild extends DynamicBus { count = 9; } +for (let i = 0; i < 2; i++) { + const dynamic = new DynamicChild(); + let seen = 0; + dynamic.addEventListener("x", () => { seen++; }); + dynamic.dispatchEvent(new Event("x")); + console.log("dynamic ancestor", seen, dynamic.count, dynamic instanceof EventTarget, + JSON.stringify(Object.keys(dynamic))); +} +try { EventTarget.prototype.dispatchEvent.call(Bus, new Event("x")); } +catch (error: any) { console.log("constructor is not instance", error.name); } +console.log("constants", Event.NONE, Event.AT_TARGET, DOMException.ABORT_ERR, + DOMException.prototype.ABORT_ERR, (Event.prototype as any).NONE); +const retargeted = new Event("x"); +Object.setPrototypeOf(retargeted, Object.prototype); +console.log("prototype controls instanceof", retargeted instanceof Event, + Object.create(Event.prototype) instanceof Event); +try { Object.getOwnPropertyDescriptor(Event.prototype, "type")!.get!.call(Object.create(Event.prototype)); } +catch (error: any) { console.log("prototype is not brand", error.name); } +const frozenEvent = Object.freeze(new Event("frozen", { cancelable: true })); +frozenEvent.preventDefault(); +console.log("frozen private state", frozenEvent.defaultPrevented, + JSON.stringify(Object.keys(frozenEvent))); +const accessorEvent = new Event("original", { cancelable: true }); +console.log("readonly attribute", Reflect.set(accessorEvent, "type", "changed"), accessorEvent.type); +accessorEvent.cancelBubble = true; +accessorEvent.returnValue = false; +const callbackController = new AbortController(); +let onAbortHits = 0; +callbackController.signal.onabort = () => { onAbortHits++; }; +callbackController.abort("reason"); +console.log("prototype setters", accessorEvent.cancelBubble, accessorEvent.defaultPrevented, + onAbortHits, callbackController.signal.reason, Object.keys(callbackController.signal).length); +try { + EventTarget.prototype.addEventListener.call(Bus.prototype, 'x', () => {}); + console.log('prototype brand', false); +} catch (e) { console.log('prototype brand', e instanceof TypeError); } +class DerivedController extends AbortController {} +const derivedController = new DerivedController(); +console.log('controller subclass', derivedController instanceof DerivedController, + derivedController instanceof AbortController, derivedController.signal instanceof AbortSignal, + derivedController.signal === derivedController.signal, + Object.getOwnPropertyNames(derivedController).join(',')); +derivedController.abort('subclass reason'); +console.log('controller subclass state', derivedController.signal.aborted, derivedController.signal.reason); +class DerivedSignal extends AbortSignal {} +try { new DerivedSignal(); console.log('signal illegal constructor', false); } +catch (e) { console.log('signal illegal constructor', e instanceof TypeError); } + +function symbolSurface(label: string, object: any): void { + console.log('symbols', label, Object.getOwnPropertySymbols(object).map(String).join(',')); + console.log('all keys', label, Reflect.ownKeys(object).map(String).join(',')); +} +symbolSurface('target', new EventTarget()); +symbolSurface('event', new Event('x')); +symbolSurface('custom', new CustomEvent('x')); +symbolSurface('controller', new AbortController()); +symbolSurface('signal', new AbortController().signal); +symbolSurface('exception', new DOMException()); +const lazyTarget: any = Object.setPrototypeOf({}, EventTarget.prototype); +symbolSurface('lazy before', lazyTarget); +let lazyHits = 0; +lazyTarget.addEventListener('lazy', () => { lazyHits++; }); +lazyTarget.dispatchEvent(new Event('lazy')); +symbolSurface('lazy after', lazyTarget); +console.log('lazy state', lazyHits, Object.keys(lazyTarget).length, JSON.stringify(lazyTarget)); +const symbolEvent = new Event('before'); +const typeSymbol = Object.getOwnPropertySymbols(symbolEvent)[0]; +(symbolEvent as any)[typeSymbol] = 'after'; +console.log('symbol is state', symbolEvent.type); +symbolEvent.stopImmediatePropagation(); +symbolSurface('stopped event', symbolEvent); +const publicController = new AbortController(); +publicController.signal.onabort = () => {}; +publicController.abort('done'); +symbolSurface('controller after use', publicController); +symbolSurface('signal after use', publicController.signal); + +// Node's public symbol brand and private-field brand are distinct. +const brandedType = Object.getOwnPropertySymbols(new Event('brand'))[0]; +const publicBrand: any = { [brandedType]: 'borrowed' }; +for (const property of ['type', 'target', 'currentTarget', 'eventPhase', 'cancelable', 'bubbles']) { + try { console.log('borrowed getter', property, Object.getOwnPropertyDescriptor(Event.prototype, property)!.get!.call(publicBrand)); } + catch (error: any) { console.log('borrowed getter', property, error.name); } +} +console.log('trusted getter', Object.getOwnPropertyDescriptor(Event.prototype, 'isTrusted')!.get!.call({})); +for (const method of ['preventDefault', 'stopPropagation']) { + try { (Event.prototype as any)[method].call(publicBrand); console.log('borrowed method', method, 'accepted'); } + catch (error: any) { console.log('borrowed method', method, error.name); } +} +console.log('frozen remains frozen', Object.isFrozen(frozenEvent)); + +class SpreadTarget extends EventTarget { constructor(...args: any[]) { super(...args); } } +const st = new SpreadTarget(); +console.log('spread target', st instanceof SpreadTarget, st instanceof EventTarget, Object.getOwnPropertySymbols(st).map(String).join(',')); +class ExplicitController extends AbortController { field = 3; constructor() { super(); } } +const ec = new ExplicitController(); +ec.abort('explicit'); +console.log('explicit controller',ec.field,ec.signal.aborted,ec.signal.reason); +class SpreadController extends AbortController { constructor(...args: any[]) { super(...args); } } +const sc = new SpreadController(); +try { console.log('spread controller',sc.signal instanceof AbortSignal); } catch(e:any){console.log('spread controller',e.name);} +class SpreadEvent extends Event { constructor(...args:any[]) { super(...args); } } +const se = new SpreadEvent('spread', {cancelable:true}); +try {console.log('spread event',se.type,se.cancelable);}catch(e:any){console.log('spread event',e.name);} diff --git a/test-files/test_gap_honest_eventtarget_integrity.ts b/test-files/test_gap_honest_eventtarget_integrity.ts new file mode 100644 index 00000000000..9d4ea178db6 --- /dev/null +++ b/test-files/test_gap_honest_eventtarget_integrity.ts @@ -0,0 +1,30 @@ +const t = Object.freeze(new EventTarget()); +let hits = 0; +try { + t.addEventListener("x", () => { hits++; }); + t.dispatchEvent(new Event("x")); + console.log("frozen target ok", hits); +} catch (e: any) { console.log("frozen target threw", e?.constructor?.name); } +const s = Object.seal(new EventTarget()); +try { s.addEventListener("y", () => { hits++; }); s.dispatchEvent(new Event("y")); console.log("sealed ok", hits); } catch (e: any) { console.log("sealed threw", e?.constructor?.name); } +const ac = new AbortController(); +Object.freeze(ac); +try { ac.abort("r"); console.log("frozen ctrl abort", ac.signal.aborted, ac.signal.reason); } catch (e: any) { console.log("frozen ctrl threw", e?.constructor?.name); } +const et2 = new EventTarget(); +console.log("syms", Object.getOwnPropertySymbols(et2).map(String).join(",")); +console.log("keys", JSON.stringify(Object.keys(et2)), JSON.stringify(et2), String(et2)); +const ev = new Event("e"); +console.log("evsyms", Object.getOwnPropertySymbols(ev).map(String).join(","), Object.keys(ev).join(",")); +console.log(Object.getOwnPropertyNames(ev).join(",")); +class Bus extends EventTarget { n = 1; } +const b = new Bus(); +b.addEventListener("z", (e) => console.log("bus got", e.type, e.target === b)); +b.dispatchEvent(new Event("z")); +console.log("bus keys", Object.keys(b).join(","), Object.getOwnPropertySymbols(b).length === Object.getOwnPropertySymbols(et2).length); +const o = Object.setPrototypeOf({}, EventTarget.prototype); +try { o.addEventListener("q", () => {}); console.log("borrowed ok"); } catch (e: any) { console.log("borrowed threw", e?.constructor?.name); } +console.log(new DOMException("m", "AbortError").name, new DOMException("m", "AbortError").code, new DOMException("m") instanceof Error); +const sig = AbortSignal.abort("why"); +console.log("static abort", sig.aborted, sig.reason); +const m = new Map([[new EventTarget(), 1], [new EventTarget(), 2]]); +console.log("map", m.size); diff --git a/test-files/test_gap_honest_symbol_consumers.ts b/test-files/test_gap_honest_symbol_consumers.ts new file mode 100644 index 00000000000..08212a55d3c --- /dev/null +++ b/test-files/test_gap_honest_symbol_consumers.ts @@ -0,0 +1,58 @@ +const s1 = Symbol("s1"); +const s2 = Symbol("s2"); +function mk(): any { + const o: any = { a: 1 }; + o[s1] = 10; + o.b = 2; + o[s2] = 20; + o.c = 3; + return o; +} +const o = mk(); +console.log("keys", JSON.stringify(Object.keys(o))); +console.log("names", JSON.stringify(Object.getOwnPropertyNames(o))); +console.log("ownKeys", Reflect.ownKeys(o).map(String).join(",")); +console.log("syms", Object.getOwnPropertySymbols(o).map(String).join(",")); +console.log("entries", JSON.stringify(Object.entries(o))); +console.log("values", JSON.stringify(Object.values(o))); +const fi: string[] = []; +for (const k in o) fi.push(k); +console.log("forin", fi.join(",")); +console.log("json", JSON.stringify(o)); +const as: any = Object.assign({}, o); +console.log("assign", as[s1], as[s2], Reflect.ownKeys(as).map(String).join(",")); +const sp: any = { ...o }; +console.log("spread", sp[s1], sp[s2], Reflect.ownKeys(sp).map(String).join(",")); +const sc: any = structuredClone(o); +console.log("clone", sc[s1], Reflect.ownKeys(sc).map(String).join(",")); +console.log("in", "a" in o, s1 in o, "s1" in o, o.hasOwnProperty("b"), o.hasOwnProperty(s2)); +delete o.b; +console.log("afterdel", Reflect.ownKeys(o).map(String).join(","), o.c, o[s2]); +delete o[s1]; +console.log("afterdelsym", Reflect.ownKeys(o).map(String).join(","), o.a, o.c, o[s2]); +o[s1] = 11; +console.log("readd", o[s1], Reflect.ownKeys(o).map(String).join(",")); +const big: any = {}; +for (let i = 0; i < 20; i++) { big["k" + i] = i; if (i % 5 === 0) big[Symbol.for("g" + i)] = -i; } +console.log("big", Object.keys(big).length, Object.getOwnPropertySymbols(big).length, big.k19, big[Symbol.for("g15")]); +Object.freeze(o); +console.log("frozen", Object.isFrozen(o), Object.getOwnPropertyDescriptor(o, s2)!.writable); +class C { x = 1; [s1] = 5; y = 2; } +const c = new C(); +console.log("class", JSON.stringify(c), Reflect.ownKeys(c).map(String).join(","), c[s1]); +const d = Object.getOwnPropertyDescriptors(o); +console.log("descs", Reflect.ownKeys(d).map(String).join(",")); +const e: any = {}; +Object.defineProperty(e, s1, { get() { return 99; }, enumerable: false }); +e.z = 1; +console.log("acc", e[s1], JSON.stringify(Object.keys(e)), Object.getOwnPropertySymbols(e).length); +const m = new Map([[o, 1]]); +console.log("map", m.get(o)); +const iter: any = { [Symbol.iterator]: function* () { yield 1; yield 2; }, n: 1 }; +console.log("iter", [...iter].join(","), Object.keys(iter).join(",")); +const tag: any = { [Symbol.toStringTag]: "Tagged", q: 1 }; +console.log("tag", String(tag), Object.prototype.toString.call(tag)); +function rd(x: any) { return x[s2]; } +let acc = 0; const objs = [mk(), mk(), { [s2]: 7 }, mk()]; +for (let i = 0; i < 1000; i++) acc += rd(objs[i & 3]); +console.log("ic", acc); diff --git a/test-files/test_gap_honest_symbol_shapes.ts b/test-files/test_gap_honest_symbol_shapes.ts new file mode 100644 index 00000000000..4dae7cdc296 --- /dev/null +++ b/test-files/test_gap_honest_symbol_shapes.ts @@ -0,0 +1,28 @@ +// Symbol data and accessors must remain ordinary properties across shape edits. +const key = Symbol('value'); +function read(object: any, symbol: symbol): any { return object[symbol]; } +function nested(object: any, symbol: symbol): any { return object[symbol].value; } +const object: any = { prefix: 1, [key]: { value: 7 } }; +console.log('warm', nested(object, key), nested(object, key), read(object, key) === object[key]); +delete object.prefix; +console.log('shifted', nested(object, key), nested(object, key), Object.keys(object).length); +object[key] = { value: 9 }; +console.log('stored', nested(object, key), nested(object, key)); +let calls = 0; +Object.defineProperty(object, key, { get() { calls++; return { value: 20 + calls }; }, configurable: true, enumerable: true }); +console.log('accessor', nested(object, key), nested(object, key), calls); +Object.defineProperty(object, key, { value: 42, writable: false, configurable: true, enumerable: false }); +console.log('descriptor', read(object, key), Reflect.set(object, key, 99), read(object, key)); +console.log('reflection', Object.getOwnPropertySymbols(object).map(String).join(','), Reflect.ownKeys(object).map(String).join(','), JSON.stringify(object)); +console.log('spread', Object.getOwnPropertySymbols({ ...object }).length); +console.log('delete', Reflect.deleteProperty(object, key), Object.getOwnPropertySymbols(object).length, read(object, key)); +object[key] = 13; +console.log('recreated', read(object, key), Object.getOwnPropertyDescriptor(object, key)!.enumerable); +const frozen = Object.freeze({ [key]: 4 }); +console.log('frozen', Reflect.set(frozen, key, 8), frozen[key], Object.isFrozen(frozen)); +// Repeated loop reads exercise one IC site rather than separately inlined calls. +const warmKey = Symbol('warm'); +const warmObject: any = { prefix: 1, [warmKey]: 17 }; +let warmTotal = 0; +for (let i = 0; i < 20; i++) warmTotal += warmObject[warmKey]; +console.log('loop-cache', warmTotal); From 31f33992bfa646e0e3a7b98e737aed88a8a3649a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 17:33:53 +0000 Subject: [PATCH 2/6] fix: a built-in without an intrinsic static surface keeps its receiver; an instanceof miss on a materialized prototype is final --- crates/perry-hir/src/analysis.rs | 2 +- crates/perry-hir/src/analysis/builtins.rs | 20 +++++++++++++--- .../src/lower/expr_member/member_tail.rs | 23 +++++++++---------- .../src/object/instanceof/static_dispatch.rs | 8 +++++++ 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 6d594e2b509..8ff47cdb8bf 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -12,7 +12,7 @@ mod builtins; pub(crate) use builtins::is_builtin_global_value_name; pub(crate) use builtins::{ builtin_constructor_length, builtin_global_function_length, builtin_static_function_length, - is_builtin_function, is_builtin_static_function_member, + has_intrinsic_static_surface, is_builtin_function, is_builtin_static_function_member, }; /// Whether `name` is one of the global constructors / namespaces the runtime diff --git a/crates/perry-hir/src/analysis/builtins.rs b/crates/perry-hir/src/analysis/builtins.rs index 8cd6c9ebde7..e89d059afa9 100644 --- a/crates/perry-hir/src/analysis/builtins.rs +++ b/crates/perry-hir/src/analysis/builtins.rs @@ -267,7 +267,21 @@ pub(crate) fn builtin_global_function_length(name: &str) -> Option { /// surfaced by `built-ins/Function` Test262 (38× `Cannot read … (reading /// 'name')` on the #799 radar). pub(crate) fn is_builtin_static_function_member(namespace: &str, member: &str) -> bool { - match namespace { + intrinsic_static_function_member(namespace, member).unwrap_or(false) +} + +/// Whether `namespace` has an intrinsic static surface at all: some static +/// member of it lowers through an intrinsic keyed on the collapsed +/// `GlobalGet(0)` receiver. A built-in without one is only ever read as the +/// real object it is, so its member reads keep their receiver. +pub(crate) fn has_intrinsic_static_surface(namespace: &str) -> bool { + intrinsic_static_function_member(namespace, "").is_some() +} + +/// `None` when `namespace` has no intrinsic static surface; otherwise whether +/// `member` is one of its intrinsic static functions. +fn intrinsic_static_function_member(namespace: &str, member: &str) -> Option { + Some(match namespace { "Math" => matches!( member, "abs" @@ -387,8 +401,8 @@ pub(crate) fn is_builtin_static_function_member(namespace: &str, member: &str) - member, "compile" | "compileStreaming" | "instantiate" | "instantiateStreaming" | "validate" ), - _ => false, - } + _ => return None, + }) } /// Spec-defined `.length` for static *function* members recognized by diff --git a/crates/perry-hir/src/lower/expr_member/member_tail.rs b/crates/perry-hir/src/lower/expr_member/member_tail.rs index 65a8b87a6b4..fb9a4669244 100644 --- a/crates/perry-hir/src/lower/expr_member/member_tail.rs +++ b/crates/perry-hir/src/lower/expr_member/member_tail.rs @@ -481,18 +481,17 @@ pub(crate) fn lower_member_tail( ast::MemberProp::Computed(c) if !matches!(c.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(_))) ); - // Event-family constructors expose ordinary static properties. - // Keep their actual receiver for constants and inherited members. - let receiver_is_event_ctor = matches!( - property.as_str(), - "EventTarget" - | "Event" - | "CustomEvent" - | "AbortController" - | "AbortSignal" - | "DOMException" - ); - if !receiver_is_event_ctor + // The collapse exists only to feed intrinsic static + // lowering. A built-in with no intrinsic static surface + // is only ever the real object it is (a constructor + // function object with its own properties): `X.prop` + // reads that object's own or inherited property, never + // `globalThis.prop`. `.name` / `.length` keep their + // dedicated folds below, which key on the collapse. + let receiver_is_plain_builtin_object = + !crate::analysis::has_intrinsic_static_surface(property) + && !matches!(outer_static_member, Some("name" | "length")); + if !receiver_is_plain_builtin_object && !outer_is_prototype_or_proto && !outer_is_constructor_property && !receiver_is_namespace_value diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index b8323963534..8b821f95809 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -136,6 +136,14 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { if matches == Some(true) { return true_val; } + // A miss is final when the value's own prototype is materialized: + // the walk read the authoritative chain. Without one, the chain is + // carried by the class id, which the paths below still follow. + if matches == Some(false) + && crate::object::prototype_chain::object_static_prototype(value_addr(value)).is_some() + { + return false_val; + } } // Subclass-of-built-in: see `subclass_of_builtin_reaches`. From 4afbd8d2edeb1b807e924b7ca0feb0ba2cc5982a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 17:44:16 +0000 Subject: [PATCH 3/6] gc_call_effects: regenerate linux-x86_64 for the event surface; changelog --- changelog.d/honest-eventtarget.md | 2 +- crates/perry-codegen/src/gc_effects/linux-x86_64.tsv | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/changelog.d/honest-eventtarget.md b/changelog.d/honest-eventtarget.md index 392051c0b42..cea6ab13609 100644 --- a/changelog.d/honest-eventtarget.md +++ b/changelog.d/honest-eventtarget.md @@ -1 +1 @@ -EventTarget, Event, CustomEvent, AbortController and AbortSignal use shared prototype methods and accessors, with state in ordinary symbol-keyed shape properties. Reflect exposed symbols like Node and keep private state hidden. Preserve DOMException Error semantics, subclass prototype inheritance and named worker-transfer rejection. +EventTarget, Event, CustomEvent, AbortController and AbortSignal use shared prototype methods and accessors, with state in ordinary symbol-keyed shape properties. Reflect exposed symbols like Node and keep private state hidden. Preserve DOMException Error semantics, subclass prototype inheritance and named worker-transfer rejection. A built-in global with no intrinsic static surface keeps its receiver, so `Event.NONE` and `DOMException.ABORT_ERR` read the constructor object itself. An `instanceof` miss on an object whose own prototype is materialized is final. diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index 7f84742d390..721268f3e27 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -21,17 +21,17 @@ global_this_builtin_noop_thunk Leaf js_abort_controller_abort Reenters js_abort_controller_abort_reason Reenters js_abort_controller_new Reenters -js_abort_controller_signal Leaf +js_abort_controller_signal Reenters js_abort_error_value Reenters js_abort_signal_abort Reenters js_abort_signal_add_listener Reenters js_abort_signal_any Reenters -js_abort_signal_is_aborted Leaf +js_abort_signal_is_aborted Reenters js_abort_signal_listener_count Reenters js_abort_signal_listeners_copy Reenters js_abort_signal_remove_listener Reenters js_abort_signal_resolve_ptr Leaf -js_abort_signal_throw_if_aborted ThrowOnly +js_abort_signal_throw_if_aborted Reenters js_abort_signal_timeout Reenters js_add Leaf js_add_throw_if_symbol Reenters @@ -1036,7 +1036,7 @@ js_error_get_kind Leaf js_error_get_message AllocOnly js_error_get_name Reenters js_error_get_stack Reenters -js_error_is_error Leaf +js_error_is_error Reenters js_error_new Reenters js_error_new_from_value Reenters js_error_new_kind_from_value Reenters @@ -1103,6 +1103,7 @@ js_event_target_new Reenters js_event_target_remove_event_listener Reenters js_event_target_remove_event_listener_with_options Reenters js_event_target_set_max_listeners Reenters +js_event_target_subclass_init Reenters js_events_add_abort_listener Reenters js_events_get_event_listeners Reenters js_events_get_max_listeners Reenters @@ -2142,7 +2143,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters From 2d365b5a3e1b796794df2c31c64eb810384377bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 19:28:50 +0000 Subject: [PATCH 4/6] fix(hir): the kept-receiver rule applies to built-in constructors, not namespace objects --- crates/perry-hir/src/analysis/builtins.rs | 2 +- .../src/lower/expr_member/member_tail.rs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/perry-hir/src/analysis/builtins.rs b/crates/perry-hir/src/analysis/builtins.rs index e89d059afa9..a33d6d195ff 100644 --- a/crates/perry-hir/src/analysis/builtins.rs +++ b/crates/perry-hir/src/analysis/builtins.rs @@ -207,7 +207,7 @@ pub(crate) fn builtin_constructor_length(name: &str) -> Option { } "Symbol" | "Map" | "Set" | "WeakMap" | "WeakSet" | "MessageChannel" | "MessagePort" | "Navigator" | "EventTarget" | "TextEncoderStream" | "TextDecoderStream" - | "DOMException" | "Storage" | "FormData" => 0, + | "DOMException" | "Storage" | "FormData" | "AbortController" | "AbortSignal" => 0, "CompressionStream" | "DecompressionStream" => 1, "RegExp" | "Proxy" | "File" => 2, "BroadcastChannel" => 1, diff --git a/crates/perry-hir/src/lower/expr_member/member_tail.rs b/crates/perry-hir/src/lower/expr_member/member_tail.rs index fb9a4669244..52e93f02bf5 100644 --- a/crates/perry-hir/src/lower/expr_member/member_tail.rs +++ b/crates/perry-hir/src/lower/expr_member/member_tail.rs @@ -482,14 +482,17 @@ pub(crate) fn lower_member_tail( if !matches!(c.expr.as_ref(), ast::Expr::Lit(ast::Lit::Str(_))) ); // The collapse exists only to feed intrinsic static - // lowering. A built-in with no intrinsic static surface - // is only ever the real object it is (a constructor - // function object with its own properties): `X.prop` - // reads that object's own or inherited property, never - // `globalThis.prop`. `.name` / `.length` keep their - // dedicated folds below, which key on the collapse. + // lowering. A built-in constructor with no intrinsic + // static surface is only ever the real function object + // it is, with its own properties: `X.prop` reads that + // object's own or inherited property, never + // `globalThis.prop`. Namespace objects (`console`, + // `process`, `Buffer`) are not constructors and keep + // their intrinsic lowering. `.name` / `.length` keep + // their dedicated folds below, which key on the collapse. let receiver_is_plain_builtin_object = - !crate::analysis::has_intrinsic_static_surface(property) + crate::analysis::builtin_constructor_length(property).is_some() + && !crate::analysis::has_intrinsic_static_surface(property) && !matches!(outer_static_member, Some("name" | "length")); if !receiver_is_plain_builtin_object && !outer_is_prototype_or_proto From 5b87cbad99917c29136e6da10125dafc5c2b0104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 20:38:32 +0000 Subject: [PATCH 5/6] fix(runtime): keep external buffer ownership in GC cells --- changelog.d/honest-external-buffer.md | 1 + .../src/gc_effects/linux-x86_64.tsv | 1 - .../src/gc_effects/macos-aarch64.tsv | 1 - .../src/gc_effects/windows-x86_64.tsv | 1 - .../perry-codegen/src/wasm32/runtime_abi.tsv | 1 - crates/perry-runtime/src/agent.rs | 4 + .../src/array/buffer_receiver.rs | 15 +- crates/perry-runtime/src/array/header.rs | 8 +- crates/perry-runtime/src/buffer/header.rs | 281 +++++++----------- crates/perry-runtime/src/buffer/mod.rs | 19 +- .../src/gc/tests/buffer_side_tables.rs | 2 +- .../src/gc/tests/external_buffer.rs | 101 +++++++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + crates/perry-runtime/src/gc/types.rs | 6 +- .../src/hot_diag/receiver_repr.rs | 12 +- .../src/node_api_host/buffers.rs | 13 +- .../src/node_api_host/lifecycle.rs | 12 +- .../src/node_api_host/metadata.rs | 19 -- .../perry-runtime/src/node_api_host/tests.rs | 130 ++++++++ crates/perry-runtime/src/registry_latch.rs | 4 +- crates/perry-runtime/src/shared_sab.rs | 4 +- crates/perry-runtime/src/thread.rs | 13 +- .../symbols_tests.rs | 26 +- crates/perry-stdlib/src/webcrypto/util.rs | 10 +- scripts/run_honest_external_buffer.sh | 39 +++ scripts/thread_exit_address_globals.json | 11 +- .../fixtures/honest-external-buffer/addon.c | 47 +++ .../honest-external-buffer/surface.ts | 30 ++ 28 files changed, 534 insertions(+), 278 deletions(-) create mode 100644 changelog.d/honest-external-buffer.md create mode 100644 crates/perry-runtime/src/gc/tests/external_buffer.rs create mode 100755 scripts/run_honest_external_buffer.sh create mode 100644 test-files/fixtures/honest-external-buffer/addon.c create mode 100644 test-files/fixtures/honest-external-buffer/surface.ts diff --git a/changelog.d/honest-external-buffer.md b/changelog.d/honest-external-buffer.md new file mode 100644 index 00000000000..f6410a6c68d --- /dev/null +++ b/changelog.d/honest-external-buffer.md @@ -0,0 +1 @@ +External buffer wrappers carry their native byte pointer and Node-API finalizer in the GC buffer cell. Remove the legacy external-buffer registration API and address tables, retain byte-address identity, release native resources after collection or environment shutdown, and reject native-backed wrappers and views by name at worker transfer. diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index 721268f3e27..e3bdf3f85fa 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -445,7 +445,6 @@ js_buffer_read_uint32_le Reenters js_buffer_read_uint8 Reenters js_buffer_read_uint_be Reenters js_buffer_read_uint_le Reenters -js_buffer_register_external Leaf js_buffer_resolve_object_url Leaf js_buffer_set Leaf js_buffer_set_from Reenters diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 56567a8030d..ee8b2811dc1 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -445,7 +445,6 @@ js_buffer_read_uint32_le Reenters js_buffer_read_uint8 Reenters js_buffer_read_uint_be Reenters js_buffer_read_uint_le Reenters -js_buffer_register_external Leaf js_buffer_resolve_object_url Leaf js_buffer_set Leaf js_buffer_set_from Reenters diff --git a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv index fd131502863..e938fc842bf 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -445,7 +445,6 @@ js_buffer_read_uint32_le Reenters js_buffer_read_uint8 Reenters js_buffer_read_uint_be Reenters js_buffer_read_uint_le Reenters -js_buffer_register_external Leaf js_buffer_resolve_object_url Leaf js_buffer_set Leaf js_buffer_set_from Reenters diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index f47c5fbd4dd..41d81236203 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -440,7 +440,6 @@ js_buffer_read_uint32_le f64 f64,i32s js_buffer_read_uint8 f64 f64,i32s js_buffer_read_uint_be f64 f64,i32s,i32s js_buffer_read_uint_le f64 f64,i32s,i32s -js_buffer_register_external void usize js_buffer_resolve_object_url f64 f64 js_buffer_set void ptr,i32s,i32s js_buffer_set_from void ptr,ptr,i32s diff --git a/crates/perry-runtime/src/agent.rs b/crates/perry-runtime/src/agent.rs index ce7d5830e22..43f2871e4e4 100644 --- a/crates/perry-runtime/src/agent.rs +++ b/crates/perry-runtime/src/agent.rs @@ -155,6 +155,10 @@ pub fn retire_agent(id: AgentId) { id, PRIMARY_AGENT, "the primary agent outlives the process; it is never retired" ); + // Finish native-addon ownership while this agent's heap and loop are + // still live. TLS destruction cannot safely call back into the runtime. + #[cfg(feature = "node-api-host")] + crate::node_api_host::shutdown_current_env(); // turnloop P9: this agent may own a `turnloop::Loop`. Tear it down FIRST, // while the arena is still mapped and this thread can still run the // bindings' completion sinks. diff --git a/crates/perry-runtime/src/array/buffer_receiver.rs b/crates/perry-runtime/src/array/buffer_receiver.rs index 5ffc0af57c8..84f9b71fdb6 100644 --- a/crates/perry-runtime/src/array/buffer_receiver.rs +++ b/crates/perry-runtime/src/array/buffer_receiver.rs @@ -63,18 +63,9 @@ pub(crate) fn buffer_receiver_dispatch( // arena-backed `GC_TYPE_ARRAY` cannot be a registered Buffer, so an // ordinary `[1,2,3].map(…)` reaches NEITHER registry. // - // `arena_payload_has_gc_type` rather than a bare header-byte read (or an - // open-coded address floor): a Buffer comes in BOTH backings, and an - // EXTERNAL one — `EXTERNAL_BUFFER_REGISTRY`, `shared_sab::alloc_shared_sab` - // — has no `GcHeader` at all. The eight bytes below its payload are - // allocator bookkeeping and can read as any `obj_type`, `GC_TYPE_ARRAY` - // included. A bare tag read would therefore skip the probe for exactly the - // receiver this function exists to catch, silently and only sometimes. - // The predicate range-checks, rejects `HeapSpace::Unknown` for the HEADER - // address, and validates through `gc_type_info` before trusting the byte; - // it answers `false` for an external buffer, which falls through to the - // registry — the authoritative answer. See `array/header.rs`'s - // `array_receiver_gc_tag` doc (#8142). + // Prove ownership before trusting the tag. Process-global SABs have a + // real header but live outside this thread's arena; they still need the + // buffer classification below rather than an arena-only rejection. if unsafe { crate::typedarray::arena_payload_has_gc_type(addr, crate::gc::GC_TYPE_ARRAY) } { return None; } diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index f5649050f22..2e81febb99e 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -85,11 +85,9 @@ pub(crate) unsafe fn array_object_flags_resolved(arr: *const ArrayHeader) -> u16 /// These carry a genuine `GcHeader` with a correct `obj_type`; they are /// pinned rather than movable, which is a different property from being /// untracked. This is the population #8041 started nulling. -/// * **external** — `EXTERNAL_BUFFER_REGISTRY` / -/// `EXTERNAL_UINT8ARRAY_REGISTRY` addresses, plus -/// `shared_sab::alloc_shared_sab`'s `alloc_zeroed`. For these the eight -/// bytes below the payload really are allocator bookkeeping and can read -/// as any value. +/// * **process-global** — `shared_sab::alloc_shared_sab` owns a real header +/// outside the current thread's GC heap. Registration alone does not prove +/// membership in that heap. /// /// So the tag is authoritative only once the address is known to be /// arena-backed. `typedarray::arena_payload_has_gc_type` is the predicate diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index 5d72f0f5fba..32868627e27 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -30,16 +30,8 @@ use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; -static EXTERNAL_BUFFER_REGISTRY: OnceLock>> = OnceLock::new(); -/// Latched true by the first external-buffer registration. Lets the hot -/// `is_registered_buffer` probe — which JSON.stringify runs for every pointer -/// value it serializes (#6009) — skip the registry mutex entirely in the -/// (overwhelmingly common) processes that never register an external buffer. -static EXTERNAL_BUFFERS_NONEMPTY: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); static EXTERNAL_UINT8ARRAY_REGISTRY: OnceLock>> = OnceLock::new(); -/// Latched by the first external Uint8Array registration, exactly as -/// `EXTERNAL_BUFFERS_NONEMPTY` does for buffers. +/// Latched by the first external Uint8Array registration. /// /// Without it `is_uint8array_buffer_slow` took the global mutex on every /// thread-local MISS — that is, on every value that is not a Uint8Array — @@ -50,29 +42,15 @@ static EXTERNAL_UINT8ARRAYS_NONEMPTY: std::sync::atomic::AtomicBool = static EXTERNAL_CRYPTO_KEY_META_REGISTRY: OnceLock>> = OnceLock::new(); -fn external_buffers() -> &'static Mutex> { - crate::once_init::get_or_init(&EXTERNAL_BUFFER_REGISTRY, || Mutex::new(HashSet::new())) -} - -/// Diagnostic-only exact membership check for the legacy external-buffer ABI. -#[inline] -pub fn is_external_buffer(addr: usize) -> bool { - EXTERNAL_BUFFERS_NONEMPTY.load(std::sync::atomic::Ordering::Acquire) - && external_buffers() - .lock() - .map(|r| r.contains(&addr)) - .unwrap_or(false) -} - -/// Test probe (#11547): is `addr` in each of the three PROCESS-GLOBAL -/// external registries (`EXTERNAL_BUFFER_REGISTRY`, -/// `EXTERNAL_UINT8ARRAY_REGISTRY`, `EXTERNAL_CRYPTO_KEY_META_REGISTRY`)? +/// Test probe (#11547): is `addr` in each of the two PROCESS-GLOBAL +/// external registries (`EXTERNAL_UINT8ARRAY_REGISTRY`, +/// `EXTERNAL_CRYPTO_KEY_META_REGISTRY`)? /// /// Unlike `is_uint8array_buffer` / `crypto_key_meta`, which consult this /// thread's registries first, it touches no thread-local, so a thread-exit /// range hook may call it from a TLS destructor. Plain locks, no allocation. #[doc(hidden)] -pub fn external_registries_hold_for_test(addr: usize) -> [bool; 3] { +pub fn external_registries_hold_for_test(addr: usize) -> [bool; 2] { use std::sync::PoisonError; let in_set = |set: &OnceLock>>| { set.get().is_some_and(|s| { @@ -82,7 +60,6 @@ pub fn external_registries_hold_for_test(addr: usize) -> [bool; 3] { }) }; [ - in_set(&EXTERNAL_BUFFER_REGISTRY), in_set(&EXTERNAL_UINT8ARRAY_REGISTRY), EXTERNAL_CRYPTO_KEY_META_REGISTRY.get().is_some_and(|m| { m.lock() @@ -148,12 +125,6 @@ crate::perry_thread_local! { /// already there. Thread-local like the registry it guards, so there is /// no ordering to reason about. static BUFFER_ADDR_RANGE: Cell<(usize, usize)> = const { Cell::new((usize::MAX, 0)) }; - /// `BufferHeader` wrappers whose bytes live in memory owned by native - /// code. The wrapper itself is an ordinary, non-moving GC object; only - /// its data pointer is external. `bun:ffi.toArrayBuffer`/`toBuffer` use - /// this to expose native memory without copying it or taking ownership. - static FOREIGN_BACKING_REGISTRY: RefCell> = - RefCell::new(new_ptr_hash_map()); /// Buffers that were specifically created via `new Uint8Array(...)` — /// formatted as `Uint8Array(N) [ a, b, c ]` instead of ``. static UINT8ARRAY_FROM_CTOR: RefCell> = RefCell::new(new_ptr_hash_set()); @@ -259,9 +230,6 @@ static BUFFER_LIKE_EVER_REGISTERED: RegistryLatch = RegistryLatch::new(); /// /// It covers every table `is_registered_buffer_slow` consults: /// * `BUFFER_REGISTRY` — only `register_buffer` inserts, and it admits first; -/// * `EXTERNAL_BUFFER_REGISTRY` — both writers (`js_buffer_register_external` -/// and `js_buffer_mark_as_crypto_key_external`) route through -/// `register_buffer` with the same address first; /// * `shared_sab`'s process-global SAB registry — `alloc_shared_sab` calls /// [`note_buffer_like_registered`] with the backing address before it /// publishes. @@ -332,11 +300,6 @@ pub(crate) fn test_buffer_addr_window_bounds() -> Option<(usize, usize)> { BUFFER_LIKE_ADDR_WINDOW.bounds_for_tests() } -/// Avoid a thread-local map probe in `buffer_data{,_mut}` until the first -/// foreign-backed buffer is created. The latch is deliberately monotone; -/// these accessors are among the hottest paths in the runtime. -static FOREIGN_BACKING_EVER_REGISTERED: RegistryLatch = RegistryLatch::new(); - /// Arm the `is_registered_buffer` latch from outside this module. /// /// `shared_sab` publishes process-global backings that `is_registered_buffer` @@ -592,7 +555,7 @@ pub(crate) fn is_small_buf_slab_addr(_addr: usize) -> bool { pub fn is_registered_buffer(addr: usize) -> bool { // Nothing buffer-shaped has ever been registered anywhere in this process // ⟹ nothing to find, in one atomic load. `register_buffer` (which - // `js_buffer_register_external` also routes through) and + // foreign allocation also routes through) and // `shared_sab::alloc_shared_sab` both arm this latch before they publish. if BUFFER_LIKE_EVER_REGISTERED.is_idle() { return false; @@ -622,7 +585,7 @@ pub fn is_registered_buffer(addr: usize) -> bool { !is_registered_buffer_slow(addr), "BUFFER_LIKE_ADDR_WINDOW rejected {addr:#x}, but it IS a \ registered buffer. Some registration route reached \ - BUFFER_REGISTRY, the external-buffer registry or the \ + BUFFER_REGISTRY, the external-Uint8Array registry or the \ shared-SAB registry without calling \ `BUFFER_LIKE_ADDR_WINDOW.admit()` (via `register_buffer` or \ `note_buffer_like_registered`) first." @@ -665,14 +628,6 @@ fn is_registered_buffer_slow(addr: usize) -> bool { if addr >= lo && addr <= hi && BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) { return true; } - if EXTERNAL_BUFFERS_NONEMPTY.load(std::sync::atomic::Ordering::Acquire) - && external_buffers() - .lock() - .map(|r| r.contains(&addr)) - .unwrap_or(false) - { - return true; - } // #4913: recognise a process-global SAB backing reached as a module-level // value on a thread that never locally registered it (see // `is_shared_array_buffer`). @@ -720,11 +675,6 @@ fn release_external_buffer_registries_in_freed_ranges( let _ = slot.compare_exchange(old, 0, Ordering::Relaxed, Ordering::Relaxed); } } - if let Some(set) = EXTERNAL_BUFFER_REGISTRY.get() { - set.lock() - .unwrap_or_else(PoisonError::into_inner) - .retain(|&addr| !freed.contains(addr)); - } if let Some(set) = EXTERNAL_UINT8ARRAY_REGISTRY.get() { set.lock() .unwrap_or_else(PoisonError::into_inner) @@ -748,24 +698,6 @@ fn register_thread_exit_hook() { }); } -#[no_mangle] -pub extern "C" fn js_buffer_register_external(addr: usize) { - register_thread_exit_hook(); - register_buffer(addr as *const BufferHeader); - // Latch BEFORE the insert: a concurrent `is_registered_buffer` that - // observed the latch after the insert-but-before-the-store window would - // skip the mutex and miss an already-registered buffer. - EXTERNAL_BUFFERS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release); - if let Ok(mut r) = external_buffers().lock() { - r.insert(addr); - } - if crate::hot_diag::receiver_repr_on() { - crate::hot_diag::receiver_repr_note_constructed( - crate::hot_diag::ReceiverReprFamily::ExternalBuffer, - ); - } -} - #[no_mangle] pub extern "C" fn js_buffer_mark_as_uint8array_external(addr: usize) { mark_as_uint8array(addr); @@ -775,7 +707,7 @@ pub extern "C" fn js_buffer_mark_as_uint8array_external(addr: usize) { /// Insert into the process-global external-Uint8Array registry, arming /// `EXTERNAL_UINT8ARRAYS_NONEMPTY` first. /// -/// Latch BEFORE the insert, matching `js_buffer_register_external`: a probe +/// Latch BEFORE the insert: a probe /// that observed the latch in the insert-but-before-the-store window would /// skip the mutex and miss an already-registered address. /// @@ -861,11 +793,6 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external( register_buffer(addr as *const BufferHeader); mark_as_uint8array(addr); mark_as_crypto_key_with_flags(addr, algo, hash, kind, extractable != 0, usages, bit_length); - // Latch BEFORE the insert — see js_buffer_register_external. - EXTERNAL_BUFFERS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release); - if let Ok(mut r) = external_buffers().lock() { - r.insert(addr); - } register_external_uint8array(addr); if let Ok(mut r) = external_crypto_keys().lock() { r.insert( @@ -968,8 +895,8 @@ pub fn asymmetric_key_meta(addr: usize) -> Option<(u8, u8)> { /// excluded — their allocation is only a header. Runtime reads resolve /// through `buffer_data` to the ultimate backing plus the view offset; /// * foreign-backed wrappers (`buffer_alloc_foreign`, bun:ffi externals) are -/// excluded at prime time — their header is a lone `BufferHeader` with no -/// inline payload, so `header + 8` is past the allocation; +/// excluded at prime time — their payload after `BufferHeader` holds a +/// native data pointer, not inline bytes; /// * ABA is closed the same way as every other buffer identity table: /// `finalize_collected_dead_buffer` clears the entry when the buffer dies, /// and `register_buffer` clears it again when the address is re-issued @@ -1236,11 +1163,11 @@ pub fn buffer_alloc(capacity: u32) -> *mut BufferHeader { /// Allocate a Buffer-shaped GC wrapper over native-owned memory. /// -/// Only the `BufferHeader` is allocated in Perry's old arena. The byte span -/// remains owned by the native caller and is never freed by the GC. Callers -/// must keep that span alive for at least as long as the returned JS value. -/// The external mapping is removed when the wrapper is collected, preventing -/// recycled GC addresses from inheriting stale backing pointers. +/// The header and native data pointer live in Perry's old arena. The byte span +/// is owned by the caller, or released by the supplied Node-API finalizer. +/// A borrowed span must outlive the returned JS value. +/// Fresh allocations start with no foreign-data bit, so recycled addresses +/// cannot inherit a previous owner's native pointer. pub(crate) fn buffer_alloc_foreign(data: *mut u8, length: u32) -> *mut BufferHeader { // RULE 3: this wrapper is reached from `extern "C"` Node-API entry points // where a JS throw has nowhere to land, so the over-range span is clamped @@ -1255,81 +1182,114 @@ pub(crate) fn buffer_alloc_foreign(data: *mut u8, length: u32) -> *mut BufferHea length, ); let ptr = crate::arena::arena_alloc_gc_old( - std::mem::size_of::(), + std::mem::size_of::(), 8, crate::gc::GC_TYPE_BUFFER, - ) as *mut BufferHeader; + ) as *mut ForeignBuffer; unsafe { - let header = (ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; - (*header).gc_flags |= crate::gc::GC_FLAG_TENURED; - (*ptr).length = length; - (*ptr).capacity = length; + let gc = (ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*gc).gc_flags |= crate::gc::GC_FLAG_TENURED; + (*gc)._reserved |= crate::gc::GC_BUFFER_FOREIGN_DATA; + (*ptr).header.length = length; + (*ptr).header.capacity = length; + (*ptr).data = data; + #[cfg(feature = "node-api-host")] + { + (*ptr).finalizer = None; + } } - register_buffer(ptr); - // Arm before publishing the map entry; see `RegistryLatch`'s ordering - // contract and the analogous buffer-registration path above. - FOREIGN_BACKING_EVER_REGISTERED.arm(); - FOREIGN_BACKING_REGISTRY.with(|r| { - r.borrow_mut().insert(ptr as usize, data as usize); + register_buffer(ptr.cast()); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::ExternalBuffer, + ); + } + ptr.cast() +} + +/// Foreign bytes remain owned by the caller. The data pointer is a raw native +/// address, never a GC edge; Buffer's collector descriptor traces no byte slots. +#[repr(C)] +struct ForeignBuffer { + header: BufferHeader, + data: *mut u8, + #[cfg(feature = "node-api-host")] + finalizer: Option, +} + +#[cfg(feature = "node-api-host")] +pub(crate) fn set_foreign_finalizer( + buffer: *mut BufferHeader, + finalizer: Option, +) { + assert!(is_foreign_backed_buffer(buffer as usize)); + // Native callback/data/module identities are POD, never GC edges. + unsafe { (*(buffer as *mut ForeignBuffer)).finalizer = finalizer }; +} + +#[cfg(feature = "node-api-host")] +fn enqueue_foreign_finalizer(addr: usize) { + if is_foreign_backed_buffer(addr) { + if let Some(finalizer) = unsafe { (*(addr as *mut ForeignBuffer)).finalizer.take() } { + crate::node_api_host::enqueue_finalizer(finalizer); + } + } +} + +/// Node-API shutdown releases live native resources before unloading addons. +/// The existing allocation inventory enumerates owners; finalizer state itself +/// lives only in the cell. Taking it also prevents a later sweep running twice. +#[cfg(feature = "node-api-host")] +pub(crate) fn enqueue_all_foreign_finalizers() { + BUFFER_REGISTRY.with(|buffers| { + for &addr in buffers.borrow().iter() { + enqueue_foreign_finalizer(addr); + } }); - ptr } -/// Re-point a foreign-backed wrapper at a new span, in place. -/// -/// [`buffer_alloc_foreign`] hands out a wrapper whose bytes live in memory the -/// caller owns. When that memory MOVES the wrapper has to follow it or every -/// later read dereferences freed memory: wasmi's linear memory is a `Vec` -/// that reallocates when wasm executes `memory.grow`, and the JS-visible -/// `WebAssembly.Memory.prototype.buffer` is a foreign wrapper over it (#9611). -/// -/// Only touches a wrapper that really is foreign-backed — a plain buffer's -/// bytes live inline after its header and cannot be re-pointed. Returns -/// whether the rebind happened. -/// -/// Gated with `wasm-host`, whose module is its only caller: the default -/// runtime build does -/// not compile the wasm host shims, and an ungated helper would be dead code -/// there. +/// Rebind a wasm linear-memory wrapper after memory.grow relocates its bytes. #[cfg(feature = "wasm-host")] pub(crate) fn rebind_foreign_buffer(addr: usize, data: *mut u8, length: u32) -> bool { - if FOREIGN_BACKING_EVER_REGISTERED.is_idle() { + if !is_foreign_backed_buffer(addr) { return false; } - let rebound = FOREIGN_BACKING_REGISTRY.with(|r| { - let mut r = r.borrow_mut(); - if !r.contains_key(&addr) { - return false; - } - r.insert(addr, data as usize); - true - }); - if rebound { - // RULE 3: same clamp as `buffer_alloc_foreign` — a rebind may not - // publish a `+4` word the emitted read path would read as a ShapeId. - let length = crate::object::shape_rule3::clamp_plus_four_word( - "BufferHeader::capacity (foreign rebind)", - length, - ); - unsafe { - let header = addr as *mut BufferHeader; - (*header).length = length; - (*header).capacity = length; - } + let length = crate::object::shape_rule3::clamp_plus_four_word( + "BufferHeader::capacity (foreign rebind)", + length, + ); + unsafe { + let buffer = addr as *mut ForeignBuffer; + (*buffer).header.length = length; + (*buffer).header.capacity = length; + (*buffer).data = data; } - rebound + true } #[inline] fn foreign_backing(addr: usize) -> Option { - if FOREIGN_BACKING_EVER_REGISTERED.is_idle() { - return None; + // Only buffer_data calls this, with a live BufferHeader. Every producer, + // including process-global SAB, now reserves a real preceding GcHeader. + // Keep the byte-access hot path to a header-bit load, without a registry + // or ownership lookup for each byte read. + unsafe { + let gc = (addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*gc)._reserved & crate::gc::GC_BUFFER_FOREIGN_DATA == 0 { + return None; + } + Some((*(addr as *const ForeignBuffer)).data as usize) } - FOREIGN_BACKING_REGISTRY.with(|r| r.borrow().get(&addr).copied()) } pub(crate) fn is_foreign_backed_buffer(addr: usize) -> bool { - foreign_backing(addr).is_some() + // This public-address probe must prove ownership before reading the header. + // SAB is a buffer too, but cannot have this per-heap foreign-data layout. + unsafe { crate::value::addr_class::try_read_tracked_gc_header(addr) }.is_some_and(|header| { + let header = unsafe { header.as_ref() }; + header.obj_type == crate::gc::GC_TYPE_BUFFER + && header._reserved & crate::gc::GC_BUFFER_FOREIGN_DATA != 0 + }) } /// Post-trace registry pruning (mirrors the #6010 Map/Set pattern): collect @@ -1360,27 +1320,10 @@ unsafe fn registered_buffer_is_dead_post_trace( addr: usize, shared_sabs: Option<&std::collections::HashSet>, ) -> bool { - // A process-global `SharedArrayBuffer` backing is NOT a GC allocation: - // `shared_sab::alloc_shared_sab` takes it straight from `alloc_zeroed`, it - // carries no `GcHeader`, and it is never freed (#4913 — that is what lets - // the same bytes alias across `perry/thread` agents). But - // `js_shared_array_buffer_new` DOES `register_buffer` it, so it lands in - // `BUFFER_REGISTRY` and reaches this scan on every full trace. - // - // `try_read_gc_header` below would then read the 8 bytes BEFORE the malloc - // block — the allocator's own metadata — and interpret them as a `GcHeader`: - // one arbitrary byte compared against `GC_TYPE_BUFFER` (10), the next - // against the mark/pin/forward bits. A chance match declares a LIVE, - // never-freed SAB dead, and `finalize_collected_dead_buffer` then runs on - // it — including `view::remove_entries_for_dead_buffer`, which retains on - // `info.backing != addr` and so unregisters EVERY live typed-array view - // over that SAB. Those views are exactly how cross-agent `Atomics` - // wait/notify resolve their absolute slot addresses. - // - // So: veto first, and never sniff a header the object does not have. The - // set is snapshotted once per scan by the caller and is `None` for the - // processes that never allocate a SAB — nearly all of them — so the common - // path here is a single null check. + // Process-global SharedArrayBuffer cells carry real GC_TYPE_BUFFER + // headers, but are immortal storage outside this thread's GC heap. They + // must never become dead candidates merely because this heap did not + // mark them. Snapshot membership once per scan, before inspecting flags. if shared_sabs.is_some_and(|sabs| sabs.contains(&addr)) { return false; } @@ -1400,15 +1343,14 @@ unsafe fn registered_buffer_is_dead_post_trace( /// (`is_registered_buffer`/`is_array_buffer` misclassify the next tenant — /// the #6080 ABA class) and the entries leak forever. pub(crate) fn finalize_collected_dead_buffer(addr: usize) { + #[cfg(feature = "node-api-host")] + enqueue_foreign_finalizer(addr); BUFFER_REGISTRY.with(|r| { r.borrow_mut().remove(&addr); }); if crate::hot_diag::buffer_on() { crate::hot_diag::buffer_note_unregistration(); } - FOREIGN_BACKING_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); ARRAY_BUFFER_REGISTRY.with(|r| { r.borrow_mut().remove(&addr); }); @@ -1475,12 +1417,9 @@ pub(crate) fn finalize_collected_dead_buffer(addr: usize) { UINT8ARRAY_FROM_CTOR.with(|r| { r.borrow_mut().remove(&addr); }); - // `js_buffer_mark_as_crypto_key_external` writes all three global maps, and + // `js_buffer_mark_as_crypto_key_external` writes both global maps, and // `is_registered_buffer`/`is_uint8array_buffer` consult them, so a dead // external key buffer has to be dropped from every one of them. - if let Ok(mut r) = external_buffers().lock() { - r.remove(&addr); - } if let Ok(mut r) = external_uint8arrays().lock() { r.remove(&addr); } diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index b896430e128..3dc91165765 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -64,11 +64,11 @@ pub use header::{ asymmetric_key_meta, buffer_ab_alias, buffer_alloc, buffer_backing_array_buffer, buffer_byte_offset, buffer_data, buffer_data_mut, crypto_key_meta, ensure_buffer_ab_alias, external_registries_hold_for_test, is_any_array_buffer, is_array_buffer, is_data_view, - is_external_buffer, is_registered_buffer, is_secret_key, is_shared_array_buffer, - is_uint8array_buffer, js_set_crypto_key_death_hook, mark_as_array_buffer, - mark_as_asymmetric_key, mark_as_crypto_key, mark_as_data_view, mark_as_secret_key, - mark_as_shared_array_buffer, mark_as_uint8array, register_buffer, resolve_buffer_ab_alias, - set_buffer_ab_alias, u8_inline_cache_holds_for_test, CryptoKeyDeathHookFn, + is_registered_buffer, is_secret_key, is_shared_array_buffer, is_uint8array_buffer, + js_set_crypto_key_death_hook, mark_as_array_buffer, mark_as_asymmetric_key, mark_as_crypto_key, + mark_as_data_view, mark_as_secret_key, mark_as_shared_array_buffer, mark_as_uint8array, + register_buffer, resolve_buffer_ab_alias, set_buffer_ab_alias, u8_inline_cache_holds_for_test, + CryptoKeyDeathHookFn, }; pub(crate) use header::{ buffer_alloc_foreign, collect_dead_registered_buffers_post_trace, @@ -79,9 +79,9 @@ pub(crate) use header::{ pub(crate) use header::rebind_foreign_buffer; #[cfg(test)] pub(crate) use header::{ - js_buffer_register_external, test_buffer_addr_window_bounds, test_buffer_registry_probe_count, - test_data_view_registry_len, test_shared_array_buffer_registry_len, - test_uint8array_addr_window_bounds, test_uint8array_registry_probe_count, + test_buffer_addr_window_bounds, test_buffer_registry_probe_count, test_data_view_registry_len, + test_shared_array_buffer_registry_len, test_uint8array_addr_window_bounds, + test_uint8array_registry_probe_count, }; // ---- Re-exports: ArrayBuffer detach / transfer (ES2024) ---- @@ -667,3 +667,6 @@ mod tests { assert_eq!(js_buffer_get(buf, 1) as u8, 0x00); } } + +#[cfg(feature = "node-api-host")] +pub(crate) use header::{enqueue_all_foreign_finalizers, set_foreign_finalizer}; diff --git a/crates/perry-runtime/src/gc/tests/buffer_side_tables.rs b/crates/perry-runtime/src/gc/tests/buffer_side_tables.rs index f2ae810df33..321624d81e8 100644 --- a/crates/perry-runtime/src/gc/tests/buffer_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/buffer_side_tables.rs @@ -383,7 +383,7 @@ fn test_buffer_own_props_table_drains_after_owners_die() { /// Nothing pinned this before, and a great deal rests on it: `bun:ffi`'s /// pointer-lifetime contract hands `ptr(view)` to native code and documents /// the address as stable for the lifetime of the JS object -/// (`bun_ffi/mod.rs`); `FOREIGN_BACKING_REGISTRY`, `VIEW_REGISTRY`, +/// (`bun_ffi/mod.rs`); `VIEW_REGISTRY`, /// `BACKING_TO_VIEWS`, `DETACHED_BUFFER_REGISTRY` and the identity registries /// above are all keyed by that address; and #9611 publishes /// `WebAssembly.Memory.prototype.buffer` as a foreign-backed wrapper whose diff --git a/crates/perry-runtime/src/gc/tests/external_buffer.rs b/crates/perry-runtime/src/gc/tests/external_buffer.rs new file mode 100644 index 00000000000..2dbc66c698e --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/external_buffer.rs @@ -0,0 +1,101 @@ +//! Honest external-buffer storage: the wrapper is managed; its bytes are native. +use super::super::*; +use super::support::*; + +#[test] +fn external_buffer_producer_returns_headered_distinct_cells_and_exact_bytes() { + let _guard = GcTestIsolationGuard::new(); + let mut bytes = [11u8, 22, 33, 44]; + let first = crate::buffer::buffer_alloc_foreign(bytes.as_mut_ptr(), 4); + let second = crate::buffer::buffer_alloc_foreign(bytes.as_mut_ptr(), 4); + // Gate B precedes any dereference: both small handles and headerless Boxes fail. + for ptr in [first, second] { + assert!(!crate::value::addr_class::is_handle_band(ptr as usize)); + let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(ptr as usize) } + .expect("external-buffer producer must return an allocator-owned cell"); + assert_eq!(unsafe { header.as_ref().obj_type }, GC_TYPE_BUFFER); + let payload_size = + std::mem::size_of::() + std::mem::size_of::<*mut u8>(); + assert!(unsafe { header.as_ref().size as usize } >= GC_HEADER_SIZE + payload_size); + assert_eq!( + unsafe { *((ptr as *const u8).add(8) as *const *mut u8) }, + bytes.as_mut_ptr(), + "the native address must live in this cell, not a side table" + ); + assert!(crate::buffer::is_foreign_backed_buffer(ptr as usize)); + assert_eq!(crate::buffer::buffer_data(ptr), bytes.as_ptr()); + assert_eq!(unsafe { (*ptr).length }, 4); + } + assert_ne!(first, second, "two wrappers must have distinct identity"); + crate::buffer::js_buffer_set(first, 1, 99); + assert_eq!(bytes, [11, 99, 33, 44]); + assert_eq!(crate::buffer::js_buffer_get(second, 1), 99); + let plain = crate::buffer::buffer_alloc(4); + assert!(!crate::buffer::is_foreign_backed_buffer(plain as usize)); +} + +#[test] +fn external_buffer_cell_survives_gc_then_releases_its_registration() { + let _guard = CopyingNurseryTestGuard::new(1); + let mut bytes = [17u8, 29]; + let ptr = crate::buffer::buffer_alloc_foreign(bytes.as_mut_ptr(), 2); + let addr = ptr as usize; + js_shadow_slot_set(0, ptr_bits(addr)); + let _ = gc_collect_minor(); + let _ = + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); + assert_eq!( + js_shadow_slot_get(0), + ptr_bits(addr), + "FFI wrapper cannot move" + ); + assert!(crate::buffer::is_registered_buffer(addr)); + assert!(crate::buffer::is_foreign_backed_buffer(addr)); + assert_eq!(crate::buffer::buffer_data(ptr), bytes.as_ptr()); + assert_eq!(crate::buffer::js_buffer_get(ptr, 1), 29); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); + let _ = + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); + assert!( + !crate::buffer::is_registered_buffer(addr), + "dead wrapper registration must disappear" + ); + // A borrowed byte span is not the collector's resource to free or overwrite. + assert_eq!(bytes, [17, 29]); +} + +#[test] +fn external_buffer_crosses_a_worker_by_value_like_an_ordinary_buffer() { + let _guard = CopyingNurseryTestGuard::new(2); + let mut bytes = [42u8, 43]; + let ptr = crate::buffer::buffer_alloc_foreign(bytes.as_mut_ptr(), 2); + js_shadow_slot_set(0, ptr_bits(ptr as usize)); + let plain = crate::buffer::buffer_alloc(2); + js_shadow_slot_set(1, ptr_bits(plain as usize)); + crate::buffer::js_buffer_set(plain, 0, 42); + crate::buffer::js_buffer_set(plain, 1, 43); + // The native backing must be invisible at the boundary: a foreign wrapper + // serializes exactly as an ordinary buffer holding the same bytes (a + // structured clone of an external ArrayBuffer copies it in node). + for mark_uint8 in [false, true] { + if mark_uint8 { + crate::buffer::mark_as_uint8array(ptr as usize); + crate::buffer::mark_as_uint8array(plain as usize); + } + let foreign = unsafe { crate::thread::serialize_nanbox_for_thread(ptr_bits(ptr as usize)) }; + let ordinary = + unsafe { crate::thread::serialize_nanbox_for_thread(ptr_bits(plain as usize)) }; + assert_eq!( + crate::thread::first_unsupported_transfer_type(&foreign), + crate::thread::first_unsupported_transfer_type(&ordinary) + ); + if mark_uint8 { + match foreign { + crate::thread::SerializedValue::Uint8Array(copy) => assert_eq!(copy, [42, 43]), + _ => panic!("a foreign Uint8Array must cross as a byte copy"), + } + } + } + // The copy is a snapshot; the native span stays caller-owned. + assert_eq!(bytes, [42, 43]); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 9ec4324d678..df76e878706 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -119,3 +119,4 @@ mod dyn_eval_registry; mod private_evaluation_storage; mod event_internal_slots; +mod external_buffer; diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index b16fb19c722..d0c5c65f2a7 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1248,6 +1248,10 @@ pub const OBJ_FLAG_NO_EXTEND: u16 = 0x04; // (`GC_COPY_SURVIVAL_AGE_MASK = 0x0038`) and bits 14..15 the layout state, // so 0x08 would be clobbered on every minor GC. Bits 6..13 are free. pub const OBJ_FLAG_NULL_PROTO: u16 = 0x40; +/// The GC_TYPE_BUFFER payload contains a native data pointer after BufferHeader. +/// Bit 7 is kind-disjoint from object/array numeric-layout proofs. Generic age +/// and layout transitions preserve it; no Buffer reader interprets those proofs. +pub(crate) const GC_BUFFER_FOREIGN_DATA: u16 = 0x80; /// #8690: this `GC_TYPE_OBJECT` carries a cached proof that the packed /// Array-subclass element prefix recorded in `ObjectMeta::flags` is numeric. /// The bit is the address-reuse-safe authority: fresh allocations start with @@ -1418,7 +1422,7 @@ pub const OBJ_FLAG_PLAIN_ORDINARY: u16 = 0x200; /// | 0..2 | `OBJ_FLAG_FROZEN` / `SEALED` / `NO_EXTEND` | same | | /// | 3..5 | | | `GC_COPY_SURVIVAL_AGE_MASK` | /// | 6 | `OBJ_FLAG_NULL_PROTO` | `GC_ARRAY_CUSTOM_PROTO` (alias) | `GC_RESIDUAL_PROTO_OWNER` (non-object) | -/// | 7 | `OBJ_FLAG_PACKED_NUMERIC_PROOF` | `GC_ARRAY_RAW_F64_LAYOUT` | | +/// | 7 | `OBJ_FLAG_PACKED_NUMERIC_PROOF` | `GC_ARRAY_RAW_F64_LAYOUT` | BUFFER: `GC_BUFFER_FOREIGN_DATA` | /// | 8 | `OBJ_FLAG_TYPED_ARRAY_PROTO` | `GC_ARRAY_NAMED_PROPS` | | /// | 9 | `OBJ_FLAG_PLAIN_ORDINARY` | `GC_ARRAY_ARGUMENTS_OBJECT` | | /// | 10 | `OBJ_FLAG_STABLE_TOMBSTONES` | `OBJ_FLAG_ARRAY_DESCRIPTORS` | | diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index 5b737505ef6..3cc96e5f96e 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -241,9 +241,6 @@ fn observe_pointer(addr: usize) { if crate::symbol::is_registered_symbol(addr) { mark_old(ReceiverReprFamily::SymbolGlobal); } - if crate::buffer::is_external_buffer(addr) { - mark_old(ReceiverReprFamily::ExternalBuffer); - } return; } @@ -492,13 +489,8 @@ mod tests { false, ) }); - assert_fixture(ReceiverReprFamily::ExternalBuffer, || { - let buffer = Box::into_raw(Box::new(crate::buffer::BufferHeader { - length: 0, - capacity: 0, - })); - crate::buffer::js_buffer_register_external(buffer as usize); - (buffer as usize, false) + assert_fixture_migrated(ReceiverReprFamily::ExternalBuffer, || { + crate::buffer::buffer_alloc_foreign(std::ptr::null_mut(), 0) as usize }); assert_fixture(ReceiverReprFamily::Sab, || { (crate::shared_sab::alloc_shared_sab(1) as usize, false) diff --git a/crates/perry-runtime/src/node_api_host/buffers.rs b/crates/perry-runtime/src/node_api_host/buffers.rs index 90bbd0bda12..064d3eb4958 100644 --- a/crates/perry-runtime/src/node_api_host/buffers.rs +++ b/crates/perry-runtime/src/node_api_host/buffers.rs @@ -144,7 +144,10 @@ pub unsafe extern "C" fn napi_create_external_buffer( Err(status) => return status, }; let buffer = crate::buffer::buffer_alloc_foreign(data.cast(), length); - super::metadata::attach_owner_finalizer(buffer as usize, data, finalize_cb, finalize_hint); + crate::buffer::set_foreign_finalizer( + buffer, + super::metadata::finalizer(finalize_cb, data, finalize_hint), + ); write_pointer_handle(env, buffer.cast(), result) } @@ -235,11 +238,9 @@ pub unsafe extern "C" fn napi_create_external_arraybuffer( }; let buffer = crate::buffer::buffer_alloc_foreign(external_data.cast(), byte_length); crate::buffer::mark_as_array_buffer(buffer as usize); - super::metadata::attach_owner_finalizer( - buffer as usize, - external_data, - finalize_cb, - finalize_hint, + crate::buffer::set_foreign_finalizer( + buffer, + super::metadata::finalizer(finalize_cb, external_data, finalize_hint), ); write_pointer_handle(env, buffer.cast(), result) } diff --git a/crates/perry-runtime/src/node_api_host/lifecycle.rs b/crates/perry-runtime/src/node_api_host/lifecycle.rs index e83ee826940..e2caaf61d6b 100644 --- a/crates/perry-runtime/src/node_api_host/lifecycle.rs +++ b/crates/perry-runtime/src/node_api_host/lifecycle.rs @@ -429,7 +429,16 @@ pub unsafe extern "C" fn napi_fatal_exception(env: NapiEnv, error: NapiValue) -> /// Idempotent owning-agent shutdown. Cleanup callbacks and finalizers run only /// after all internal borrows have been released. pub fn shutdown_current_env() { - let env_pointer = current_env(); + // A worker that never loaded an addon has no environment to shut down. + // Do not create one during retirement. + let env_pointer = NODE_API_ENV.with(|cell| { + cell.borrow_mut() + .as_deref_mut() + .map(|env| env as *mut Env as NapiEnv) + }); + let Some(env_pointer) = env_pointer else { + return; + }; let state = with_env_mut(env_pointer, |env| { if env.shutting_down { return None; @@ -482,6 +491,7 @@ pub fn shutdown_current_env() { super::metadata::enqueue_finalizer(finalizer); } } + crate::buffer::enqueue_all_foreign_finalizers(); super::metadata::enqueue_all_object_finalizers(); super::process_pending(); super::loader::close_loaded_addons(env_pointer); diff --git a/crates/perry-runtime/src/node_api_host/metadata.rs b/crates/perry-runtime/src/node_api_host/metadata.rs index c4775571c7b..ee13cf9f82d 100644 --- a/crates/perry-runtime/src/node_api_host/metadata.rs +++ b/crates/perry-runtime/src/node_api_host/metadata.rs @@ -104,25 +104,6 @@ pub(crate) fn attach_external_finalizer( }); } -pub(crate) fn attach_owner_finalizer( - owner: usize, - data: *mut c_void, - callback: NapiFinalize, - hint: *mut c_void, -) { - let Some(finalizer) = finalizer(callback, data, hint) else { - return; - }; - OBJECT_METADATA.with(|table| { - table - .borrow_mut() - .entry(owner) - .or_default() - .finalizers - .push(finalizer); - }); -} - pub(crate) fn is_external_owner(owner: usize) -> bool { OBJECT_METADATA.with(|table| { table diff --git a/crates/perry-runtime/src/node_api_host/tests.rs b/crates/perry-runtime/src/node_api_host/tests.rs index 6f47a4fee59..c892a12777b 100644 --- a/crates/perry-runtime/src/node_api_host/tests.rs +++ b/crates/perry-runtime/src/node_api_host/tests.rs @@ -1185,3 +1185,133 @@ fn run_script_and_uv_loop_fail_explicitly() { ); assert!(loop_pointer.is_null()); } + +#[test] +fn external_buffer_finalizer_runs_once_after_the_owner_dies() { + unsafe extern "C" fn release(_env: NapiEnv, data: *mut c_void, hint: *mut c_void) { + drop(Box::from_raw(data as *mut [u8; 3])); + (*(hint as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + } + let env = test_env(); + let releases = AtomicUsize::new(0); + let data = Box::into_raw(Box::new([65u8, 66, 67])); + let mut scope = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_open_handle_scope(env, &mut scope) }, + NapiStatus::Ok + ); + let mut value = std::ptr::null_mut(); + assert_eq!( + unsafe { + napi_create_external_buffer( + env, + 3, + data.cast(), + Some(release), + (&releases as *const AtomicUsize).cast_mut().cast(), + &mut value, + ) + }, + NapiStatus::Ok + ); + let owner = (value_bits(env, value).unwrap() & crate::value::POINTER_MASK) as usize; + assert!(crate::buffer::is_foreign_backed_buffer(owner)); + crate::gc::js_gc_collect(); + super::metadata::drain_pending_finalizers(); + assert_eq!( + releases.load(Ordering::SeqCst), + 0, + "live owner must retain bytes" + ); + assert_eq!( + unsafe { napi_close_handle_scope(env, scope) }, + NapiStatus::Ok + ); + crate::gc::js_gc_collect(); + super::metadata::drain_pending_finalizers(); + assert_eq!( + releases.load(Ordering::SeqCst), + 1, + "dead owner's native resource must be released" + ); + crate::gc::js_gc_collect(); + super::metadata::drain_pending_finalizers(); + assert_eq!( + releases.load(Ordering::SeqCst), + 1, + "finalizer entry must be removed" + ); + let live_data = Box::into_raw(Box::new([68u8, 69, 70])); + assert_eq!( + unsafe { + napi_create_external_buffer( + env, + 3, + live_data.cast(), + Some(release), + (&releases as *const AtomicUsize).cast_mut().cast(), + &mut value, + ) + }, + NapiStatus::Ok + ); + super::shutdown_current_env(); + super::shutdown_current_env(); + assert_eq!( + releases.load(Ordering::SeqCst), + 2, + "shutdown must release a still-live native buffer exactly once" + ); +} + +#[test] +fn external_buffer_arraybuffer_released_on_worker_retirement() { + unsafe extern "C" fn release(_env: NapiEnv, data: *mut c_void, hint: *mut c_void) { + drop(Box::from_raw(data as *mut [u8; 3])); + (*(hint as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + } + let releases = std::sync::Arc::new(AtomicUsize::new(0)); + let worker_releases = releases.clone(); + std::thread::spawn(move || { + let agent = crate::agent::enter_worker_agent(); + let env = current_env(); + let bytes = Box::into_raw(Box::new([11u8, 22, 33])); + let mut value = std::ptr::null_mut(); + assert_eq!( + unsafe { + napi_create_external_arraybuffer( + env, + bytes.cast(), + 3, + Some(release), + std::sync::Arc::as_ptr(&worker_releases).cast_mut().cast(), + &mut value, + ) + }, + NapiStatus::Ok + ); + let owner = (value_bits(env, value).unwrap() & crate::value::POINTER_MASK) as usize; + assert!(crate::buffer::is_foreign_backed_buffer(owner)); + assert!(crate::buffer::is_array_buffer(owner)); + let mut data = std::ptr::null_mut(); + let mut length = 0; + assert_eq!( + unsafe { napi_get_arraybuffer_info(env, value, &mut data, &mut length) }, + NapiStatus::Ok + ); + assert_eq!(data, bytes.cast()); + assert_eq!(length, 3); + assert_eq!(worker_releases.load(Ordering::SeqCst), 0); + crate::agent::retire_agent(agent); + assert_eq!( + worker_releases.load(Ordering::SeqCst), + 1, + "retiring the owning agent must finalize native bytes" + ); + crate::agent::retire_agent(agent); + assert_eq!(worker_releases.load(Ordering::SeqCst), 1); + }) + .join() + .unwrap(); + assert_eq!(releases.load(Ordering::SeqCst), 1); +} diff --git a/crates/perry-runtime/src/registry_latch.rs b/crates/perry-runtime/src/registry_latch.rs index 0dc12ae5e1d..8fb7d8f1457 100644 --- a/crates/perry-runtime/src/registry_latch.rs +++ b/crates/perry-runtime/src/registry_latch.rs @@ -41,8 +41,8 @@ //! window in which the feature is live and reachable but the latch still reads //! idle, so a concurrent probe takes the fast path and answers `false` for an //! address that is genuinely registered. That is not hypothetical: the sibling -//! latch `buffer::header::EXTERNAL_BUFFERS_NONEMPTY` carries an inline comment -//! for precisely this reason, and `js_buffer_register_external` latches first. +//! latch `buffer::header::EXTERNAL_UINT8ARRAYS_NONEMPTY` carries an inline comment +//! for precisely this reason, and `register_external_uint8array` latches first. //! //! With the arm placed first, the argument for a reader on *another* thread is: //! a thread can only probe an address it holds, and every route by which an diff --git a/crates/perry-runtime/src/shared_sab.rs b/crates/perry-runtime/src/shared_sab.rs index f311319df29..bd89c58972d 100644 --- a/crates/perry-runtime/src/shared_sab.rs +++ b/crates/perry-runtime/src/shared_sab.rs @@ -34,7 +34,7 @@ use crate::gc::{GcHeader, GC_FLAG_TENURED, GC_HEADER_SIZE, GC_TYPE_BUFFER}; /// Set of `BufferHeader` addresses that back a `SharedArrayBuffer`. static SHARED_SAB_REGISTRY: OnceLock>> = OnceLock::new(); -/// Latched true by the first SAB allocation. Mirrors `EXTERNAL_BUFFERS_NONEMPTY` +/// Latched true by the first SAB allocation. Mirrors the external-Uint8Array latch /// in `buffer::header` and exists for the same reason: `is_shared_sab` sits on /// two hot paths that run for *every* pointer-shaped value — /// `buffer::is_registered_buffer`'s final fallback (which JSON.stringify runs @@ -124,7 +124,7 @@ pub fn alloc_shared_sab(size: u32) -> *mut BufferHeader { // without it ever entering their thread-local registries, so both latches // must be armed before this address can be found — an arm placed after the // insert leaves a window in which the entry is live and a probe still takes - // the idle fast path. (This is the ordering `js_buffer_register_external` + // the idle fast path. (This is the ordering `register_buffer` // already documents; see also `crate::registry_latch`.) SHARED_SAB_NONEMPTY.store(true, Ordering::Release); crate::buffer::note_buffer_like_registered(buf as usize); diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 3cc34a5f2a5..3c81359c149 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -413,21 +413,18 @@ pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { return SerializedValue::Inline(TAG_UNDEFINED); } - // SharedArrayBuffer: a process-global backing store (no GcHeader). + // SharedArrayBuffer is an immortal process-global buffer cell. // Pass it by reference so the receiving agent aliases the same bytes - // (#4913). This MUST precede the GcHeader read below — a SAB header has - // no preceding GcHeader, so reading one would misclassify it. + // rather than taking the ordinary per-agent copy path below. if crate::shared_sab::is_shared_sab(raw_ptr as usize) { return SerializedValue::SharedArrayBuffer { addr: raw_ptr as usize, }; } - // Uint8Array can be backed by an ordinary GC allocation, a registered - // view, or an external BufferHeader with no preceding GcHeader. Brand - // detection must therefore precede the GcHeader read below, just like - // SharedArrayBuffer detection does. Always read through buffer_data so - // views and external storage copy their authoritative byte window. + // Byte views copy their authoritative span through buffer_data, so a + // foreign-backed Uint8Array crosses by value exactly as a structured + // clone does in node; its native backing is never shared. if crate::buffer::is_uint8array_buffer(raw_ptr as usize) { let buffer = raw_ptr as *const crate::buffer::BufferHeader; let len = (*buffer).length as usize; diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs index 4e46abe68a6..9df0fecb8f9 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -278,10 +278,9 @@ fn thread_exit_releases_the_threads_buffer_own_props() { /// inline-access cache (a key is not integer-indexed), so the key buffer can /// no longer witness the cache's release; an ordinary `Buffer` on the same /// thread does. -fn external_buffer_registrations(key: usize, view: usize) -> [bool; 4] { - let [ext, u8a, meta] = perry_runtime::buffer::external_registries_hold_for_test(key); +fn external_buffer_registrations(key: usize, view: usize) -> [bool; 3] { + let [u8a, meta] = perry_runtime::buffer::external_registries_hold_for_test(key); [ - ext, u8a, meta, perry_runtime::buffer::u8_inline_cache_holds_for_test(view), @@ -304,7 +303,7 @@ fn external_buffer_registrations(key: usize, view: usize) -> [bool; 4] { /// Holds `(key, view, seen)`: the CryptoKey buffer, the cache-admitted byte /// view, and the verdict. #[allow(clippy::type_complexity)] -static EXTERNAL_BUFFER_EXIT_PROBE: std::sync::Mutex<(usize, usize, Option<[bool; 4]>)> = +static EXTERNAL_BUFFER_EXIT_PROBE: std::sync::Mutex<(usize, usize, Option<[bool; 3]>)> = std::sync::Mutex::new((0, 0, None)); fn record_external_buffer_release(freed: &perry_runtime::arena::thread_exit::FreedRanges) { @@ -322,9 +321,8 @@ fn record_external_buffer_release(freed: &perry_runtime::arena::thread_exit::Fre /// Verdict on what [`record_external_buffer_release`] saw: `Err` names the /// first table that still held the dead buffer when its thread was released. -fn external_buffer_release_verdict(seen: Option<[bool; 4]>) -> Result<(), &'static str> { - const TABLES: [&str; 4] = [ - "EXTERNAL_BUFFER_REGISTRY outlived the thread", +fn external_buffer_release_verdict(seen: Option<[bool; 3]>) -> Result<(), &'static str> { + const TABLES: [&str; 3] = [ "EXTERNAL_UINT8ARRAY_REGISTRY outlived the thread", "EXTERNAL_CRYPTO_KEY_META_REGISTRY outlived the thread", "PERRY_U8_INLINE_CACHE outlived the thread", @@ -342,7 +340,7 @@ fn thread_exit_releases_the_threads_external_buffer_registrations() { let scope = RuntimeHandleScope::new(); let buf = scope.root_raw_mut_ptr(perry_runtime::buffer::js_buffer_alloc(16, 0)); let addr = buf.get_raw_mut_ptr::() as usize; - // webcrypto's CryptoKey registration: all three external registries. + // webcrypto's CryptoKey registration: both external registries. // This also registers their thread-exit hook, so it runs before the // probe registered below. unsafe { js_buffer_mark_as_crypto_key_external(addr, 1, 0, 1, 1, 0, 0) }; @@ -373,7 +371,7 @@ fn thread_exit_releases_the_threads_external_buffer_registrations() { "key material must never enter the inline element-access cache" ); assert_eq!( - alive, [true; 4], + alive, [true; 3], "every registration must exist while its thread lives" ); if let Err(table) = external_buffer_release_verdict(seen) { @@ -393,20 +391,20 @@ fn external_buffer_verdict_is_taken_at_release_not_by_address() { let addr = buf.get_raw_mut_ptr::() as usize; unsafe { js_buffer_mark_as_crypto_key_external(addr, 1, 0, 1, 1, 0, 0) }; assert!( - perry_runtime::buffer::is_external_buffer(addr), + perry_runtime::buffer::external_registries_hold_for_test(addr)[0], "the newcomer is registered, so an address-only check would fail" ); assert_eq!( - external_buffer_release_verdict(Some([false; 4])), + external_buffer_release_verdict(Some([false; 3])), Ok(()), "a newcomer at a released address was reported as the dead buffer" ); assert_eq!( - external_buffer_release_verdict(Some([true, false, false, false])), - Err("EXTERNAL_BUFFER_REGISTRY outlived the thread") + external_buffer_release_verdict(Some([true, false, false])), + Err("EXTERNAL_UINT8ARRAY_REGISTRY outlived the thread") ); assert_eq!( - external_buffer_release_verdict(Some([false, false, false, true])), + external_buffer_release_verdict(Some([false, false, true])), Err("PERRY_U8_INLINE_CACHE outlived the thread") ); assert!( diff --git a/crates/perry-stdlib/src/webcrypto/util.rs b/crates/perry-stdlib/src/webcrypto/util.rs index 98f0d02a019..f8790b06874 100644 --- a/crates/perry-stdlib/src/webcrypto/util.rs +++ b/crates/perry-stdlib/src/webcrypto/util.rs @@ -60,13 +60,13 @@ pub(super) use ml_kem::pkcs8::{ }; pub(super) use perry_runtime::{ - buffer::{buffer_alloc, buffer_data_mut, is_registered_buffer, BufferHeader}, + buffer::{buffer_data_mut, is_registered_buffer, BufferHeader}, js_object_alloc, js_object_set_field_by_name, js_promise_resolved, JSValue, Promise, StringHeader, }; extern "C" { - fn js_buffer_register_external(addr: usize); + fn js_buffer_alloc_unsafe(size: i32) -> *mut BufferHeader; fn js_buffer_mark_as_uint8array_external(addr: usize); fn js_buffer_mark_as_crypto_key_external( addr: usize, @@ -944,7 +944,10 @@ pub(super) fn require_usage( /// Allocate a fresh Buffer marked as Uint8Array (so `instanceof Uint8Array` /// is true and `new Uint8Array(buf)` memcpy's correctly), copy `bytes` in. pub(super) unsafe fn alloc_uint8array_from_slice(bytes: &[u8]) -> *mut BufferHeader { - let buf = buffer_alloc(bytes.len() as u32); + // Allocate through the runtime provider's C ABI. A separately packaged + // stdlib must not depend on registering a Rust-allocated cell afterwards. + // A negative size makes the runtime raise its ordinary allocation error. + let buf = js_buffer_alloc_unsafe(i32::try_from(bytes.len()).unwrap_or(-1)); if buf.is_null() { return buf; } @@ -953,7 +956,6 @@ pub(super) unsafe fn alloc_uint8array_from_slice(bytes: &[u8]) -> *mut BufferHea let dst = buffer_data_mut(buf); std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()); } - js_buffer_register_external(buf as usize); js_buffer_mark_as_uint8array_external(buf as usize); buf } diff --git a/scripts/run_honest_external_buffer.sh b/scripts/run_honest_external_buffer.sh new file mode 100755 index 00000000000..1aef401f434 --- /dev/null +++ b/scripts/run_honest_external_buffer.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Build a local Node-API fixture; no package installation or network access. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +fixture="$root/.lane-tmp/honest-external-buffer" +mkdir -p "$fixture/node_modules/fixture-external-buffer" +package="$fixture/node_modules/fixture-external-buffer" +cat > "$fixture/package.json" <<'JSON' +{"name":"honest-external-buffer","private":true,"perry":{"compilePackages":["fixture-external-buffer"],"allow":{"compilePackages":["fixture-external-buffer"]},"nativeAddons":["fixture-external-buffer"]}} +JSON +cat > "$package/package.json" <<'JSON' +{"name":"fixture-external-buffer","version":"1.0.0","main":"index.js"} +JSON +printf '%s\n' 'module.exports = require("./addon.node");' > "$package/index.js" +flags=(-shared) +if [[ $(uname -s) == Darwin ]]; then flags+=(-undefined dynamic_lookup); else flags+=(-fPIC); fi +"${CC:-cc}" "${flags[@]}" "$root/test-files/fixtures/honest-external-buffer/addon.c" -o "$package/addon.node" +cp "$root/test-files/fixtures/honest-external-buffer/surface.ts" "$fixture/main.ts" +cd "$fixture" +node --experimental-strip-types main.ts > node.out +if [[ ${1:-} == --node-only ]]; then cat node.out; exit 0; fi +# Perry uses a fixed optional-runtime subdirectory for native addons. Alias +# that path to this worktree's single physical target; do not duplicate it. +if [[ ! -e "$root/target/perry-optional-runtime" ]]; then + ln -s . "$root/target/perry-optional-runtime" +fi +if [[ ! -L "$root/target/perry-optional-runtime" ]]; then + echo "optional-runtime must alias the single target directory" >&2 + exit 1 +fi +# An unsuccessful compile must never leave an old run available as evidence. +rm -f perry.out +# Default target/ remains the only cargo target directory. Supply matching +# compiler and static wrappers with node-api-host enabled. +PERRY_NO_UPDATE_CHECK=1 PERRY_NO_TELEMETRY=1 PERRY_WORKSPACE_ROOT="$root" \ + PERRY_RUNTIME_DIR="$root/target/release" \ + "$root/target/release/perry" compile main.ts --no-cache --no-auto-optimize -o app +./app > perry.out +diff -u node.out perry.out diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index e08f03f0932..811e066be7a 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -2019,15 +2019,6 @@ "verdict": "process_global_allocation", "why": "Set of AsyncHook handles, each a never-freed Box::into_raw(AsyncHookHandle{index}) pointer (async_hooks.rs:740-741) — std alloc, not arena." }, - { - "file": "crates/perry-runtime/src/buffer/header.rs", - "names": [ - "EXTERNAL_BUFFER_REGISTRY" - ], - "verdict": "thread_exit_invalidated", - "why": "Addresses of externally-registered buffers, removed only by the dead-buffer finalizer or re-registration; freed addresses are dropped at thread exit.", - "hook": "release_external_buffer_registries_in_freed_ranges" - }, { "file": "crates/perry-runtime/src/buffer/header.rs", "names": [ @@ -2060,7 +2051,7 @@ "BUFFER_LIKE_ADDR_WINDOW" ], "verdict": "conservative_filter", - "why": "Monotone lo/hi address window over every buffer-like registration; a stale bound only admits more addresses to the slow path. Authoritative: BUFFER_REGISTRY (thread-local), EXTERNAL_BUFFER_REGISTRY, shared_sab's registry via is_registered_buffer_slow." + "why": "Monotone lo/hi address window over every buffer-like registration; a stale bound only admits more addresses to the slow path. Authoritative: BUFFER_REGISTRY (thread-local), shared_sab's registry via is_registered_buffer_slow." }, { "file": "crates/perry-runtime/src/buffer/header.rs", diff --git a/test-files/fixtures/honest-external-buffer/addon.c b/test-files/fixtures/honest-external-buffer/addon.c new file mode 100644 index 00000000000..19cb349817a --- /dev/null +++ b/test-files/fixtures/honest-external-buffer/addon.c @@ -0,0 +1,47 @@ +#include +#include +#include + +typedef void *napi_env; +typedef void *napi_value; +typedef void *napi_callback_info; +typedef int32_t napi_status; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef void (*napi_finalize)(napi_env, void *, void *); +extern napi_status napi_create_external_buffer(napi_env, size_t, void *, napi_finalize, void *, napi_value *); +extern napi_status napi_create_function(napi_env, const char *, size_t, napi_callback, void *, napi_value *); +extern napi_status napi_set_named_property(napi_env, napi_value, const char *, napi_value); +extern napi_status napi_create_int32(napi_env, int32_t, napi_value *); +static int32_t released; +static void release_bytes(napi_env env, void *bytes, void *hint) { + (void)env; (void)hint; + free(bytes); + released++; +} +static napi_value create(napi_env env, napi_callback_info info) { + (void)info; + unsigned char *bytes = malloc(3); + if (!bytes) return 0; + bytes[0] = 65; bytes[1] = 66; bytes[2] = 67; + napi_value value = 0; + if (napi_create_external_buffer(env, 3, bytes, release_bytes, 0, &value)) { + free(bytes); + return 0; + } + return value; +} +static napi_value count(napi_env env, napi_callback_info info) { + (void)info; + napi_value value = 0; + napi_create_int32(env, released, &value); + return value; +} +__attribute__((visibility("default"))) int32_t node_api_module_get_api_version_v1(void) { return 8; } +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_value fn = 0; + if (napi_create_function(env, "create", 6, create, 0, &fn) || + napi_set_named_property(env, exports, "create", fn) || + napi_create_function(env, "released", 8, count, 0, &fn) || + napi_set_named_property(env, exports, "released", fn)) return 0; + return exports; +} diff --git a/test-files/fixtures/honest-external-buffer/surface.ts b/test-files/fixtures/honest-external-buffer/surface.ts new file mode 100644 index 00000000000..14d617365e2 --- /dev/null +++ b/test-files/fixtures/honest-external-buffer/surface.ts @@ -0,0 +1,30 @@ +// Real napi_create_external_buffer producer. Prepare fixture-addon with +// scripts/run_honest_external_buffer.sh before running this differential file. +const addon = require('fixture-external-buffer'); +const first = addon.create(); +const second = addon.create(); +const same = first; +console.log('identity', first !== second, same === first); +const map = new Map([[first, 1], [second, 2]]); +const set = new Set([first, second, same]); +const weak = new WeakMap([[first, 3], [second, 4]]); +console.log('keys', map.size, set.size, map.get(same), weak.get(first), weak.get(second)); +console.log('brands', typeof first, first instanceof Buffer, first instanceof Uint8Array, Buffer.isBuffer(first), ArrayBuffer.isView(first)); +console.log('own', Object.keys(first).join(','), Object.getOwnPropertyNames(first).join(',')); +console.log('json', JSON.stringify(first)); +console.log('string', String(first), Object.prototype.toString.call(first)); +console.log('prototype', Object.getPrototypeOf(first) === Buffer.prototype); +let rejected = false; +try { Uint8Array.prototype.subarray.call({}, 0); } +catch (error) { rejected = error instanceof TypeError; } +console.log('brand check', rejected); +const view = first.subarray(1); +view[0] = 90; +console.log('bytes', first[0], first[1], first[2], String(first), String(second)); +console.log('backing', first.buffer === first.buffer, view.buffer === first.buffer); +function indexed(value: Uint8Array): number { + value[2] = 88; + return value[0] + value[2]; +} +console.log('typed access', indexed(second), String(second)); +console.log('native lifetime', addon.released()); From 7e5782a36dbe628cfbf1fd0ae226bd69059d0dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 30 Sep 2026 00:10:30 +0000 Subject: [PATCH 6/6] fix(runtime): AbortSignal.abort/any/timeout are own data properties of the constructor The kept-receiver rule for built-in constructors without an intrinsic static surface reads the real constructor object; the call forms are codegen intrinsics, so a value read (const f = AbortSignal.abort) found nothing. Install the three statics like the other built-in constructors do. --- .../src/object/global_this/install_static.rs | 59 +++++++++++++++++++ ...est_gap_abort_signal_static_value_reads.ts | 20 +++++++ 2 files changed, 79 insertions(+) create mode 100644 test-files/test_gap_abort_signal_static_value_reads.ts diff --git a/crates/perry-runtime/src/object/global_this/install_static.rs b/crates/perry-runtime/src/object/global_this/install_static.rs index 0569dca4329..6ba170de8ca 100644 --- a/crates/perry-runtime/src/object/global_this/install_static.rs +++ b/crates/perry-runtime/src/object/global_this/install_static.rs @@ -44,6 +44,42 @@ pub extern "C" fn js_promise_static_function_value(name_ptr: *const u8, name_len value } +extern "C" fn abort_signal_abort_thunk( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + reason: f64, +) -> f64 { + let signal = crate::url::abort::js_abort_signal_abort(reason); + crate::value::js_nanbox_pointer(signal as i64) +} + +extern "C" fn abort_signal_timeout_thunk( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + ms: f64, +) -> f64 { + let signal = crate::url::abort::js_abort_signal_timeout(ms); + crate::value::js_nanbox_pointer(signal as i64) +} + +extern "C" fn abort_signal_any_thunk( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + signals: f64, +) -> f64 { + let is_array = crate::array::js_array_is_array(signals).to_bits() == 0x7FFC_0000_0000_0004; + let array = if is_array { + crate::value::js_nanbox_get_pointer(signals) as *mut crate::array::ArrayHeader + } else { + std::ptr::null_mut() + }; + if array.is_null() { + crate::validators::throw_invalid_arg_type("signals", "Array", signals); + } + let signal = crate::url::abort::js_abort_signal_any(array); + crate::value::js_nanbox_pointer(signal as i64) +} + extern "C" fn url_can_parse_thunk( _closure: *const crate::closure::ClosureHeader, _this: crate::closure::JsThis, @@ -631,6 +667,29 @@ pub(crate) fn install_builtin_constructor_statics( 1, ); } + "AbortSignal" => { + // The call forms are codegen intrinsics; these are the real own + // data properties a value read (`const f = AbortSignal.abort`) + // and reflection see. + install_constructor_static( + ctor, + "abort", + crate::fn_info!(abort_signal_abort_thunk, 1; with_declared(0)), + 0, + ); + install_constructor_static( + ctor, + "timeout", + crate::fn_info!(abort_signal_timeout_thunk, 1; with_declared(1)), + 1, + ); + install_constructor_static( + ctor, + "any", + crate::fn_info!(abort_signal_any_thunk, 1; with_declared(1)), + 1, + ); + } "Response" => { install_constructor_static( ctor, diff --git a/test-files/test_gap_abort_signal_static_value_reads.ts b/test-files/test_gap_abort_signal_static_value_reads.ts new file mode 100644 index 00000000000..1f3fb34d119 --- /dev/null +++ b/test-files/test_gap_abort_signal_static_value_reads.ts @@ -0,0 +1,20 @@ +// AbortSignal statics read as VALUES (not just called) are real own data +// properties of the constructor: a kept-receiver read must find them. +const a = AbortSignal.abort, t = AbortSignal.timeout, n = AbortSignal.any; +console.log(typeof a, typeof t, typeof n); +console.log(a.name, a.length, t.name, t.length, n.name, n.length); +console.log(Object.getOwnPropertyNames(AbortSignal).sort().join()); +const s = a("why"); +console.log(s.aborted, s.reason); +const c = new AbortController(); +const m = n([c.signal]); +console.log(m.aborted); +c.abort("r"); +console.log(m.aborted, m.reason); +try { + n(5 as any); +} catch (e: any) { + console.log(e.code, e.name); +} +const d = Object.getOwnPropertyDescriptor(AbortSignal, "abort")!; +console.log(d.writable, d.enumerable, d.configurable);